refactor(vim): finalize navigation boundaries
- commit
- 4bafd5c0e3f506845ce1070d65556f3faccfa3c5
- author
- brice · · 2026-08-14 14:41 UTC
- parent
- 0afb76e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
18 files · +207 -1514
M docs/config/index.md +3 -0
| @@ -404,6 +404,8 @@ cancel = ["escape"] | |||
| 404 | 404 | |-----|------|---------|-------------| | |
| 405 | 405 | | `workspace.vim_mode.activation_shortcut` | string | `"ctrl+y"` | Vim mode activation key | | |
| 406 | 406 | | `workspace.vim_mode.timeout_ms` | int | `0` | Vim mode timeout in milliseconds (`0` disables auto-timeout) | | |
| 407 | + | | `workspace.vim_mode.sequence_timeout_ms` | int | `500` | Timeout for ambiguous multi-key Vim sequences | | |
| 408 | + | | `workspace.vim_mode.preload_accessibility` | bool | `false` | Pre-enable browser accessibility when a WebView is shown | | |
| 407 | 409 | | `workspace.vim_mode.actions` | map | See below | Action→binding mappings | | |
| 408 | 410 | ||
| 409 | 411 | **Default vim mode actions:** | |
| @@ -461,6 +463,7 @@ Notes: | |||
| 461 | 463 | - The default `timeout_ms = 0` means Vim Mode stays active until you exit it or focus moves into an editable/browser UI context. `Ctrl+Y` can still activate it when a page input is already focused. | |
| 462 | 464 | - Arrow keys still use the browser engine's native page-navigation path while Vim Mode is active. | |
| 463 | 465 | - `Tab` and `Shift+Tab` keep focus traversal inside the page while a page input is focused. | |
| 466 | + | - Live input, heading, and page-focus navigation currently use the CEF engine path; WebKit fallback supports the Vim scroll commands while equivalent semantic navigation is pending. | |
| 464 | 467 | - Other app-level shortcuts stay suspended until Vim Mode exits, except for the Vim Mode toggle itself. | |
| 465 | 468 | - `workspace.styling.pane_mode_color`, `workspace.styling.transition_duration`, and `workspace.styling.mode_indicator_toaster_enabled` control the pane-local Vim Mode visuals. | |
| 466 | 469 | - Scroll execution: CEF and WebKit both use the shared `BuildPageScrollByJS` resolver when the page is ready. Each step starts under the viewport center, walks up through ancestors that can move in the requested direction, and hands scrolling to the document when a nested container reaches its boundary. The application repeater owns held-key cadence; each engine executes one immediate scroll step per tick. Cross-origin frame contents remain best-effort. CEF may use native precision-wheel input only as a pre-frame fallback before the browser/main frame is ready. | |
| @@ -404,6 +404,8 @@ cancel = ["escape"] | |||||
| 404 | |-----|------|---------|-------------| | 404 | |-----|------|---------|-------------| | ||
| 405 | | `workspace.vim_mode.activation_shortcut` | string | `"ctrl+y"` | Vim mode activation key | | 405 | | `workspace.vim_mode.activation_shortcut` | string | `"ctrl+y"` | Vim mode activation key | | ||
| 406 | | `workspace.vim_mode.timeout_ms` | int | `0` | Vim mode timeout in milliseconds (`0` disables auto-timeout) | | 406 | | `workspace.vim_mode.timeout_ms` | int | `0` | Vim mode timeout in milliseconds (`0` disables auto-timeout) | | ||
| 407 | + | | `workspace.vim_mode.sequence_timeout_ms` | int | `500` | Timeout for ambiguous multi-key Vim sequences | | |||
| 408 | + | | `workspace.vim_mode.preload_accessibility` | bool | `false` | Pre-enable browser accessibility when a WebView is shown | | |||
| 407 | | `workspace.vim_mode.actions` | map | See below | Action→binding mappings | | 409 | | `workspace.vim_mode.actions` | map | See below | Action→binding mappings | | ||
| 408 | 410 | ||||
| 409 | **Default vim mode actions:** | 411 | **Default vim mode actions:** | ||
| @@ -461,6 +463,7 @@ Notes: | |||||
| 461 | - The default `timeout_ms = 0` means Vim Mode stays active until you exit it or focus moves into an editable/browser UI context. `Ctrl+Y` can still activate it when a page input is already focused. | 463 | - The default `timeout_ms = 0` means Vim Mode stays active until you exit it or focus moves into an editable/browser UI context. `Ctrl+Y` can still activate it when a page input is already focused. | ||
| 462 | - Arrow keys still use the browser engine's native page-navigation path while Vim Mode is active. | 464 | - Arrow keys still use the browser engine's native page-navigation path while Vim Mode is active. | ||
| 463 | - `Tab` and `Shift+Tab` keep focus traversal inside the page while a page input is focused. | 465 | - `Tab` and `Shift+Tab` keep focus traversal inside the page while a page input is focused. | ||
| 466 | + | - Live input, heading, and page-focus navigation currently use the CEF engine path; WebKit fallback supports the Vim scroll commands while equivalent semantic navigation is pending. | |||
| 464 | - Other app-level shortcuts stay suspended until Vim Mode exits, except for the Vim Mode toggle itself. | 467 | - Other app-level shortcuts stay suspended until Vim Mode exits, except for the Vim Mode toggle itself. | ||
| 465 | - `workspace.styling.pane_mode_color`, `workspace.styling.transition_duration`, and `workspace.styling.mode_indicator_toaster_enabled` control the pane-local Vim Mode visuals. | 468 | - `workspace.styling.pane_mode_color`, `workspace.styling.transition_duration`, and `workspace.styling.mode_indicator_toaster_enabled` control the pane-local Vim Mode visuals. | ||
| 466 | - Scroll execution: CEF and WebKit both use the shared `BuildPageScrollByJS` resolver when the page is ready. Each step starts under the viewport center, walks up through ancestors that can move in the requested direction, and hands scrolling to the document when a nested container reaches its boundary. The application repeater owns held-key cadence; each engine executes one immediate scroll step per tick. Cross-origin frame contents remain best-effort. CEF may use native precision-wheel input only as a pre-frame fallback before the browser/main frame is ready. | 469 | - Scroll execution: CEF and WebKit both use the shared `BuildPageScrollByJS` resolver when the page is ready. Each step starts under the viewport center, walks up through ancestors that can move in the requested direction, and hands scrolling to the document when a nested container reaches its boundary. The application repeater owns held-key cadence; each engine executes one immediate scroll step per tick. Cross-origin frame contents remain best-effort. CEF may use native precision-wheel input only as a pre-frame fallback before the browser/main frame is ready. | ||
M docs/reference/configuration.md +2 -0
| @@ -102,6 +102,8 @@ | |||
| 102 | 102 | | `workspace.tab_mode.actions.<action>` | []string | see defaults | tab mode key mappings | | |
| 103 | 103 | | `workspace.vim_mode.activation_shortcut` | string | `ctrl+y` | | | |
| 104 | 104 | | `workspace.vim_mode.timeout_ms` | int | `0` | >= 0 | | |
| 105 | + | | `workspace.vim_mode.sequence_timeout_ms` | int | `500` | >= 0; timeout for ambiguous multi-key Vim sequences | | |
| 106 | + | | `workspace.vim_mode.preload_accessibility` | bool | `false` | Pre-enable browser accessibility when a WebView is shown | | |
| 105 | 107 | | `workspace.vim_mode.actions.<action>` | object | see defaults | vim mode action object with `keys` ([]string) and `desc` (string) | | |
| 106 | 108 | | `workspace.resize_mode.activation_shortcut` | string | `ctrl+n` | | | |
| 107 | 109 | | `workspace.resize_mode.timeout_ms` | int | `3000` | | | |
| @@ -102,6 +102,8 @@ | |||||
| 102 | | `workspace.tab_mode.actions.<action>` | []string | see defaults | tab mode key mappings | | 102 | | `workspace.tab_mode.actions.<action>` | []string | see defaults | tab mode key mappings | | ||
| 103 | | `workspace.vim_mode.activation_shortcut` | string | `ctrl+y` | | | 103 | | `workspace.vim_mode.activation_shortcut` | string | `ctrl+y` | | | ||
| 104 | | `workspace.vim_mode.timeout_ms` | int | `0` | >= 0 | | 104 | | `workspace.vim_mode.timeout_ms` | int | `0` | >= 0 | | ||
| 105 | + | | `workspace.vim_mode.sequence_timeout_ms` | int | `500` | >= 0; timeout for ambiguous multi-key Vim sequences | | |||
| 106 | + | | `workspace.vim_mode.preload_accessibility` | bool | `false` | Pre-enable browser accessibility when a WebView is shown | | |||
| 105 | | `workspace.vim_mode.actions.<action>` | object | see defaults | vim mode action object with `keys` ([]string) and `desc` (string) | | 107 | | `workspace.vim_mode.actions.<action>` | object | see defaults | vim mode action object with `keys` ([]string) and `desc` (string) | | ||
| 106 | | `workspace.resize_mode.activation_shortcut` | string | `ctrl+n` | | | 108 | | `workspace.resize_mode.activation_shortcut` | string | `ctrl+n` | | | ||
| 107 | | `workspace.resize_mode.timeout_ms` | int | `3000` | | | 109 | | `workspace.resize_mode.timeout_ms` | int | `3000` | | | ||
M docs/reference/keybindings.md +1 -1
| @@ -59,7 +59,7 @@ Vim Mode is an explicit Vim-style navigation mode for the active pane only. It a | |||
| 59 | 59 | ||
| 60 | 60 | CEF and WebKit execute Vim Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with the shared `BuildPageScrollByJS` resolver. Each step starts under the viewport center, walks up through ancestors that can move in the requested direction, and hands scrolling to the document when a nested container reaches its boundary. Cross-origin frame contents remain best-effort. | |
| 61 | 61 | ||
| 62 | - | `gi` focuses the next visible, editable page input. With no eligible input focused it starts at the first one; repeated `gi` commands cycle through the inputs. `]]` and `[[` move through visible `h1`–`h6` headings and outline the selected heading with the current Dumber theme accent. When a page input is focused, `Tab` and `Shift+Tab` keep focus traversal inside the page; traversal stops safely at the page boundaries instead of moving into the host window. | |
| 62 | + | `gi` focuses the next visible, editable page input. With no eligible input focused it starts at the first one; repeated `gi` commands cycle through the inputs. `]]` and `[[` move through visible `h1`–`h6` headings and outline the selected heading with the current Dumber theme accent. When a page input is focused, `Tab` and `Shift+Tab` keep focus traversal inside the page; traversal stops safely at the page boundaries instead of moving into the host window. These live input, heading, and page-focus motions currently use the CEF engine path; the WebKit fallback supports Vim scrolling but not these semantic motions yet. | |
| 63 | 63 | ||
| 64 | 64 | | Action | Keys | | |
| 65 | 65 | |--------|------| | |
| @@ -59,7 +59,7 @@ Vim Mode is an explicit Vim-style navigation mode for the active pane only. It a | |||||
| 59 | 59 | ||||
| 60 | CEF and WebKit execute Vim Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with the shared `BuildPageScrollByJS` resolver. Each step starts under the viewport center, walks up through ancestors that can move in the requested direction, and hands scrolling to the document when a nested container reaches its boundary. Cross-origin frame contents remain best-effort. | 60 | CEF and WebKit execute Vim Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with the shared `BuildPageScrollByJS` resolver. Each step starts under the viewport center, walks up through ancestors that can move in the requested direction, and hands scrolling to the document when a nested container reaches its boundary. Cross-origin frame contents remain best-effort. | ||
| 61 | 61 | ||||
| 62 | - | `gi` focuses the next visible, editable page input. With no eligible input focused it starts at the first one; repeated `gi` commands cycle through the inputs. `]]` and `[[` move through visible `h1`–`h6` headings and outline the selected heading with the current Dumber theme accent. When a page input is focused, `Tab` and `Shift+Tab` keep focus traversal inside the page; traversal stops safely at the page boundaries instead of moving into the host window. | 62 | + | `gi` focuses the next visible, editable page input. With no eligible input focused it starts at the first one; repeated `gi` commands cycle through the inputs. `]]` and `[[` move through visible `h1`–`h6` headings and outline the selected heading with the current Dumber theme accent. When a page input is focused, `Tab` and `Shift+Tab` keep focus traversal inside the page; traversal stops safely at the page boundaries instead of moving into the host window. These live input, heading, and page-focus motions currently use the CEF engine path; the WebKit fallback supports Vim scrolling but not these semantic motions yet. |
| 63 | 63 | ||||
| 64 | | Action | Keys | | 64 | | Action | Keys | | ||
| 65 | |--------|------| | 65 | |--------|------| | ||
A internal/application/usecase/vim_navigation.go +76 -0
| @@ -0,0 +1,76 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | var errUnsupportedVimNavigationAction = errors.New("vim navigation: unsupported action") | |
| 11 | + | ||
| 12 | + | // VimNavigationUseCase maps configured Vim Mode navigation actions to optional | |
| 13 | + | // WebView capabilities. The UI layer supplies the active WebView and remains | |
| 14 | + | // responsible only for event and theme context. | |
| 15 | + | type VimNavigationUseCase struct{} | |
| 16 | + | ||
| 17 | + | // NewVimNavigationUseCase creates a stateless Vim navigation use case. | |
| 18 | + | func NewVimNavigationUseCase() *VimNavigationUseCase { | |
| 19 | + | return &VimNavigationUseCase{} | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // Execute dispatches a configured semantic Vim action to the active WebView. | |
| 23 | + | func (*VimNavigationUseCase) Execute(ctx context.Context, wv port.WebView, action string, count int, highlightColor string) error { | |
| 24 | + | if wv == nil { | |
| 25 | + | return errors.New("vim navigation: nil webview") | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | if action == "focus-input" { | |
| 29 | + | focuser, ok := wv.(port.PageInputFocuser) | |
| 30 | + | if !ok { | |
| 31 | + | return nil | |
| 32 | + | } | |
| 33 | + | focuser.FocusNextInput() | |
| 34 | + | return nil | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | var request port.SemanticNavigationRequest | |
| 38 | + | switch action { | |
| 39 | + | case "heading-next": | |
| 40 | + | request = port.SemanticNavigationRequest{ | |
| 41 | + | Target: port.SemanticNavigationTargetHeading, | |
| 42 | + | Direction: port.SemanticNavigationForward, | |
| 43 | + | Count: count, | |
| 44 | + | HighlightColor: highlightColor, | |
| 45 | + | } | |
| 46 | + | case "heading-prev": | |
| 47 | + | request = port.SemanticNavigationRequest{ | |
| 48 | + | Target: port.SemanticNavigationTargetHeading, | |
| 49 | + | Direction: port.SemanticNavigationBackward, | |
| 50 | + | Count: count, | |
| 51 | + | HighlightColor: highlightColor, | |
| 52 | + | } | |
| 53 | + | default: | |
| 54 | + | return errUnsupportedVimNavigationAction | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | navigator, ok := wv.(port.SemanticNavigable) | |
| 58 | + | if !ok { | |
| 59 | + | return nil | |
| 60 | + | } | |
| 61 | + | return navigator.NavigateSemantic(ctx, request) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // NavigatePageFocus keeps focus traversal inside a page when the active page | |
| 65 | + | // control would otherwise hand the host window a Tab event. | |
| 66 | + | func (*VimNavigationUseCase) NavigatePageFocus(wv port.WebView, backward bool) bool { | |
| 67 | + | if wv == nil { | |
| 68 | + | return false | |
| 69 | + | } | |
| 70 | + | navigator, ok := wv.(port.PageFocusNavigator) | |
| 71 | + | if !ok { | |
| 72 | + | return false | |
| 73 | + | } | |
| 74 | + | navigator.NavigatePageFocus(backward) | |
| 75 | + | return true | |
| 76 | + | } | |
| @@ -0,0 +1,76 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | var errUnsupportedVimNavigationAction = errors.New("vim navigation: unsupported action") | |||
| 11 | + | ||||
| 12 | + | // VimNavigationUseCase maps configured Vim Mode navigation actions to optional | |||
| 13 | + | // WebView capabilities. The UI layer supplies the active WebView and remains | |||
| 14 | + | // responsible only for event and theme context. | |||
| 15 | + | type VimNavigationUseCase struct{} | |||
| 16 | + | ||||
| 17 | + | // NewVimNavigationUseCase creates a stateless Vim navigation use case. | |||
| 18 | + | func NewVimNavigationUseCase() *VimNavigationUseCase { | |||
| 19 | + | return &VimNavigationUseCase{} | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // Execute dispatches a configured semantic Vim action to the active WebView. | |||
| 23 | + | func (*VimNavigationUseCase) Execute(ctx context.Context, wv port.WebView, action string, count int, highlightColor string) error { | |||
| 24 | + | if wv == nil { | |||
| 25 | + | return errors.New("vim navigation: nil webview") | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | if action == "focus-input" { | |||
| 29 | + | focuser, ok := wv.(port.PageInputFocuser) | |||
| 30 | + | if !ok { | |||
| 31 | + | return nil | |||
| 32 | + | } | |||
| 33 | + | focuser.FocusNextInput() | |||
| 34 | + | return nil | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | var request port.SemanticNavigationRequest | |||
| 38 | + | switch action { | |||
| 39 | + | case "heading-next": | |||
| 40 | + | request = port.SemanticNavigationRequest{ | |||
| 41 | + | Target: port.SemanticNavigationTargetHeading, | |||
| 42 | + | Direction: port.SemanticNavigationForward, | |||
| 43 | + | Count: count, | |||
| 44 | + | HighlightColor: highlightColor, | |||
| 45 | + | } | |||
| 46 | + | case "heading-prev": | |||
| 47 | + | request = port.SemanticNavigationRequest{ | |||
| 48 | + | Target: port.SemanticNavigationTargetHeading, | |||
| 49 | + | Direction: port.SemanticNavigationBackward, | |||
| 50 | + | Count: count, | |||
| 51 | + | HighlightColor: highlightColor, | |||
| 52 | + | } | |||
| 53 | + | default: | |||
| 54 | + | return errUnsupportedVimNavigationAction | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | navigator, ok := wv.(port.SemanticNavigable) | |||
| 58 | + | if !ok { | |||
| 59 | + | return nil | |||
| 60 | + | } | |||
| 61 | + | return navigator.NavigateSemantic(ctx, request) | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // NavigatePageFocus keeps focus traversal inside a page when the active page | |||
| 65 | + | // control would otherwise hand the host window a Tab event. | |||
| 66 | + | func (*VimNavigationUseCase) NavigatePageFocus(wv port.WebView, backward bool) bool { | |||
| 67 | + | if wv == nil { | |||
| 68 | + | return false | |||
| 69 | + | } | |||
| 70 | + | navigator, ok := wv.(port.PageFocusNavigator) | |||
| 71 | + | if !ok { | |||
| 72 | + | return false | |||
| 73 | + | } | |||
| 74 | + | navigator.NavigatePageFocus(backward) | |||
| 75 | + | return true | |||
| 76 | + | } | |||
A internal/application/usecase/vim_navigation_test.go +64 -0
| @@ -0,0 +1,64 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | type vimNavigationWebView struct { | |
| 14 | + | *portmocks.MockWebView | |
| 15 | + | focusNextInputCalls int | |
| 16 | + | pageFocusBackward []bool | |
| 17 | + | semanticRequests []port.SemanticNavigationRequest | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func (wv *vimNavigationWebView) FocusNextInput() { | |
| 21 | + | wv.focusNextInputCalls++ | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func (wv *vimNavigationWebView) NavigatePageFocus(backward bool) { | |
| 25 | + | wv.pageFocusBackward = append(wv.pageFocusBackward, backward) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func (wv *vimNavigationWebView) NavigateSemantic(_ context.Context, request port.SemanticNavigationRequest) error { | |
| 29 | + | wv.semanticRequests = append(wv.semanticRequests, request) | |
| 30 | + | return nil | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestVimNavigationUseCaseExecuteDispatchesConfiguredActions(t *testing.T) { | |
| 34 | + | wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)} | |
| 35 | + | uc := NewVimNavigationUseCase() | |
| 36 | + | ||
| 37 | + | require.NoError(t, uc.Execute(context.Background(), wv, "focus-input", 1, "")) | |
| 38 | + | require.NoError(t, uc.Execute(context.Background(), wv, "heading-prev", 3, "#aabbcc")) | |
| 39 | + | ||
| 40 | + | assert.Equal(t, 1, wv.focusNextInputCalls) | |
| 41 | + | require.Len(t, wv.semanticRequests, 1) | |
| 42 | + | assert.Equal(t, port.SemanticNavigationRequest{ | |
| 43 | + | Target: port.SemanticNavigationTargetHeading, | |
| 44 | + | Direction: port.SemanticNavigationBackward, | |
| 45 | + | Count: 3, | |
| 46 | + | HighlightColor: "#aabbcc", | |
| 47 | + | }, wv.semanticRequests[0]) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestVimNavigationUseCaseNavigatePageFocus(t *testing.T) { | |
| 51 | + | wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)} | |
| 52 | + | ||
| 53 | + | handled := NewVimNavigationUseCase().NavigatePageFocus(wv, true) | |
| 54 | + | ||
| 55 | + | assert.True(t, handled) | |
| 56 | + | assert.Equal(t, []bool{true}, wv.pageFocusBackward) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestVimNavigationUseCaseIgnoresUnsupportedCapability(t *testing.T) { | |
| 60 | + | wv := portmocks.NewMockWebView(t) | |
| 61 | + | ||
| 62 | + | require.NoError(t, NewVimNavigationUseCase().Execute(context.Background(), wv, "focus-input", 1, "")) | |
| 63 | + | assert.False(t, NewVimNavigationUseCase().NavigatePageFocus(wv, true)) | |
| 64 | + | } | |
| @@ -0,0 +1,64 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 8 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | type vimNavigationWebView struct { | |||
| 14 | + | *portmocks.MockWebView | |||
| 15 | + | focusNextInputCalls int | |||
| 16 | + | pageFocusBackward []bool | |||
| 17 | + | semanticRequests []port.SemanticNavigationRequest | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func (wv *vimNavigationWebView) FocusNextInput() { | |||
| 21 | + | wv.focusNextInputCalls++ | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func (wv *vimNavigationWebView) NavigatePageFocus(backward bool) { | |||
| 25 | + | wv.pageFocusBackward = append(wv.pageFocusBackward, backward) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func (wv *vimNavigationWebView) NavigateSemantic(_ context.Context, request port.SemanticNavigationRequest) error { | |||
| 29 | + | wv.semanticRequests = append(wv.semanticRequests, request) | |||
| 30 | + | return nil | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func TestVimNavigationUseCaseExecuteDispatchesConfiguredActions(t *testing.T) { | |||
| 34 | + | wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)} | |||
| 35 | + | uc := NewVimNavigationUseCase() | |||
| 36 | + | ||||
| 37 | + | require.NoError(t, uc.Execute(context.Background(), wv, "focus-input", 1, "")) | |||
| 38 | + | require.NoError(t, uc.Execute(context.Background(), wv, "heading-prev", 3, "#aabbcc")) | |||
| 39 | + | ||||
| 40 | + | assert.Equal(t, 1, wv.focusNextInputCalls) | |||
| 41 | + | require.Len(t, wv.semanticRequests, 1) | |||
| 42 | + | assert.Equal(t, port.SemanticNavigationRequest{ | |||
| 43 | + | Target: port.SemanticNavigationTargetHeading, | |||
| 44 | + | Direction: port.SemanticNavigationBackward, | |||
| 45 | + | Count: 3, | |||
| 46 | + | HighlightColor: "#aabbcc", | |||
| 47 | + | }, wv.semanticRequests[0]) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func TestVimNavigationUseCaseNavigatePageFocus(t *testing.T) { | |||
| 51 | + | wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)} | |||
| 52 | + | ||||
| 53 | + | handled := NewVimNavigationUseCase().NavigatePageFocus(wv, true) | |||
| 54 | + | ||||
| 55 | + | assert.True(t, handled) | |||
| 56 | + | assert.Equal(t, []bool{true}, wv.pageFocusBackward) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func TestVimNavigationUseCaseIgnoresUnsupportedCapability(t *testing.T) { | |||
| 60 | + | wv := portmocks.NewMockWebView(t) | |||
| 61 | + | ||||
| 62 | + | require.NoError(t, NewVimNavigationUseCase().Execute(context.Background(), wv, "focus-input", 1, "")) | |||
| 63 | + | assert.False(t, NewVimNavigationUseCase().NavigatePageFocus(wv, true)) | |||
| 64 | + | } | |||
D internal/domain/docmodel/block_motions_test.go +0 -256
| @@ -1,256 +0,0 @@ | |||
| 1 | - | package docmodel | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "reflect" | |
| 5 | - | "testing" | |
| 6 | - | ) | |
| 7 | - | ||
| 8 | - | func TestStrictNextBlockOfKind(t *testing.T) { | |
| 9 | - | doc := blockKindMotionDocument(t) | |
| 10 | - | ||
| 11 | - | tests := []struct { | |
| 12 | - | name string | |
| 13 | - | from Cursor | |
| 14 | - | kind BlockKind | |
| 15 | - | count int | |
| 16 | - | want Cursor | |
| 17 | - | ok bool | |
| 18 | - | }{ | |
| 19 | - | { | |
| 20 | - | name: "next code block", | |
| 21 | - | from: Cursor{BlockID: "p0"}, | |
| 22 | - | kind: BlockKindCode, | |
| 23 | - | want: Cursor{BlockID: "code1"}, | |
| 24 | - | ok: true, | |
| 25 | - | }, | |
| 26 | - | { | |
| 27 | - | name: "next table block", | |
| 28 | - | from: Cursor{BlockID: "p0"}, | |
| 29 | - | kind: BlockKindTable, | |
| 30 | - | want: Cursor{BlockID: "table1"}, | |
| 31 | - | ok: true, | |
| 32 | - | }, | |
| 33 | - | { | |
| 34 | - | name: "next image block", | |
| 35 | - | from: Cursor{BlockID: "p0"}, | |
| 36 | - | kind: BlockKindImage, | |
| 37 | - | want: Cursor{BlockID: "image1"}, | |
| 38 | - | ok: true, | |
| 39 | - | }, | |
| 40 | - | { | |
| 41 | - | name: "count below one means one", | |
| 42 | - | from: Cursor{BlockID: "p1"}, | |
| 43 | - | kind: BlockKindTable, | |
| 44 | - | count: -2, | |
| 45 | - | want: Cursor{BlockID: "table1"}, | |
| 46 | - | ok: true, | |
| 47 | - | }, | |
| 48 | - | { | |
| 49 | - | name: "strict next does not match current block", | |
| 50 | - | from: Cursor{BlockID: "code1"}, | |
| 51 | - | kind: BlockKindCode, | |
| 52 | - | count: 1, | |
| 53 | - | want: Cursor{BlockID: "code2"}, | |
| 54 | - | ok: true, | |
| 55 | - | }, | |
| 56 | - | { | |
| 57 | - | name: "count selects nth next block of kind", | |
| 58 | - | from: Cursor{BlockID: "p0"}, | |
| 59 | - | kind: BlockKindCode, | |
| 60 | - | count: 2, | |
| 61 | - | want: Cursor{BlockID: "code2"}, | |
| 62 | - | ok: true, | |
| 63 | - | }, | |
| 64 | - | { | |
| 65 | - | name: "no later matching kind returns false", | |
| 66 | - | from: Cursor{BlockID: "code2"}, | |
| 67 | - | kind: BlockKindCode, | |
| 68 | - | }, | |
| 69 | - | { | |
| 70 | - | name: "count past available matches returns false", | |
| 71 | - | from: Cursor{BlockID: "p0"}, | |
| 72 | - | kind: BlockKindImage, | |
| 73 | - | count: 3, | |
| 74 | - | }, | |
| 75 | - | { | |
| 76 | - | name: "zero cursor returns false", | |
| 77 | - | from: Cursor{}, | |
| 78 | - | kind: BlockKindCode, | |
| 79 | - | }, | |
| 80 | - | { | |
| 81 | - | name: "missing cursor returns false", | |
| 82 | - | from: Cursor{BlockID: "missing"}, | |
| 83 | - | kind: BlockKindCode, | |
| 84 | - | }, | |
| 85 | - | { | |
| 86 | - | name: "empty kind returns false", | |
| 87 | - | from: Cursor{BlockID: "p0"}, | |
| 88 | - | kind: "", | |
| 89 | - | }, | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | for _, tt := range tests { | |
| 93 | - | t.Run(tt.name, func(t *testing.T) { | |
| 94 | - | before := doc.Blocks() | |
| 95 | - | got, ok := doc.NextBlockOfKind(tt.from, tt.kind, tt.count) | |
| 96 | - | if ok != tt.ok || got != tt.want { | |
| 97 | - | t.Fatalf("NextBlockOfKind(%#v, %q, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.kind, tt.count, got, ok, tt.want, tt.ok) | |
| 98 | - | } | |
| 99 | - | if after := doc.Blocks(); !reflect.DeepEqual(after, before) { | |
| 100 | - | t.Fatalf("NextBlockOfKind mutated document: before %#v after %#v", before, after) | |
| 101 | - | } | |
| 102 | - | }) | |
| 103 | - | } | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | func TestStrictPreviousBlockOfKind(t *testing.T) { | |
| 107 | - | doc := blockKindMotionDocument(t) | |
| 108 | - | ||
| 109 | - | tests := []struct { | |
| 110 | - | name string | |
| 111 | - | from Cursor | |
| 112 | - | kind BlockKind | |
| 113 | - | count int | |
| 114 | - | want Cursor | |
| 115 | - | ok bool | |
| 116 | - | }{ | |
| 117 | - | { | |
| 118 | - | name: "previous code block", | |
| 119 | - | from: Cursor{BlockID: "p2"}, | |
| 120 | - | kind: BlockKindCode, | |
| 121 | - | want: Cursor{BlockID: "code2"}, | |
| 122 | - | ok: true, | |
| 123 | - | }, | |
| 124 | - | { | |
| 125 | - | name: "previous table block", | |
| 126 | - | from: Cursor{BlockID: "p2"}, | |
| 127 | - | kind: BlockKindTable, | |
| 128 | - | want: Cursor{BlockID: "table2"}, | |
| 129 | - | ok: true, | |
| 130 | - | }, | |
| 131 | - | { | |
| 132 | - | name: "previous image block", | |
| 133 | - | from: Cursor{BlockID: "p2"}, | |
| 134 | - | kind: BlockKindImage, | |
| 135 | - | want: Cursor{BlockID: "image2"}, | |
| 136 | - | ok: true, | |
| 137 | - | }, | |
| 138 | - | { | |
| 139 | - | name: "count below one means one", | |
| 140 | - | from: Cursor{BlockID: "p2"}, | |
| 141 | - | kind: BlockKindTable, | |
| 142 | - | count: 0, | |
| 143 | - | want: Cursor{BlockID: "table2"}, | |
| 144 | - | ok: true, | |
| 145 | - | }, | |
| 146 | - | { | |
| 147 | - | name: "strict previous does not match current block", | |
| 148 | - | from: Cursor{BlockID: "image2"}, | |
| 149 | - | kind: BlockKindImage, | |
| 150 | - | count: 1, | |
| 151 | - | want: Cursor{BlockID: "image1"}, | |
| 152 | - | ok: true, | |
| 153 | - | }, | |
| 154 | - | { | |
| 155 | - | name: "count selects nth previous block of kind", | |
| 156 | - | from: Cursor{BlockID: "p2"}, | |
| 157 | - | kind: BlockKindTable, | |
| 158 | - | count: 2, | |
| 159 | - | want: Cursor{BlockID: "table1"}, | |
| 160 | - | ok: true, | |
| 161 | - | }, | |
| 162 | - | { | |
| 163 | - | name: "no earlier matching kind returns false", | |
| 164 | - | from: Cursor{BlockID: "code1"}, | |
| 165 | - | kind: BlockKindCode, | |
| 166 | - | }, | |
| 167 | - | { | |
| 168 | - | name: "count before available matches returns false", | |
| 169 | - | from: Cursor{BlockID: "p2"}, | |
| 170 | - | kind: BlockKindImage, | |
| 171 | - | count: 3, | |
| 172 | - | }, | |
| 173 | - | { | |
| 174 | - | name: "zero cursor returns false", | |
| 175 | - | from: Cursor{}, | |
| 176 | - | kind: BlockKindCode, | |
| 177 | - | }, | |
| 178 | - | { | |
| 179 | - | name: "missing cursor returns false", | |
| 180 | - | from: Cursor{BlockID: "missing"}, | |
| 181 | - | kind: BlockKindCode, | |
| 182 | - | }, | |
| 183 | - | { | |
| 184 | - | name: "empty kind returns false", | |
| 185 | - | from: Cursor{BlockID: "p2"}, | |
| 186 | - | kind: "", | |
| 187 | - | }, | |
| 188 | - | } | |
| 189 | - | ||
| 190 | - | for _, tt := range tests { | |
| 191 | - | t.Run(tt.name, func(t *testing.T) { | |
| 192 | - | before := doc.Blocks() | |
| 193 | - | got, ok := doc.PreviousBlockOfKind(tt.from, tt.kind, tt.count) | |
| 194 | - | if ok != tt.ok || got != tt.want { | |
| 195 | - | t.Fatalf("PreviousBlockOfKind(%#v, %q, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.kind, tt.count, got, ok, tt.want, tt.ok) | |
| 196 | - | } | |
| 197 | - | if after := doc.Blocks(); !reflect.DeepEqual(after, before) { | |
| 198 | - | t.Fatalf("PreviousBlockOfKind mutated document: before %#v after %#v", before, after) | |
| 199 | - | } | |
| 200 | - | }) | |
| 201 | - | } | |
| 202 | - | } | |
| 203 | - | ||
| 204 | - | func TestBlockOfKindMotionsOnEmptyAndUnmatchedDocuments(t *testing.T) { | |
| 205 | - | tests := []struct { | |
| 206 | - | name string | |
| 207 | - | blocks []Block | |
| 208 | - | from Cursor | |
| 209 | - | kind BlockKind | |
| 210 | - | }{ | |
| 211 | - | { | |
| 212 | - | name: "empty document", | |
| 213 | - | from: Cursor{BlockID: "missing"}, | |
| 214 | - | kind: BlockKindCode, | |
| 215 | - | }, | |
| 216 | - | { | |
| 217 | - | name: "document without requested structural kind", | |
| 218 | - | blocks: []Block{ | |
| 219 | - | {ID: "p0", Kind: BlockKind("paragraph")}, | |
| 220 | - | {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1}, | |
| 221 | - | {ID: "p1", Kind: BlockKind("list_item")}, | |
| 222 | - | }, | |
| 223 | - | from: Cursor{BlockID: "p0"}, | |
| 224 | - | kind: BlockKindImage, | |
| 225 | - | }, | |
| 226 | - | } | |
| 227 | - | ||
| 228 | - | for _, tt := range tests { | |
| 229 | - | t.Run(tt.name, func(t *testing.T) { | |
| 230 | - | doc := mustDocument(t, tt.blocks) | |
| 231 | - | ||
| 232 | - | if got, ok := doc.NextBlockOfKind(tt.from, tt.kind, 1); ok || got != (Cursor{}) { | |
| 233 | - | t.Fatalf("NextBlockOfKind() = (%#v, %v), want zero cursor and false", got, ok) | |
| 234 | - | } | |
| 235 | - | if got, ok := doc.PreviousBlockOfKind(tt.from, tt.kind, 1); ok || got != (Cursor{}) { | |
| 236 | - | t.Fatalf("PreviousBlockOfKind() = (%#v, %v), want zero cursor and false", got, ok) | |
| 237 | - | } | |
| 238 | - | }) | |
| 239 | - | } | |
| 240 | - | } | |
| 241 | - | ||
| 242 | - | func blockKindMotionDocument(t *testing.T) Document { | |
| 243 | - | t.Helper() | |
| 244 | - | ||
| 245 | - | return mustDocument(t, []Block{ | |
| 246 | - | {ID: "p0", Kind: BlockKind("paragraph")}, | |
| 247 | - | {ID: "code1", Kind: BlockKindCode}, | |
| 248 | - | {ID: "p1", Kind: BlockKind("paragraph")}, | |
| 249 | - | {ID: "table1", Kind: BlockKindTable}, | |
| 250 | - | {ID: "image1", Kind: BlockKindImage}, | |
| 251 | - | {ID: "code2", Kind: BlockKindCode}, | |
| 252 | - | {ID: "table2", Kind: BlockKindTable}, | |
| 253 | - | {ID: "image2", Kind: BlockKindImage}, | |
| 254 | - | {ID: "p2", Kind: BlockKind("paragraph")}, | |
| 255 | - | }) | |
| 256 | - | } | |
| @@ -1,256 +0,0 @@ | |||||
| 1 | - | package docmodel | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "reflect" | |||
| 5 | - | "testing" | |||
| 6 | - | ) | |||
| 7 | - | ||||
| 8 | - | func TestStrictNextBlockOfKind(t *testing.T) { | |||
| 9 | - | doc := blockKindMotionDocument(t) | |||
| 10 | - | ||||
| 11 | - | tests := []struct { | |||
| 12 | - | name string | |||
| 13 | - | from Cursor | |||
| 14 | - | kind BlockKind | |||
| 15 | - | count int | |||
| 16 | - | want Cursor | |||
| 17 | - | ok bool | |||
| 18 | - | }{ | |||
| 19 | - | { | |||
| 20 | - | name: "next code block", | |||
| 21 | - | from: Cursor{BlockID: "p0"}, | |||
| 22 | - | kind: BlockKindCode, | |||
| 23 | - | want: Cursor{BlockID: "code1"}, | |||
| 24 | - | ok: true, | |||
| 25 | - | }, | |||
| 26 | - | { | |||
| 27 | - | name: "next table block", | |||
| 28 | - | from: Cursor{BlockID: "p0"}, | |||
| 29 | - | kind: BlockKindTable, | |||
| 30 | - | want: Cursor{BlockID: "table1"}, | |||
| 31 | - | ok: true, | |||
| 32 | - | }, | |||
| 33 | - | { | |||
| 34 | - | name: "next image block", | |||
| 35 | - | from: Cursor{BlockID: "p0"}, | |||
| 36 | - | kind: BlockKindImage, | |||
| 37 | - | want: Cursor{BlockID: "image1"}, | |||
| 38 | - | ok: true, | |||
| 39 | - | }, | |||
| 40 | - | { | |||
| 41 | - | name: "count below one means one", | |||
| 42 | - | from: Cursor{BlockID: "p1"}, | |||
| 43 | - | kind: BlockKindTable, | |||
| 44 | - | count: -2, | |||
| 45 | - | want: Cursor{BlockID: "table1"}, | |||
| 46 | - | ok: true, | |||
| 47 | - | }, | |||
| 48 | - | { | |||
| 49 | - | name: "strict next does not match current block", | |||
| 50 | - | from: Cursor{BlockID: "code1"}, | |||
| 51 | - | kind: BlockKindCode, | |||
| 52 | - | count: 1, | |||
| 53 | - | want: Cursor{BlockID: "code2"}, | |||
| 54 | - | ok: true, | |||
| 55 | - | }, | |||
| 56 | - | { | |||
| 57 | - | name: "count selects nth next block of kind", | |||
| 58 | - | from: Cursor{BlockID: "p0"}, | |||
| 59 | - | kind: BlockKindCode, | |||
| 60 | - | count: 2, | |||
| 61 | - | want: Cursor{BlockID: "code2"}, | |||
| 62 | - | ok: true, | |||
| 63 | - | }, | |||
| 64 | - | { | |||
| 65 | - | name: "no later matching kind returns false", | |||
| 66 | - | from: Cursor{BlockID: "code2"}, | |||
| 67 | - | kind: BlockKindCode, | |||
| 68 | - | }, | |||
| 69 | - | { | |||
| 70 | - | name: "count past available matches returns false", | |||
| 71 | - | from: Cursor{BlockID: "p0"}, | |||
| 72 | - | kind: BlockKindImage, | |||
| 73 | - | count: 3, | |||
| 74 | - | }, | |||
| 75 | - | { | |||
| 76 | - | name: "zero cursor returns false", | |||
| 77 | - | from: Cursor{}, | |||
| 78 | - | kind: BlockKindCode, | |||
| 79 | - | }, | |||
| 80 | - | { | |||
| 81 | - | name: "missing cursor returns false", | |||
| 82 | - | from: Cursor{BlockID: "missing"}, | |||
| 83 | - | kind: BlockKindCode, | |||
| 84 | - | }, | |||
| 85 | - | { | |||
| 86 | - | name: "empty kind returns false", | |||
| 87 | - | from: Cursor{BlockID: "p0"}, | |||
| 88 | - | kind: "", | |||
| 89 | - | }, | |||
| 90 | - | } | |||
| 91 | - | ||||
| 92 | - | for _, tt := range tests { | |||
| 93 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 94 | - | before := doc.Blocks() | |||
| 95 | - | got, ok := doc.NextBlockOfKind(tt.from, tt.kind, tt.count) | |||
| 96 | - | if ok != tt.ok || got != tt.want { | |||
| 97 | - | t.Fatalf("NextBlockOfKind(%#v, %q, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.kind, tt.count, got, ok, tt.want, tt.ok) | |||
| 98 | - | } | |||
| 99 | - | if after := doc.Blocks(); !reflect.DeepEqual(after, before) { | |||
| 100 | - | t.Fatalf("NextBlockOfKind mutated document: before %#v after %#v", before, after) | |||
| 101 | - | } | |||
| 102 | - | }) | |||
| 103 | - | } | |||
| 104 | - | } | |||
| 105 | - | ||||
| 106 | - | func TestStrictPreviousBlockOfKind(t *testing.T) { | |||
| 107 | - | doc := blockKindMotionDocument(t) | |||
| 108 | - | ||||
| 109 | - | tests := []struct { | |||
| 110 | - | name string | |||
| 111 | - | from Cursor | |||
| 112 | - | kind BlockKind | |||
| 113 | - | count int | |||
| 114 | - | want Cursor | |||
| 115 | - | ok bool | |||
| 116 | - | }{ | |||
| 117 | - | { | |||
| 118 | - | name: "previous code block", | |||
| 119 | - | from: Cursor{BlockID: "p2"}, | |||
| 120 | - | kind: BlockKindCode, | |||
| 121 | - | want: Cursor{BlockID: "code2"}, | |||
| 122 | - | ok: true, | |||
| 123 | - | }, | |||
| 124 | - | { | |||
| 125 | - | name: "previous table block", | |||
| 126 | - | from: Cursor{BlockID: "p2"}, | |||
| 127 | - | kind: BlockKindTable, | |||
| 128 | - | want: Cursor{BlockID: "table2"}, | |||
| 129 | - | ok: true, | |||
| 130 | - | }, | |||
| 131 | - | { | |||
| 132 | - | name: "previous image block", | |||
| 133 | - | from: Cursor{BlockID: "p2"}, | |||
| 134 | - | kind: BlockKindImage, | |||
| 135 | - | want: Cursor{BlockID: "image2"}, | |||
| 136 | - | ok: true, | |||
| 137 | - | }, | |||
| 138 | - | { | |||
| 139 | - | name: "count below one means one", | |||
| 140 | - | from: Cursor{BlockID: "p2"}, | |||
| 141 | - | kind: BlockKindTable, | |||
| 142 | - | count: 0, | |||
| 143 | - | want: Cursor{BlockID: "table2"}, | |||
| 144 | - | ok: true, | |||
| 145 | - | }, | |||
| 146 | - | { | |||
| 147 | - | name: "strict previous does not match current block", | |||
| 148 | - | from: Cursor{BlockID: "image2"}, | |||
| 149 | - | kind: BlockKindImage, | |||
| 150 | - | count: 1, | |||
| 151 | - | want: Cursor{BlockID: "image1"}, | |||
| 152 | - | ok: true, | |||
| 153 | - | }, | |||
| 154 | - | { | |||
| 155 | - | name: "count selects nth previous block of kind", | |||
| 156 | - | from: Cursor{BlockID: "p2"}, | |||
| 157 | - | kind: BlockKindTable, | |||
| 158 | - | count: 2, | |||
| 159 | - | want: Cursor{BlockID: "table1"}, | |||
| 160 | - | ok: true, | |||
| 161 | - | }, | |||
| 162 | - | { | |||
| 163 | - | name: "no earlier matching kind returns false", | |||
| 164 | - | from: Cursor{BlockID: "code1"}, | |||
| 165 | - | kind: BlockKindCode, | |||
| 166 | - | }, | |||
| 167 | - | { | |||
| 168 | - | name: "count before available matches returns false", | |||
| 169 | - | from: Cursor{BlockID: "p2"}, | |||
| 170 | - | kind: BlockKindImage, | |||
| 171 | - | count: 3, | |||
| 172 | - | }, | |||
| 173 | - | { | |||
| 174 | - | name: "zero cursor returns false", | |||
| 175 | - | from: Cursor{}, | |||
| 176 | - | kind: BlockKindCode, | |||
| 177 | - | }, | |||
| 178 | - | { | |||
| 179 | - | name: "missing cursor returns false", | |||
| 180 | - | from: Cursor{BlockID: "missing"}, | |||
| 181 | - | kind: BlockKindCode, | |||
| 182 | - | }, | |||
| 183 | - | { | |||
| 184 | - | name: "empty kind returns false", | |||
| 185 | - | from: Cursor{BlockID: "p2"}, | |||
| 186 | - | kind: "", | |||
| 187 | - | }, | |||
| 188 | - | } | |||
| 189 | - | ||||
| 190 | - | for _, tt := range tests { | |||
| 191 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 192 | - | before := doc.Blocks() | |||
| 193 | - | got, ok := doc.PreviousBlockOfKind(tt.from, tt.kind, tt.count) | |||
| 194 | - | if ok != tt.ok || got != tt.want { | |||
| 195 | - | t.Fatalf("PreviousBlockOfKind(%#v, %q, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.kind, tt.count, got, ok, tt.want, tt.ok) | |||
| 196 | - | } | |||
| 197 | - | if after := doc.Blocks(); !reflect.DeepEqual(after, before) { | |||
| 198 | - | t.Fatalf("PreviousBlockOfKind mutated document: before %#v after %#v", before, after) | |||
| 199 | - | } | |||
| 200 | - | }) | |||
| 201 | - | } | |||
| 202 | - | } | |||
| 203 | - | ||||
| 204 | - | func TestBlockOfKindMotionsOnEmptyAndUnmatchedDocuments(t *testing.T) { | |||
| 205 | - | tests := []struct { | |||
| 206 | - | name string | |||
| 207 | - | blocks []Block | |||
| 208 | - | from Cursor | |||
| 209 | - | kind BlockKind | |||
| 210 | - | }{ | |||
| 211 | - | { | |||
| 212 | - | name: "empty document", | |||
| 213 | - | from: Cursor{BlockID: "missing"}, | |||
| 214 | - | kind: BlockKindCode, | |||
| 215 | - | }, | |||
| 216 | - | { | |||
| 217 | - | name: "document without requested structural kind", | |||
| 218 | - | blocks: []Block{ | |||
| 219 | - | {ID: "p0", Kind: BlockKind("paragraph")}, | |||
| 220 | - | {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1}, | |||
| 221 | - | {ID: "p1", Kind: BlockKind("list_item")}, | |||
| 222 | - | }, | |||
| 223 | - | from: Cursor{BlockID: "p0"}, | |||
| 224 | - | kind: BlockKindImage, | |||
| 225 | - | }, | |||
| 226 | - | } | |||
| 227 | - | ||||
| 228 | - | for _, tt := range tests { | |||
| 229 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 230 | - | doc := mustDocument(t, tt.blocks) | |||
| 231 | - | ||||
| 232 | - | if got, ok := doc.NextBlockOfKind(tt.from, tt.kind, 1); ok || got != (Cursor{}) { | |||
| 233 | - | t.Fatalf("NextBlockOfKind() = (%#v, %v), want zero cursor and false", got, ok) | |||
| 234 | - | } | |||
| 235 | - | if got, ok := doc.PreviousBlockOfKind(tt.from, tt.kind, 1); ok || got != (Cursor{}) { | |||
| 236 | - | t.Fatalf("PreviousBlockOfKind() = (%#v, %v), want zero cursor and false", got, ok) | |||
| 237 | - | } | |||
| 238 | - | }) | |||
| 239 | - | } | |||
| 240 | - | } | |||
| 241 | - | ||||
| 242 | - | func blockKindMotionDocument(t *testing.T) Document { | |||
| 243 | - | t.Helper() | |||
| 244 | - | ||||
| 245 | - | return mustDocument(t, []Block{ | |||
| 246 | - | {ID: "p0", Kind: BlockKind("paragraph")}, | |||
| 247 | - | {ID: "code1", Kind: BlockKindCode}, | |||
| 248 | - | {ID: "p1", Kind: BlockKind("paragraph")}, | |||
| 249 | - | {ID: "table1", Kind: BlockKindTable}, | |||
| 250 | - | {ID: "image1", Kind: BlockKindImage}, | |||
| 251 | - | {ID: "code2", Kind: BlockKindCode}, | |||
| 252 | - | {ID: "table2", Kind: BlockKindTable}, | |||
| 253 | - | {ID: "image2", Kind: BlockKindImage}, | |||
| 254 | - | {ID: "p2", Kind: BlockKind("paragraph")}, | |||
| 255 | - | }) | |||
| 256 | - | } | |||
D internal/domain/docmodel/docmodel.go +0 -152
| @@ -1,152 +0,0 @@ | |||
| 1 | - | package docmodel | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "errors" | |
| 5 | - | "fmt" | |
| 6 | - | ) | |
| 7 | - | ||
| 8 | - | // BlockID identifies a block within a document. | |
| 9 | - | type BlockID string | |
| 10 | - | ||
| 11 | - | // BlockKind describes the semantic role of a block. Non-heading kinds are | |
| 12 | - | // intentionally left schema-agnostic and are preserved as provided. | |
| 13 | - | type BlockKind string | |
| 14 | - | ||
| 15 | - | const ( | |
| 16 | - | // BlockKindHeading marks blocks that participate in heading motions. | |
| 17 | - | BlockKindHeading BlockKind = "heading" | |
| 18 | - | // BlockKindParagraph marks a paragraph block for Vim Mode paragraph motions. | |
| 19 | - | BlockKindParagraph BlockKind = "paragraph" | |
| 20 | - | // BlockKindCode marks a structural code block for Vim Mode block-kind motions. | |
| 21 | - | BlockKindCode BlockKind = "code" | |
| 22 | - | // BlockKindTable marks a structural table block for Vim Mode block-kind motions. | |
| 23 | - | BlockKindTable BlockKind = "table" | |
| 24 | - | // BlockKindImage marks a structural image block for Vim Mode block-kind motions. | |
| 25 | - | BlockKindImage BlockKind = "image" | |
| 26 | - | // BlockKindList marks a structural list block for Vim Mode list motions. | |
| 27 | - | BlockKindList BlockKind = "list" | |
| 28 | - | ||
| 29 | - | // MinHeadingLevel is the lowest valid heading level. | |
| 30 | - | MinHeadingLevel = 1 | |
| 31 | - | // MaxHeadingLevel is the highest valid heading level. | |
| 32 | - | MaxHeadingLevel = 6 | |
| 33 | - | ) | |
| 34 | - | ||
| 35 | - | var ( | |
| 36 | - | // ErrEmptyBlockID reports a block with no ID. | |
| 37 | - | ErrEmptyBlockID = errors.New("docmodel: empty block ID") | |
| 38 | - | // ErrDuplicateBlockID reports a repeated block ID. | |
| 39 | - | ErrDuplicateBlockID = errors.New("docmodel: duplicate block ID") | |
| 40 | - | // ErrInvalidHeadingLevel reports a heading outside levels 1 through 6. | |
| 41 | - | ErrInvalidHeadingLevel = errors.New("docmodel: invalid heading level") | |
| 42 | - | // ErrUnexpectedHeadingLevel reports a non-heading block with a heading level. | |
| 43 | - | ErrUnexpectedHeadingLevel = errors.New("docmodel: non-heading block has heading level") | |
| 44 | - | ) | |
| 45 | - | ||
| 46 | - | // Block is an ordered document block. HeadingLevel is meaningful only when | |
| 47 | - | // Kind is BlockKindHeading; non-heading blocks must leave it as zero. | |
| 48 | - | type Block struct { | |
| 49 | - | ID BlockID | |
| 50 | - | Kind BlockKind | |
| 51 | - | HeadingLevel int | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | // IsHeading reports whether the block is a heading. | |
| 55 | - | func (b Block) IsHeading() bool { | |
| 56 | - | return b.Kind == BlockKindHeading | |
| 57 | - | } | |
| 58 | - | ||
| 59 | - | // Cursor identifies the current block for document navigation. | |
| 60 | - | type Cursor struct { | |
| 61 | - | BlockID BlockID | |
| 62 | - | } | |
| 63 | - | ||
| 64 | - | // BlockRange identifies a half-open span of blocks in document order. A zero | |
| 65 | - | // EndExclusive cursor represents the end of the document. | |
| 66 | - | type BlockRange struct { | |
| 67 | - | StartInclusive Cursor | |
| 68 | - | EndExclusive Cursor | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | // Document is an immutable, order-preserving collection of blocks. | |
| 72 | - | type Document struct { | |
| 73 | - | blocks []Block | |
| 74 | - | indexByID map[BlockID]int | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | // NewDocument validates blocks and returns an order-preserving document. The | |
| 78 | - | // input slice is copied so later caller mutations cannot affect navigation. | |
| 79 | - | func NewDocument(blocks []Block) (Document, error) { | |
| 80 | - | copied := append([]Block(nil), blocks...) | |
| 81 | - | indexByID := make(map[BlockID]int, len(copied)) | |
| 82 | - | ||
| 83 | - | for i, block := range copied { | |
| 84 | - | if block.ID == "" { | |
| 85 | - | return Document{}, fmt.Errorf("%w at index %d", ErrEmptyBlockID, i) | |
| 86 | - | } | |
| 87 | - | if _, exists := indexByID[block.ID]; exists { | |
| 88 | - | return Document{}, fmt.Errorf("%w %q at index %d", ErrDuplicateBlockID, block.ID, i) | |
| 89 | - | } | |
| 90 | - | if err := validateBlockHeading(block, i); err != nil { | |
| 91 | - | return Document{}, err | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | indexByID[block.ID] = i | |
| 95 | - | } | |
| 96 | - | ||
| 97 | - | return Document{ | |
| 98 | - | blocks: copied, | |
| 99 | - | indexByID: indexByID, | |
| 100 | - | }, nil | |
| 101 | - | } | |
| 102 | - | ||
| 103 | - | // Len returns the number of blocks in document order. | |
| 104 | - | func (d Document) Len() int { | |
| 105 | - | return len(d.blocks) | |
| 106 | - | } | |
| 107 | - | ||
| 108 | - | // Blocks returns a copy of the blocks in document order. | |
| 109 | - | func (d Document) Blocks() []Block { | |
| 110 | - | blocks := make([]Block, len(d.blocks)) | |
| 111 | - | copy(blocks, d.blocks) | |
| 112 | - | return blocks | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | // BlockByID returns the block with id when present. | |
| 116 | - | func (d Document) BlockByID(id BlockID) (Block, bool) { | |
| 117 | - | idx, ok := d.indexByID[id] | |
| 118 | - | if !ok { | |
| 119 | - | return Block{}, false | |
| 120 | - | } | |
| 121 | - | return d.blocks[idx], true | |
| 122 | - | } | |
| 123 | - | ||
| 124 | - | // CursorByID returns a cursor positioned on id when present. | |
| 125 | - | func (d Document) CursorByID(id BlockID) (Cursor, bool) { | |
| 126 | - | if _, ok := d.indexByID[id]; !ok { | |
| 127 | - | return Cursor{}, false | |
| 128 | - | } | |
| 129 | - | return Cursor{BlockID: id}, true | |
| 130 | - | } | |
| 131 | - | ||
| 132 | - | // FirstCursor returns a cursor positioned on the first block in document order. | |
| 133 | - | func (d Document) FirstCursor() (Cursor, bool) { | |
| 134 | - | if len(d.blocks) == 0 { | |
| 135 | - | return Cursor{}, false | |
| 136 | - | } | |
| 137 | - | return Cursor{BlockID: d.blocks[0].ID}, true | |
| 138 | - | } | |
| 139 | - | ||
| 140 | - | func validateBlockHeading(block Block, index int) error { | |
| 141 | - | if block.IsHeading() { | |
| 142 | - | if block.HeadingLevel < MinHeadingLevel || block.HeadingLevel > MaxHeadingLevel { | |
| 143 | - | return fmt.Errorf("%w %d at index %d", ErrInvalidHeadingLevel, block.HeadingLevel, index) | |
| 144 | - | } | |
| 145 | - | return nil | |
| 146 | - | } | |
| 147 | - | ||
| 148 | - | if block.HeadingLevel != 0 { | |
| 149 | - | return fmt.Errorf("%w %d at index %d", ErrUnexpectedHeadingLevel, block.HeadingLevel, index) | |
| 150 | - | } | |
| 151 | - | return nil | |
| 152 | - | } | |
| @@ -1,152 +0,0 @@ | |||||
| 1 | - | package docmodel | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "errors" | |||
| 5 | - | "fmt" | |||
| 6 | - | ) | |||
| 7 | - | ||||
| 8 | - | // BlockID identifies a block within a document. | |||
| 9 | - | type BlockID string | |||
| 10 | - | ||||
| 11 | - | // BlockKind describes the semantic role of a block. Non-heading kinds are | |||
| 12 | - | // intentionally left schema-agnostic and are preserved as provided. | |||
| 13 | - | type BlockKind string | |||
| 14 | - | ||||
| 15 | - | const ( | |||
| 16 | - | // BlockKindHeading marks blocks that participate in heading motions. | |||
| 17 | - | BlockKindHeading BlockKind = "heading" | |||
| 18 | - | // BlockKindParagraph marks a paragraph block for Vim Mode paragraph motions. | |||
| 19 | - | BlockKindParagraph BlockKind = "paragraph" | |||
| 20 | - | // BlockKindCode marks a structural code block for Vim Mode block-kind motions. | |||
| 21 | - | BlockKindCode BlockKind = "code" | |||
| 22 | - | // BlockKindTable marks a structural table block for Vim Mode block-kind motions. | |||
| 23 | - | BlockKindTable BlockKind = "table" | |||
| 24 | - | // BlockKindImage marks a structural image block for Vim Mode block-kind motions. | |||
| 25 | - | BlockKindImage BlockKind = "image" | |||
| 26 | - | // BlockKindList marks a structural list block for Vim Mode list motions. | |||
| 27 | - | BlockKindList BlockKind = "list" | |||
| 28 | - | ||||
| 29 | - | // MinHeadingLevel is the lowest valid heading level. | |||
| 30 | - | MinHeadingLevel = 1 | |||
| 31 | - | // MaxHeadingLevel is the highest valid heading level. | |||
| 32 | - | MaxHeadingLevel = 6 | |||
| 33 | - | ) | |||
| 34 | - | ||||
| 35 | - | var ( | |||
| 36 | - | // ErrEmptyBlockID reports a block with no ID. | |||
| 37 | - | ErrEmptyBlockID = errors.New("docmodel: empty block ID") | |||
| 38 | - | // ErrDuplicateBlockID reports a repeated block ID. | |||
| 39 | - | ErrDuplicateBlockID = errors.New("docmodel: duplicate block ID") | |||
| 40 | - | // ErrInvalidHeadingLevel reports a heading outside levels 1 through 6. | |||
| 41 | - | ErrInvalidHeadingLevel = errors.New("docmodel: invalid heading level") | |||
| 42 | - | // ErrUnexpectedHeadingLevel reports a non-heading block with a heading level. | |||
| 43 | - | ErrUnexpectedHeadingLevel = errors.New("docmodel: non-heading block has heading level") | |||
| 44 | - | ) | |||
| 45 | - | ||||
| 46 | - | // Block is an ordered document block. HeadingLevel is meaningful only when | |||
| 47 | - | // Kind is BlockKindHeading; non-heading blocks must leave it as zero. | |||
| 48 | - | type Block struct { | |||
| 49 | - | ID BlockID | |||
| 50 | - | Kind BlockKind | |||
| 51 | - | HeadingLevel int | |||
| 52 | - | } | |||
| 53 | - | ||||
| 54 | - | // IsHeading reports whether the block is a heading. | |||
| 55 | - | func (b Block) IsHeading() bool { | |||
| 56 | - | return b.Kind == BlockKindHeading | |||
| 57 | - | } | |||
| 58 | - | ||||
| 59 | - | // Cursor identifies the current block for document navigation. | |||
| 60 | - | type Cursor struct { | |||
| 61 | - | BlockID BlockID | |||
| 62 | - | } | |||
| 63 | - | ||||
| 64 | - | // BlockRange identifies a half-open span of blocks in document order. A zero | |||
| 65 | - | // EndExclusive cursor represents the end of the document. | |||
| 66 | - | type BlockRange struct { | |||
| 67 | - | StartInclusive Cursor | |||
| 68 | - | EndExclusive Cursor | |||
| 69 | - | } | |||
| 70 | - | ||||
| 71 | - | // Document is an immutable, order-preserving collection of blocks. | |||
| 72 | - | type Document struct { | |||
| 73 | - | blocks []Block | |||
| 74 | - | indexByID map[BlockID]int | |||
| 75 | - | } | |||
| 76 | - | ||||
| 77 | - | // NewDocument validates blocks and returns an order-preserving document. The | |||
| 78 | - | // input slice is copied so later caller mutations cannot affect navigation. | |||
| 79 | - | func NewDocument(blocks []Block) (Document, error) { | |||
| 80 | - | copied := append([]Block(nil), blocks...) | |||
| 81 | - | indexByID := make(map[BlockID]int, len(copied)) | |||
| 82 | - | ||||
| 83 | - | for i, block := range copied { | |||
| 84 | - | if block.ID == "" { | |||
| 85 | - | return Document{}, fmt.Errorf("%w at index %d", ErrEmptyBlockID, i) | |||
| 86 | - | } | |||
| 87 | - | if _, exists := indexByID[block.ID]; exists { | |||
| 88 | - | return Document{}, fmt.Errorf("%w %q at index %d", ErrDuplicateBlockID, block.ID, i) | |||
| 89 | - | } | |||
| 90 | - | if err := validateBlockHeading(block, i); err != nil { | |||
| 91 | - | return Document{}, err | |||
| 92 | - | } | |||
| 93 | - | ||||
| 94 | - | indexByID[block.ID] = i | |||
| 95 | - | } | |||
| 96 | - | ||||
| 97 | - | return Document{ | |||
| 98 | - | blocks: copied, | |||
| 99 | - | indexByID: indexByID, | |||
| 100 | - | }, nil | |||
| 101 | - | } | |||
| 102 | - | ||||
| 103 | - | // Len returns the number of blocks in document order. | |||
| 104 | - | func (d Document) Len() int { | |||
| 105 | - | return len(d.blocks) | |||
| 106 | - | } | |||
| 107 | - | ||||
| 108 | - | // Blocks returns a copy of the blocks in document order. | |||
| 109 | - | func (d Document) Blocks() []Block { | |||
| 110 | - | blocks := make([]Block, len(d.blocks)) | |||
| 111 | - | copy(blocks, d.blocks) | |||
| 112 | - | return blocks | |||
| 113 | - | } | |||
| 114 | - | ||||
| 115 | - | // BlockByID returns the block with id when present. | |||
| 116 | - | func (d Document) BlockByID(id BlockID) (Block, bool) { | |||
| 117 | - | idx, ok := d.indexByID[id] | |||
| 118 | - | if !ok { | |||
| 119 | - | return Block{}, false | |||
| 120 | - | } | |||
| 121 | - | return d.blocks[idx], true | |||
| 122 | - | } | |||
| 123 | - | ||||
| 124 | - | // CursorByID returns a cursor positioned on id when present. | |||
| 125 | - | func (d Document) CursorByID(id BlockID) (Cursor, bool) { | |||
| 126 | - | if _, ok := d.indexByID[id]; !ok { | |||
| 127 | - | return Cursor{}, false | |||
| 128 | - | } | |||
| 129 | - | return Cursor{BlockID: id}, true | |||
| 130 | - | } | |||
| 131 | - | ||||
| 132 | - | // FirstCursor returns a cursor positioned on the first block in document order. | |||
| 133 | - | func (d Document) FirstCursor() (Cursor, bool) { | |||
| 134 | - | if len(d.blocks) == 0 { | |||
| 135 | - | return Cursor{}, false | |||
| 136 | - | } | |||
| 137 | - | return Cursor{BlockID: d.blocks[0].ID}, true | |||
| 138 | - | } | |||
| 139 | - | ||||
| 140 | - | func validateBlockHeading(block Block, index int) error { | |||
| 141 | - | if block.IsHeading() { | |||
| 142 | - | if block.HeadingLevel < MinHeadingLevel || block.HeadingLevel > MaxHeadingLevel { | |||
| 143 | - | return fmt.Errorf("%w %d at index %d", ErrInvalidHeadingLevel, block.HeadingLevel, index) | |||
| 144 | - | } | |||
| 145 | - | return nil | |||
| 146 | - | } | |||
| 147 | - | ||||
| 148 | - | if block.HeadingLevel != 0 { | |||
| 149 | - | return fmt.Errorf("%w %d at index %d", ErrUnexpectedHeadingLevel, block.HeadingLevel, index) | |||
| 150 | - | } | |||
| 151 | - | return nil | |||
| 152 | - | } | |||
D internal/domain/docmodel/docmodel_test.go +0 -384
| @@ -1,384 +0,0 @@ | |||
| 1 | - | package docmodel | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "errors" | |
| 5 | - | "reflect" | |
| 6 | - | "testing" | |
| 7 | - | ) | |
| 8 | - | ||
| 9 | - | func TestNewDocumentValidation(t *testing.T) { | |
| 10 | - | tests := []struct { | |
| 11 | - | name string | |
| 12 | - | blocks []Block | |
| 13 | - | wantErr error | |
| 14 | - | }{ | |
| 15 | - | { | |
| 16 | - | name: "empty document is valid", | |
| 17 | - | }, | |
| 18 | - | { | |
| 19 | - | name: "valid headings and non headings", | |
| 20 | - | blocks: []Block{ | |
| 21 | - | {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1}, | |
| 22 | - | {ID: "body", Kind: BlockKind("paragraph")}, | |
| 23 | - | {ID: "h6", Kind: BlockKindHeading, HeadingLevel: 6}, | |
| 24 | - | }, | |
| 25 | - | }, | |
| 26 | - | { | |
| 27 | - | name: "empty block ID is invalid", | |
| 28 | - | blocks: []Block{ | |
| 29 | - | {ID: "ok", Kind: BlockKind("paragraph")}, | |
| 30 | - | {ID: "", Kind: BlockKindHeading, HeadingLevel: 1}, | |
| 31 | - | }, | |
| 32 | - | wantErr: ErrEmptyBlockID, | |
| 33 | - | }, | |
| 34 | - | { | |
| 35 | - | name: "duplicate block ID is invalid", | |
| 36 | - | blocks: []Block{ | |
| 37 | - | {ID: "same", Kind: BlockKindHeading, HeadingLevel: 1}, | |
| 38 | - | {ID: "same", Kind: BlockKind("paragraph")}, | |
| 39 | - | }, | |
| 40 | - | wantErr: ErrDuplicateBlockID, | |
| 41 | - | }, | |
| 42 | - | { | |
| 43 | - | name: "heading level below one is invalid", | |
| 44 | - | blocks: []Block{ | |
| 45 | - | {ID: "h0", Kind: BlockKindHeading, HeadingLevel: 0}, | |
| 46 | - | }, | |
| 47 | - | wantErr: ErrInvalidHeadingLevel, | |
| 48 | - | }, | |
| 49 | - | { | |
| 50 | - | name: "heading level above six is invalid", | |
| 51 | - | blocks: []Block{ | |
| 52 | - | {ID: "h7", Kind: BlockKindHeading, HeadingLevel: 7}, | |
| 53 | - | }, | |
| 54 | - | wantErr: ErrInvalidHeadingLevel, | |
| 55 | - | }, | |
| 56 | - | { | |
| 57 | - | name: "non heading cannot carry heading level", | |
| 58 | - | blocks: []Block{ | |
| 59 | - | {ID: "body", Kind: BlockKind("paragraph"), HeadingLevel: 2}, | |
| 60 | - | }, | |
| 61 | - | wantErr: ErrUnexpectedHeadingLevel, | |
| 62 | - | }, | |
| 63 | - | } | |
| 64 | - | ||
| 65 | - | for _, tt := range tests { | |
| 66 | - | t.Run(tt.name, func(t *testing.T) { | |
| 67 | - | _, err := NewDocument(tt.blocks) | |
| 68 | - | if tt.wantErr == nil { | |
| 69 | - | if err != nil { | |
| 70 | - | t.Fatalf("NewDocument() error = %v, want nil", err) | |
| 71 | - | } | |
| 72 | - | return | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | if !errors.Is(err, tt.wantErr) { | |
| 76 | - | t.Fatalf("NewDocument() error = %v, want errors.Is(_, %v)", err, tt.wantErr) | |
| 77 | - | } | |
| 78 | - | }) | |
| 79 | - | } | |
| 80 | - | } | |
| 81 | - | ||
| 82 | - | func TestDocumentOrderAndCopies(t *testing.T) { | |
| 83 | - | blocks := []Block{ | |
| 84 | - | {ID: "intro", Kind: BlockKindHeading, HeadingLevel: 1}, | |
| 85 | - | {ID: "body", Kind: BlockKind("paragraph")}, | |
| 86 | - | {ID: "details", Kind: BlockKindHeading, HeadingLevel: 2}, | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | doc, err := NewDocument(blocks) | |
| 90 | - | if err != nil { | |
| 91 | - | t.Fatalf("NewDocument() error = %v", err) | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | blocks[0] = Block{ID: "mutated", Kind: BlockKindHeading, HeadingLevel: 6} | |
| 95 | - | ||
| 96 | - | got := doc.Blocks() | |
| 97 | - | want := []Block{ | |
| 98 | - | {ID: "intro", Kind: BlockKindHeading, HeadingLevel: 1}, | |
| 99 | - | {ID: "body", Kind: BlockKind("paragraph")}, | |
| 100 | - | {ID: "details", Kind: BlockKindHeading, HeadingLevel: 2}, | |
| 101 | - | } | |
| 102 | - | if !reflect.DeepEqual(got, want) { | |
| 103 | - | t.Fatalf("Blocks() = %#v, want %#v", got, want) | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | got[0] = Block{ID: "also-mutated", Kind: BlockKind("paragraph")} | |
| 107 | - | gotAgain := doc.Blocks() | |
| 108 | - | if !reflect.DeepEqual(gotAgain, want) { | |
| 109 | - | t.Fatalf("Blocks() after mutating returned slice = %#v, want %#v", gotAgain, want) | |
| 110 | - | } | |
| 111 | - | } | |
| 112 | - | ||
| 113 | - | func TestDocumentCursorNavigation(t *testing.T) { | |
| 114 | - | t.Run("empty document has no first cursor", func(t *testing.T) { | |
| 115 | - | doc, err := NewDocument(nil) | |
| 116 | - | if err != nil { | |
| 117 | - | t.Fatalf("NewDocument() error = %v", err) | |
| 118 | - | } | |
| 119 | - | ||
| 120 | - | if got, ok := doc.FirstCursor(); ok || got != (Cursor{}) { | |
| 121 | - | t.Fatalf("FirstCursor() = (%#v, %v), want zero cursor and false", got, ok) | |
| 122 | - | } | |
| 123 | - | }) | |
| 124 | - | ||
| 125 | - | t.Run("cursor by ID and first cursor use document order", func(t *testing.T) { | |
| 126 | - | doc := mustDocument(t, []Block{ | |
| 127 | - | {ID: "first", Kind: BlockKind("paragraph")}, | |
| 128 | - | {ID: "second", Kind: BlockKindHeading, HeadingLevel: 2}, | |
| 129 | - | }) | |
| 130 | - | ||
| 131 | - | if got, ok := doc.FirstCursor(); !ok || got != (Cursor{BlockID: "first"}) { | |
| 132 | - | t.Fatalf("FirstCursor() = (%#v, %v), want first cursor and true", got, ok) | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | if got, ok := doc.CursorByID("second"); !ok || got != (Cursor{BlockID: "second"}) { | |
| 136 | - | t.Fatalf("CursorByID(second) = (%#v, %v), want second cursor and true", got, ok) | |
| 137 | - | } | |
| 138 | - | ||
| 139 | - | if got, ok := doc.CursorByID("missing"); ok || got != (Cursor{}) { | |
| 140 | - | t.Fatalf("CursorByID(missing) = (%#v, %v), want zero cursor and false", got, ok) | |
| 141 | - | } | |
| 142 | - | }) | |
| 143 | - | } | |
| 144 | - | ||
| 145 | - | func TestStrictNextHeading(t *testing.T) { | |
| 146 | - | doc := headingMotionDocument(t) | |
| 147 | - | ||
| 148 | - | tests := []struct { | |
| 149 | - | name string | |
| 150 | - | from BlockID | |
| 151 | - | count int | |
| 152 | - | levels []int | |
| 153 | - | want Cursor | |
| 154 | - | ok bool | |
| 155 | - | }{ | |
| 156 | - | { | |
| 157 | - | name: "from non heading uses next heading", | |
| 158 | - | from: "p0", | |
| 159 | - | want: Cursor{BlockID: "h1"}, | |
| 160 | - | ok: true, | |
| 161 | - | }, | |
| 162 | - | { | |
| 163 | - | name: "count below one means one", | |
| 164 | - | from: "p1", | |
| 165 | - | count: -3, | |
| 166 | - | want: Cursor{BlockID: "h2"}, | |
| 167 | - | ok: true, | |
| 168 | - | }, | |
| 169 | - | { | |
| 170 | - | name: "strict next does not match current heading", | |
| 171 | - | from: "h1", | |
| 172 | - | count: 1, | |
| 173 | - | want: Cursor{BlockID: "h2"}, | |
| 174 | - | ok: true, | |
| 175 | - | }, | |
| 176 | - | { | |
| 177 | - | name: "count selects nth next heading", | |
| 178 | - | from: "h1", | |
| 179 | - | count: 2, | |
| 180 | - | want: Cursor{BlockID: "h3"}, | |
| 181 | - | ok: true, | |
| 182 | - | }, | |
| 183 | - | { | |
| 184 | - | name: "level filter skips other headings", | |
| 185 | - | from: "h1", | |
| 186 | - | count: 1, | |
| 187 | - | levels: []int{1}, | |
| 188 | - | want: Cursor{BlockID: "h1b"}, | |
| 189 | - | ok: true, | |
| 190 | - | }, | |
| 191 | - | { | |
| 192 | - | name: "count applies after filtering", | |
| 193 | - | from: "h1", | |
| 194 | - | count: 2, | |
| 195 | - | levels: []int{2}, | |
| 196 | - | want: Cursor{BlockID: "h2b"}, | |
| 197 | - | ok: true, | |
| 198 | - | }, | |
| 199 | - | { | |
| 200 | - | name: "multi level filter matches any listed level", | |
| 201 | - | from: "h2", | |
| 202 | - | count: 1, | |
| 203 | - | levels: []int{1, 3}, | |
| 204 | - | want: Cursor{BlockID: "h3"}, | |
| 205 | - | ok: true, | |
| 206 | - | }, | |
| 207 | - | { | |
| 208 | - | name: "no matching level returns false", | |
| 209 | - | from: "p0", | |
| 210 | - | levels: []int{6}, | |
| 211 | - | }, | |
| 212 | - | { | |
| 213 | - | name: "past last heading returns false", | |
| 214 | - | from: "h1b", | |
| 215 | - | }, | |
| 216 | - | { | |
| 217 | - | name: "missing cursor returns false", | |
| 218 | - | from: "missing", | |
| 219 | - | }, | |
| 220 | - | } | |
| 221 | - | ||
| 222 | - | for _, tt := range tests { | |
| 223 | - | t.Run(tt.name, func(t *testing.T) { | |
| 224 | - | before := doc.Blocks() | |
| 225 | - | got, ok := doc.NextHeading(Cursor{BlockID: tt.from}, tt.count, tt.levels...) | |
| 226 | - | if ok != tt.ok || got != tt.want { | |
| 227 | - | t.Fatalf("NextHeading(%q, %d, %v) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, tt.levels, got, ok, tt.want, tt.ok) | |
| 228 | - | } | |
| 229 | - | if after := doc.Blocks(); !reflect.DeepEqual(after, before) { | |
| 230 | - | t.Fatalf("NextHeading mutated document: before %#v after %#v", before, after) | |
| 231 | - | } | |
| 232 | - | }) | |
| 233 | - | } | |
| 234 | - | } | |
| 235 | - | ||
| 236 | - | func TestStrictPreviousHeading(t *testing.T) { | |
| 237 | - | doc := headingMotionDocument(t) | |
| 238 | - | ||
| 239 | - | tests := []struct { | |
| 240 | - | name string | |
| 241 | - | from BlockID | |
| 242 | - | count int | |
| 243 | - | levels []int | |
| 244 | - | want Cursor | |
| 245 | - | ok bool | |
| 246 | - | }{ | |
| 247 | - | { | |
| 248 | - | name: "from non heading uses previous heading", | |
| 249 | - | from: "p2", | |
| 250 | - | want: Cursor{BlockID: "h2b"}, | |
| 251 | - | ok: true, | |
| 252 | - | }, | |
| 253 | - | { | |
| 254 | - | name: "count below one means one", | |
| 255 | - | from: "p2", | |
| 256 | - | count: 0, | |
| 257 | - | want: Cursor{BlockID: "h2b"}, | |
| 258 | - | ok: true, | |
| 259 | - | }, | |
| 260 | - | { | |
| 261 | - | name: "strict previous does not match current heading", | |
| 262 | - | from: "h1b", | |
| 263 | - | count: 1, | |
| 264 | - | want: Cursor{BlockID: "h2b"}, | |
| 265 | - | ok: true, | |
| 266 | - | }, | |
| 267 | - | { | |
| 268 | - | name: "count selects nth previous heading", | |
| 269 | - | from: "h1b", | |
| 270 | - | count: 2, | |
| 271 | - | want: Cursor{BlockID: "h3"}, | |
| 272 | - | ok: true, | |
| 273 | - | }, | |
| 274 | - | { | |
| 275 | - | name: "level filter skips other headings", | |
| 276 | - | from: "h1b", | |
| 277 | - | count: 1, | |
| 278 | - | levels: []int{1}, | |
| 279 | - | want: Cursor{BlockID: "h1"}, | |
| 280 | - | ok: true, | |
| 281 | - | }, | |
| 282 | - | { | |
| 283 | - | name: "count applies after filtering", | |
| 284 | - | from: "h1b", | |
| 285 | - | count: 2, | |
| 286 | - | levels: []int{2}, | |
| 287 | - | want: Cursor{BlockID: "h2"}, | |
| 288 | - | ok: true, | |
| 289 | - | }, | |
| 290 | - | { | |
| 291 | - | name: "multi level filter matches any listed level", | |
| 292 | - | from: "h2b", | |
| 293 | - | count: 1, | |
| 294 | - | levels: []int{1, 3}, | |
| 295 | - | want: Cursor{BlockID: "h3"}, | |
| 296 | - | ok: true, | |
| 297 | - | }, | |
| 298 | - | { | |
| 299 | - | name: "no matching level returns false", | |
| 300 | - | from: "h1b", | |
| 301 | - | levels: []int{6}, | |
| 302 | - | }, | |
| 303 | - | { | |
| 304 | - | name: "before first heading returns false", | |
| 305 | - | from: "h1", | |
| 306 | - | }, | |
| 307 | - | { | |
| 308 | - | name: "missing cursor returns false", | |
| 309 | - | from: "missing", | |
| 310 | - | }, | |
| 311 | - | } | |
| 312 | - | ||
| 313 | - | for _, tt := range tests { | |
| 314 | - | t.Run(tt.name, func(t *testing.T) { | |
| 315 | - | before := doc.Blocks() | |
| 316 | - | got, ok := doc.PreviousHeading(Cursor{BlockID: tt.from}, tt.count, tt.levels...) | |
| 317 | - | if ok != tt.ok || got != tt.want { | |
| 318 | - | t.Fatalf("PreviousHeading(%q, %d, %v) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, tt.levels, got, ok, tt.want, tt.ok) | |
| 319 | - | } | |
| 320 | - | if after := doc.Blocks(); !reflect.DeepEqual(after, before) { | |
| 321 | - | t.Fatalf("PreviousHeading mutated document: before %#v after %#v", before, after) | |
| 322 | - | } | |
| 323 | - | }) | |
| 324 | - | } | |
| 325 | - | } | |
| 326 | - | ||
| 327 | - | func TestHeadingMotionsOnEmptyAndHeadinglessDocuments(t *testing.T) { | |
| 328 | - | tests := []struct { | |
| 329 | - | name string | |
| 330 | - | blocks []Block | |
| 331 | - | from Cursor | |
| 332 | - | }{ | |
| 333 | - | { | |
| 334 | - | name: "empty document", | |
| 335 | - | from: Cursor{BlockID: "missing"}, | |
| 336 | - | }, | |
| 337 | - | { | |
| 338 | - | name: "document without headings", | |
| 339 | - | blocks: []Block{ | |
| 340 | - | {ID: "p0", Kind: BlockKind("paragraph")}, | |
| 341 | - | {ID: "p1", Kind: BlockKind("list_item")}, | |
| 342 | - | }, | |
| 343 | - | from: Cursor{BlockID: "p0"}, | |
| 344 | - | }, | |
| 345 | - | } | |
| 346 | - | ||
| 347 | - | for _, tt := range tests { | |
| 348 | - | t.Run(tt.name, func(t *testing.T) { | |
| 349 | - | doc := mustDocument(t, tt.blocks) | |
| 350 | - | ||
| 351 | - | if got, ok := doc.NextHeading(tt.from, 1); ok || got != (Cursor{}) { | |
| 352 | - | t.Fatalf("NextHeading() = (%#v, %v), want zero cursor and false", got, ok) | |
| 353 | - | } | |
| 354 | - | if got, ok := doc.PreviousHeading(tt.from, 1); ok || got != (Cursor{}) { | |
| 355 | - | t.Fatalf("PreviousHeading() = (%#v, %v), want zero cursor and false", got, ok) | |
| 356 | - | } | |
| 357 | - | }) | |
| 358 | - | } | |
| 359 | - | } | |
| 360 | - | ||
| 361 | - | func headingMotionDocument(t *testing.T) Document { | |
| 362 | - | t.Helper() | |
| 363 | - | ||
| 364 | - | return mustDocument(t, []Block{ | |
| 365 | - | {ID: "p0", Kind: BlockKindParagraph}, | |
| 366 | - | {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1}, | |
| 367 | - | {ID: "p1", Kind: BlockKindParagraph}, | |
| 368 | - | {ID: "h2", Kind: BlockKindHeading, HeadingLevel: 2}, | |
| 369 | - | {ID: "h3", Kind: BlockKindHeading, HeadingLevel: 3}, | |
| 370 | - | {ID: "h2b", Kind: BlockKindHeading, HeadingLevel: 2}, | |
| 371 | - | {ID: "p2", Kind: BlockKindParagraph}, | |
| 372 | - | {ID: "h1b", Kind: BlockKindHeading, HeadingLevel: 1}, | |
| 373 | - | }) | |
| 374 | - | } | |
| 375 | - | ||
| 376 | - | func mustDocument(t *testing.T, blocks []Block) Document { | |
| 377 | - | t.Helper() | |
| 378 | - | ||
| 379 | - | doc, err := NewDocument(blocks) | |
| 380 | - | if err != nil { | |
| 381 | - | t.Fatalf("NewDocument() error = %v", err) | |
| 382 | - | } | |
| 383 | - | return doc | |
| 384 | - | } | |
| @@ -1,384 +0,0 @@ | |||||
| 1 | - | package docmodel | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "errors" | |||
| 5 | - | "reflect" | |||
| 6 | - | "testing" | |||
| 7 | - | ) | |||
| 8 | - | ||||
| 9 | - | func TestNewDocumentValidation(t *testing.T) { | |||
| 10 | - | tests := []struct { | |||
| 11 | - | name string | |||
| 12 | - | blocks []Block | |||
| 13 | - | wantErr error | |||
| 14 | - | }{ | |||
| 15 | - | { | |||
| 16 | - | name: "empty document is valid", | |||
| 17 | - | }, | |||
| 18 | - | { | |||
| 19 | - | name: "valid headings and non headings", | |||
| 20 | - | blocks: []Block{ | |||
| 21 | - | {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1}, | |||
| 22 | - | {ID: "body", Kind: BlockKind("paragraph")}, | |||
| 23 | - | {ID: "h6", Kind: BlockKindHeading, HeadingLevel: 6}, | |||
| 24 | - | }, | |||
| 25 | - | }, | |||
| 26 | - | { | |||
| 27 | - | name: "empty block ID is invalid", | |||
| 28 | - | blocks: []Block{ | |||
| 29 | - | {ID: "ok", Kind: BlockKind("paragraph")}, | |||
| 30 | - | {ID: "", Kind: BlockKindHeading, HeadingLevel: 1}, | |||
| 31 | - | }, | |||
| 32 | - | wantErr: ErrEmptyBlockID, | |||
| 33 | - | }, | |||
| 34 | - | { | |||
| 35 | - | name: "duplicate block ID is invalid", | |||
| 36 | - | blocks: []Block{ | |||
| 37 | - | {ID: "same", Kind: BlockKindHeading, HeadingLevel: 1}, | |||
| 38 | - | {ID: "same", Kind: BlockKind("paragraph")}, | |||
| 39 | - | }, | |||
| 40 | - | wantErr: ErrDuplicateBlockID, | |||
| 41 | - | }, | |||
| 42 | - | { | |||
| 43 | - | name: "heading level below one is invalid", | |||
| 44 | - | blocks: []Block{ | |||
| 45 | - | {ID: "h0", Kind: BlockKindHeading, HeadingLevel: 0}, | |||
| 46 | - | }, | |||
| 47 | - | wantErr: ErrInvalidHeadingLevel, | |||
| 48 | - | }, | |||
| 49 | - | { | |||
| 50 | - | name: "heading level above six is invalid", | |||
| 51 | - | blocks: []Block{ | |||
| 52 | - | {ID: "h7", Kind: BlockKindHeading, HeadingLevel: 7}, | |||
| 53 | - | }, | |||
| 54 | - | wantErr: ErrInvalidHeadingLevel, | |||
| 55 | - | }, | |||
| 56 | - | { | |||
| 57 | - | name: "non heading cannot carry heading level", | |||
| 58 | - | blocks: []Block{ | |||
| 59 | - | {ID: "body", Kind: BlockKind("paragraph"), HeadingLevel: 2}, | |||
| 60 | - | }, | |||
| 61 | - | wantErr: ErrUnexpectedHeadingLevel, | |||
| 62 | - | }, | |||
| 63 | - | } | |||
| 64 | - | ||||
| 65 | - | for _, tt := range tests { | |||
| 66 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 67 | - | _, err := NewDocument(tt.blocks) | |||
| 68 | - | if tt.wantErr == nil { | |||
| 69 | - | if err != nil { | |||
| 70 | - | t.Fatalf("NewDocument() error = %v, want nil", err) | |||
| 71 | - | } | |||
| 72 | - | return | |||
| 73 | - | } | |||
| 74 | - | ||||
| 75 | - | if !errors.Is(err, tt.wantErr) { | |||
| 76 | - | t.Fatalf("NewDocument() error = %v, want errors.Is(_, %v)", err, tt.wantErr) | |||
| 77 | - | } | |||
| 78 | - | }) | |||
| 79 | - | } | |||
| 80 | - | } | |||
| 81 | - | ||||
| 82 | - | func TestDocumentOrderAndCopies(t *testing.T) { | |||
| 83 | - | blocks := []Block{ | |||
| 84 | - | {ID: "intro", Kind: BlockKindHeading, HeadingLevel: 1}, | |||
| 85 | - | {ID: "body", Kind: BlockKind("paragraph")}, | |||
| 86 | - | {ID: "details", Kind: BlockKindHeading, HeadingLevel: 2}, | |||
| 87 | - | } | |||
| 88 | - | ||||
| 89 | - | doc, err := NewDocument(blocks) | |||
| 90 | - | if err != nil { | |||
| 91 | - | t.Fatalf("NewDocument() error = %v", err) | |||
| 92 | - | } | |||
| 93 | - | ||||
| 94 | - | blocks[0] = Block{ID: "mutated", Kind: BlockKindHeading, HeadingLevel: 6} | |||
| 95 | - | ||||
| 96 | - | got := doc.Blocks() | |||
| 97 | - | want := []Block{ | |||
| 98 | - | {ID: "intro", Kind: BlockKindHeading, HeadingLevel: 1}, | |||
| 99 | - | {ID: "body", Kind: BlockKind("paragraph")}, | |||
| 100 | - | {ID: "details", Kind: BlockKindHeading, HeadingLevel: 2}, | |||
| 101 | - | } | |||
| 102 | - | if !reflect.DeepEqual(got, want) { | |||
| 103 | - | t.Fatalf("Blocks() = %#v, want %#v", got, want) | |||
| 104 | - | } | |||
| 105 | - | ||||
| 106 | - | got[0] = Block{ID: "also-mutated", Kind: BlockKind("paragraph")} | |||
| 107 | - | gotAgain := doc.Blocks() | |||
| 108 | - | if !reflect.DeepEqual(gotAgain, want) { | |||
| 109 | - | t.Fatalf("Blocks() after mutating returned slice = %#v, want %#v", gotAgain, want) | |||
| 110 | - | } | |||
| 111 | - | } | |||
| 112 | - | ||||
| 113 | - | func TestDocumentCursorNavigation(t *testing.T) { | |||
| 114 | - | t.Run("empty document has no first cursor", func(t *testing.T) { | |||
| 115 | - | doc, err := NewDocument(nil) | |||
| 116 | - | if err != nil { | |||
| 117 | - | t.Fatalf("NewDocument() error = %v", err) | |||
| 118 | - | } | |||
| 119 | - | ||||
| 120 | - | if got, ok := doc.FirstCursor(); ok || got != (Cursor{}) { | |||
| 121 | - | t.Fatalf("FirstCursor() = (%#v, %v), want zero cursor and false", got, ok) | |||
| 122 | - | } | |||
| 123 | - | }) | |||
| 124 | - | ||||
| 125 | - | t.Run("cursor by ID and first cursor use document order", func(t *testing.T) { | |||
| 126 | - | doc := mustDocument(t, []Block{ | |||
| 127 | - | {ID: "first", Kind: BlockKind("paragraph")}, | |||
| 128 | - | {ID: "second", Kind: BlockKindHeading, HeadingLevel: 2}, | |||
| 129 | - | }) | |||
| 130 | - | ||||
| 131 | - | if got, ok := doc.FirstCursor(); !ok || got != (Cursor{BlockID: "first"}) { | |||
| 132 | - | t.Fatalf("FirstCursor() = (%#v, %v), want first cursor and true", got, ok) | |||
| 133 | - | } | |||
| 134 | - | ||||
| 135 | - | if got, ok := doc.CursorByID("second"); !ok || got != (Cursor{BlockID: "second"}) { | |||
| 136 | - | t.Fatalf("CursorByID(second) = (%#v, %v), want second cursor and true", got, ok) | |||
| 137 | - | } | |||
| 138 | - | ||||
| 139 | - | if got, ok := doc.CursorByID("missing"); ok || got != (Cursor{}) { | |||
| 140 | - | t.Fatalf("CursorByID(missing) = (%#v, %v), want zero cursor and false", got, ok) | |||
| 141 | - | } | |||
| 142 | - | }) | |||
| 143 | - | } | |||
| 144 | - | ||||
| 145 | - | func TestStrictNextHeading(t *testing.T) { | |||
| 146 | - | doc := headingMotionDocument(t) | |||
| 147 | - | ||||
| 148 | - | tests := []struct { | |||
| 149 | - | name string | |||
| 150 | - | from BlockID | |||
| 151 | - | count int | |||
| 152 | - | levels []int | |||
| 153 | - | want Cursor | |||
| 154 | - | ok bool | |||
| 155 | - | }{ | |||
| 156 | - | { | |||
| 157 | - | name: "from non heading uses next heading", | |||
| 158 | - | from: "p0", | |||
| 159 | - | want: Cursor{BlockID: "h1"}, | |||
| 160 | - | ok: true, | |||
| 161 | - | }, | |||
| 162 | - | { | |||
| 163 | - | name: "count below one means one", | |||
| 164 | - | from: "p1", | |||
| 165 | - | count: -3, | |||
| 166 | - | want: Cursor{BlockID: "h2"}, | |||
| 167 | - | ok: true, | |||
| 168 | - | }, | |||
| 169 | - | { | |||
| 170 | - | name: "strict next does not match current heading", | |||
| 171 | - | from: "h1", | |||
| 172 | - | count: 1, | |||
| 173 | - | want: Cursor{BlockID: "h2"}, | |||
| 174 | - | ok: true, | |||
| 175 | - | }, | |||
| 176 | - | { | |||
| 177 | - | name: "count selects nth next heading", | |||
| 178 | - | from: "h1", | |||
| 179 | - | count: 2, | |||
| 180 | - | want: Cursor{BlockID: "h3"}, | |||
| 181 | - | ok: true, | |||
| 182 | - | }, | |||
| 183 | - | { | |||
| 184 | - | name: "level filter skips other headings", | |||
| 185 | - | from: "h1", | |||
| 186 | - | count: 1, | |||
| 187 | - | levels: []int{1}, | |||
| 188 | - | want: Cursor{BlockID: "h1b"}, | |||
| 189 | - | ok: true, | |||
| 190 | - | }, | |||
| 191 | - | { | |||
| 192 | - | name: "count applies after filtering", | |||
| 193 | - | from: "h1", | |||
| 194 | - | count: 2, | |||
| 195 | - | levels: []int{2}, | |||
| 196 | - | want: Cursor{BlockID: "h2b"}, | |||
| 197 | - | ok: true, | |||
| 198 | - | }, | |||
| 199 | - | { | |||
| 200 | - | name: "multi level filter matches any listed level", | |||
| 201 | - | from: "h2", | |||
| 202 | - | count: 1, | |||
| 203 | - | levels: []int{1, 3}, | |||
| 204 | - | want: Cursor{BlockID: "h3"}, | |||
| 205 | - | ok: true, | |||
| 206 | - | }, | |||
| 207 | - | { | |||
| 208 | - | name: "no matching level returns false", | |||
| 209 | - | from: "p0", | |||
| 210 | - | levels: []int{6}, | |||
| 211 | - | }, | |||
| 212 | - | { | |||
| 213 | - | name: "past last heading returns false", | |||
| 214 | - | from: "h1b", | |||
| 215 | - | }, | |||
| 216 | - | { | |||
| 217 | - | name: "missing cursor returns false", | |||
| 218 | - | from: "missing", | |||
| 219 | - | }, | |||
| 220 | - | } | |||
| 221 | - | ||||
| 222 | - | for _, tt := range tests { | |||
| 223 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 224 | - | before := doc.Blocks() | |||
| 225 | - | got, ok := doc.NextHeading(Cursor{BlockID: tt.from}, tt.count, tt.levels...) | |||
| 226 | - | if ok != tt.ok || got != tt.want { | |||
| 227 | - | t.Fatalf("NextHeading(%q, %d, %v) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, tt.levels, got, ok, tt.want, tt.ok) | |||
| 228 | - | } | |||
| 229 | - | if after := doc.Blocks(); !reflect.DeepEqual(after, before) { | |||
| 230 | - | t.Fatalf("NextHeading mutated document: before %#v after %#v", before, after) | |||
| 231 | - | } | |||
| 232 | - | }) | |||
| 233 | - | } | |||
| 234 | - | } | |||
| 235 | - | ||||
| 236 | - | func TestStrictPreviousHeading(t *testing.T) { | |||
| 237 | - | doc := headingMotionDocument(t) | |||
| 238 | - | ||||
| 239 | - | tests := []struct { | |||
| 240 | - | name string | |||
| 241 | - | from BlockID | |||
| 242 | - | count int | |||
| 243 | - | levels []int | |||
| 244 | - | want Cursor | |||
| 245 | - | ok bool | |||
| 246 | - | }{ | |||
| 247 | - | { | |||
| 248 | - | name: "from non heading uses previous heading", | |||
| 249 | - | from: "p2", | |||
| 250 | - | want: Cursor{BlockID: "h2b"}, | |||
| 251 | - | ok: true, | |||
| 252 | - | }, | |||
| 253 | - | { | |||
| 254 | - | name: "count below one means one", | |||
| 255 | - | from: "p2", | |||
| 256 | - | count: 0, | |||
| 257 | - | want: Cursor{BlockID: "h2b"}, | |||
| 258 | - | ok: true, | |||
| 259 | - | }, | |||
| 260 | - | { | |||
| 261 | - | name: "strict previous does not match current heading", | |||
| 262 | - | from: "h1b", | |||
| 263 | - | count: 1, | |||
| 264 | - | want: Cursor{BlockID: "h2b"}, | |||
| 265 | - | ok: true, | |||
| 266 | - | }, | |||
| 267 | - | { | |||
| 268 | - | name: "count selects nth previous heading", | |||
| 269 | - | from: "h1b", | |||
| 270 | - | count: 2, | |||
| 271 | - | want: Cursor{BlockID: "h3"}, | |||
| 272 | - | ok: true, | |||
| 273 | - | }, | |||
| 274 | - | { | |||
| 275 | - | name: "level filter skips other headings", | |||
| 276 | - | from: "h1b", | |||
| 277 | - | count: 1, | |||
| 278 | - | levels: []int{1}, | |||
| 279 | - | want: Cursor{BlockID: "h1"}, | |||
| 280 | - | ok: true, | |||
| 281 | - | }, | |||
| 282 | - | { | |||
| 283 | - | name: "count applies after filtering", | |||
| 284 | - | from: "h1b", | |||
| 285 | - | count: 2, | |||
| 286 | - | levels: []int{2}, | |||
| 287 | - | want: Cursor{BlockID: "h2"}, | |||
| 288 | - | ok: true, | |||
| 289 | - | }, | |||
| 290 | - | { | |||
| 291 | - | name: "multi level filter matches any listed level", | |||
| 292 | - | from: "h2b", | |||
| 293 | - | count: 1, | |||
| 294 | - | levels: []int{1, 3}, | |||
| 295 | - | want: Cursor{BlockID: "h3"}, | |||
| 296 | - | ok: true, | |||
| 297 | - | }, | |||
| 298 | - | { | |||
| 299 | - | name: "no matching level returns false", | |||
| 300 | - | from: "h1b", | |||
| 301 | - | levels: []int{6}, | |||
| 302 | - | }, | |||
| 303 | - | { | |||
| 304 | - | name: "before first heading returns false", | |||
| 305 | - | from: "h1", | |||
| 306 | - | }, | |||
| 307 | - | { | |||
| 308 | - | name: "missing cursor returns false", | |||
| 309 | - | from: "missing", | |||
| 310 | - | }, | |||
| 311 | - | } | |||
| 312 | - | ||||
| 313 | - | for _, tt := range tests { | |||
| 314 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 315 | - | before := doc.Blocks() | |||
| 316 | - | got, ok := doc.PreviousHeading(Cursor{BlockID: tt.from}, tt.count, tt.levels...) | |||
| 317 | - | if ok != tt.ok || got != tt.want { | |||
| 318 | - | t.Fatalf("PreviousHeading(%q, %d, %v) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, tt.levels, got, ok, tt.want, tt.ok) | |||
| 319 | - | } | |||
| 320 | - | if after := doc.Blocks(); !reflect.DeepEqual(after, before) { | |||
| 321 | - | t.Fatalf("PreviousHeading mutated document: before %#v after %#v", before, after) | |||
| 322 | - | } | |||
| 323 | - | }) | |||
| 324 | - | } | |||
| 325 | - | } | |||
| 326 | - | ||||
| 327 | - | func TestHeadingMotionsOnEmptyAndHeadinglessDocuments(t *testing.T) { | |||
| 328 | - | tests := []struct { | |||
| 329 | - | name string | |||
| 330 | - | blocks []Block | |||
| 331 | - | from Cursor | |||
| 332 | - | }{ | |||
| 333 | - | { | |||
| 334 | - | name: "empty document", | |||
| 335 | - | from: Cursor{BlockID: "missing"}, | |||
| 336 | - | }, | |||
| 337 | - | { | |||
| 338 | - | name: "document without headings", | |||
| 339 | - | blocks: []Block{ | |||
| 340 | - | {ID: "p0", Kind: BlockKind("paragraph")}, | |||
| 341 | - | {ID: "p1", Kind: BlockKind("list_item")}, | |||
| 342 | - | }, | |||
| 343 | - | from: Cursor{BlockID: "p0"}, | |||
| 344 | - | }, | |||
| 345 | - | } | |||
| 346 | - | ||||
| 347 | - | for _, tt := range tests { | |||
| 348 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 349 | - | doc := mustDocument(t, tt.blocks) | |||
| 350 | - | ||||
| 351 | - | if got, ok := doc.NextHeading(tt.from, 1); ok || got != (Cursor{}) { | |||
| 352 | - | t.Fatalf("NextHeading() = (%#v, %v), want zero cursor and false", got, ok) | |||
| 353 | - | } | |||
| 354 | - | if got, ok := doc.PreviousHeading(tt.from, 1); ok || got != (Cursor{}) { | |||
| 355 | - | t.Fatalf("PreviousHeading() = (%#v, %v), want zero cursor and false", got, ok) | |||
| 356 | - | } | |||
| 357 | - | }) | |||
| 358 | - | } | |||
| 359 | - | } | |||
| 360 | - | ||||
| 361 | - | func headingMotionDocument(t *testing.T) Document { | |||
| 362 | - | t.Helper() | |||
| 363 | - | ||||
| 364 | - | return mustDocument(t, []Block{ | |||
| 365 | - | {ID: "p0", Kind: BlockKindParagraph}, | |||
| 366 | - | {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1}, | |||
| 367 | - | {ID: "p1", Kind: BlockKindParagraph}, | |||
| 368 | - | {ID: "h2", Kind: BlockKindHeading, HeadingLevel: 2}, | |||
| 369 | - | {ID: "h3", Kind: BlockKindHeading, HeadingLevel: 3}, | |||
| 370 | - | {ID: "h2b", Kind: BlockKindHeading, HeadingLevel: 2}, | |||
| 371 | - | {ID: "p2", Kind: BlockKindParagraph}, | |||
| 372 | - | {ID: "h1b", Kind: BlockKindHeading, HeadingLevel: 1}, | |||
| 373 | - | }) | |||
| 374 | - | } | |||
| 375 | - | ||||
| 376 | - | func mustDocument(t *testing.T, blocks []Block) Document { | |||
| 377 | - | t.Helper() | |||
| 378 | - | ||||
| 379 | - | doc, err := NewDocument(blocks) | |||
| 380 | - | if err != nil { | |||
| 381 | - | t.Fatalf("NewDocument() error = %v", err) | |||
| 382 | - | } | |||
| 383 | - | return doc | |||
| 384 | - | } | |||
D internal/domain/docmodel/list_motions_test.go +0 -176
| @@ -1,176 +0,0 @@ | |||
| 1 | - | package docmodel | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "reflect" | |
| 5 | - | "testing" | |
| 6 | - | ) | |
| 7 | - | ||
| 8 | - | func TestListNextBlockOfKindSupportsStrictForwardNavigation(t *testing.T) { | |
| 9 | - | doc := listMotionDocument(t) | |
| 10 | - | ||
| 11 | - | tests := []struct { | |
| 12 | - | name string | |
| 13 | - | from Cursor | |
| 14 | - | count int | |
| 15 | - | want Cursor | |
| 16 | - | ok bool | |
| 17 | - | }{ | |
| 18 | - | { | |
| 19 | - | name: "forward list motion skips non lists", | |
| 20 | - | from: Cursor{BlockID: "h1"}, | |
| 21 | - | want: Cursor{BlockID: "list1"}, | |
| 22 | - | ok: true, | |
| 23 | - | }, | |
| 24 | - | { | |
| 25 | - | name: "count below one means one", | |
| 26 | - | from: Cursor{BlockID: "h1"}, | |
| 27 | - | count: -1, | |
| 28 | - | want: Cursor{BlockID: "list1"}, | |
| 29 | - | ok: true, | |
| 30 | - | }, | |
| 31 | - | { | |
| 32 | - | name: "count zero means one", | |
| 33 | - | from: Cursor{BlockID: "code1"}, | |
| 34 | - | count: 0, | |
| 35 | - | want: Cursor{BlockID: "list2"}, | |
| 36 | - | ok: true, | |
| 37 | - | }, | |
| 38 | - | { | |
| 39 | - | name: "strict forward list motion does not match current list", | |
| 40 | - | from: Cursor{BlockID: "list1"}, | |
| 41 | - | count: 1, | |
| 42 | - | want: Cursor{BlockID: "list2"}, | |
| 43 | - | ok: true, | |
| 44 | - | }, | |
| 45 | - | { | |
| 46 | - | name: "count selects nth next list", | |
| 47 | - | from: Cursor{BlockID: "p0"}, | |
| 48 | - | count: 2, | |
| 49 | - | want: Cursor{BlockID: "list2"}, | |
| 50 | - | ok: true, | |
| 51 | - | }, | |
| 52 | - | { | |
| 53 | - | name: "no later list returns false", | |
| 54 | - | from: Cursor{BlockID: "list3"}, | |
| 55 | - | }, | |
| 56 | - | { | |
| 57 | - | name: "count past later lists returns false", | |
| 58 | - | from: Cursor{BlockID: "list1"}, | |
| 59 | - | count: 3, | |
| 60 | - | }, | |
| 61 | - | { | |
| 62 | - | name: "zero cursor returns false", | |
| 63 | - | from: Cursor{}, | |
| 64 | - | }, | |
| 65 | - | { | |
| 66 | - | name: "missing cursor returns false", | |
| 67 | - | from: Cursor{BlockID: "missing"}, | |
| 68 | - | }, | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | for _, tt := range tests { | |
| 72 | - | t.Run(tt.name, func(t *testing.T) { | |
| 73 | - | before := doc.Blocks() | |
| 74 | - | got, ok := doc.NextBlockOfKind(tt.from, BlockKindList, tt.count) | |
| 75 | - | if ok != tt.ok || got != tt.want { | |
| 76 | - | t.Fatalf("NextBlockOfKind(%#v, BlockKindList, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, got, ok, tt.want, tt.ok) | |
| 77 | - | } | |
| 78 | - | if after := doc.Blocks(); !reflect.DeepEqual(after, before) { | |
| 79 | - | t.Fatalf("NextBlockOfKind mutated document: before %#v after %#v", before, after) | |
| 80 | - | } | |
| 81 | - | }) | |
| 82 | - | } | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | func TestListPreviousBlockOfKindSupportsStrictBackwardNavigation(t *testing.T) { | |
| 86 | - | doc := listMotionDocument(t) | |
| 87 | - | ||
| 88 | - | tests := []struct { | |
| 89 | - | name string | |
| 90 | - | from Cursor | |
| 91 | - | count int | |
| 92 | - | want Cursor | |
| 93 | - | ok bool | |
| 94 | - | }{ | |
| 95 | - | { | |
| 96 | - | name: "backward list motion skips non lists", | |
| 97 | - | from: Cursor{BlockID: "table1"}, | |
| 98 | - | want: Cursor{BlockID: "list3"}, | |
| 99 | - | ok: true, | |
| 100 | - | }, | |
| 101 | - | { | |
| 102 | - | name: "count below one means one", | |
| 103 | - | from: Cursor{BlockID: "table1"}, | |
| 104 | - | count: -1, | |
| 105 | - | want: Cursor{BlockID: "list3"}, | |
| 106 | - | ok: true, | |
| 107 | - | }, | |
| 108 | - | { | |
| 109 | - | name: "count zero means one", | |
| 110 | - | from: Cursor{BlockID: "code1"}, | |
| 111 | - | count: 0, | |
| 112 | - | want: Cursor{BlockID: "list1"}, | |
| 113 | - | ok: true, | |
| 114 | - | }, | |
| 115 | - | { | |
| 116 | - | name: "strict backward list motion does not match current list", | |
| 117 | - | from: Cursor{BlockID: "list3"}, | |
| 118 | - | count: 1, | |
| 119 | - | want: Cursor{BlockID: "list2"}, | |
| 120 | - | ok: true, | |
| 121 | - | }, | |
| 122 | - | { | |
| 123 | - | name: "count selects nth previous list", | |
| 124 | - | from: Cursor{BlockID: "list3"}, | |
| 125 | - | count: 2, | |
| 126 | - | want: Cursor{BlockID: "list1"}, | |
| 127 | - | ok: true, | |
| 128 | - | }, | |
| 129 | - | { | |
| 130 | - | name: "no earlier list returns false", | |
| 131 | - | from: Cursor{BlockID: "list0"}, | |
| 132 | - | }, | |
| 133 | - | { | |
| 134 | - | name: "count past earlier lists returns false", | |
| 135 | - | from: Cursor{BlockID: "list2"}, | |
| 136 | - | count: 3, | |
| 137 | - | }, | |
| 138 | - | { | |
| 139 | - | name: "zero cursor returns false", | |
| 140 | - | from: Cursor{}, | |
| 141 | - | }, | |
| 142 | - | { | |
| 143 | - | name: "missing cursor returns false", | |
| 144 | - | from: Cursor{BlockID: "missing"}, | |
| 145 | - | }, | |
| 146 | - | } | |
| 147 | - | ||
| 148 | - | for _, tt := range tests { | |
| 149 | - | t.Run(tt.name, func(t *testing.T) { | |
| 150 | - | before := doc.Blocks() | |
| 151 | - | got, ok := doc.PreviousBlockOfKind(tt.from, BlockKindList, tt.count) | |
| 152 | - | if ok != tt.ok || got != tt.want { | |
| 153 | - | t.Fatalf("PreviousBlockOfKind(%#v, BlockKindList, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, got, ok, tt.want, tt.ok) | |
| 154 | - | } | |
| 155 | - | if after := doc.Blocks(); !reflect.DeepEqual(after, before) { | |
| 156 | - | t.Fatalf("PreviousBlockOfKind mutated document: before %#v after %#v", before, after) | |
| 157 | - | } | |
| 158 | - | }) | |
| 159 | - | } | |
| 160 | - | } | |
| 161 | - | ||
| 162 | - | func listMotionDocument(t *testing.T) Document { | |
| 163 | - | t.Helper() | |
| 164 | - | ||
| 165 | - | return mustDocument(t, []Block{ | |
| 166 | - | {ID: "list0", Kind: BlockKindList}, | |
| 167 | - | {ID: "p0", Kind: BlockKindParagraph}, | |
| 168 | - | {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1}, | |
| 169 | - | {ID: "list1", Kind: BlockKindList}, | |
| 170 | - | {ID: "code1", Kind: BlockKindCode}, | |
| 171 | - | {ID: "list2", Kind: BlockKindList}, | |
| 172 | - | {ID: "image1", Kind: BlockKindImage}, | |
| 173 | - | {ID: "list3", Kind: BlockKindList}, | |
| 174 | - | {ID: "table1", Kind: BlockKindTable}, | |
| 175 | - | }) | |
| 176 | - | } | |
| @@ -1,176 +0,0 @@ | |||||
| 1 | - | package docmodel | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "reflect" | |||
| 5 | - | "testing" | |||
| 6 | - | ) | |||
| 7 | - | ||||
| 8 | - | func TestListNextBlockOfKindSupportsStrictForwardNavigation(t *testing.T) { | |||
| 9 | - | doc := listMotionDocument(t) | |||
| 10 | - | ||||
| 11 | - | tests := []struct { | |||
| 12 | - | name string | |||
| 13 | - | from Cursor | |||
| 14 | - | count int | |||
| 15 | - | want Cursor | |||
| 16 | - | ok bool | |||
| 17 | - | }{ | |||
| 18 | - | { | |||
| 19 | - | name: "forward list motion skips non lists", | |||
| 20 | - | from: Cursor{BlockID: "h1"}, | |||
| 21 | - | want: Cursor{BlockID: "list1"}, | |||
| 22 | - | ok: true, | |||
| 23 | - | }, | |||
| 24 | - | { | |||
| 25 | - | name: "count below one means one", | |||
| 26 | - | from: Cursor{BlockID: "h1"}, | |||
| 27 | - | count: -1, | |||
| 28 | - | want: Cursor{BlockID: "list1"}, | |||
| 29 | - | ok: true, | |||
| 30 | - | }, | |||
| 31 | - | { | |||
| 32 | - | name: "count zero means one", | |||
| 33 | - | from: Cursor{BlockID: "code1"}, | |||
| 34 | - | count: 0, | |||
| 35 | - | want: Cursor{BlockID: "list2"}, | |||
| 36 | - | ok: true, | |||
| 37 | - | }, | |||
| 38 | - | { | |||
| 39 | - | name: "strict forward list motion does not match current list", | |||
| 40 | - | from: Cursor{BlockID: "list1"}, | |||
| 41 | - | count: 1, | |||
| 42 | - | want: Cursor{BlockID: "list2"}, | |||
| 43 | - | ok: true, | |||
| 44 | - | }, | |||
| 45 | - | { | |||
| 46 | - | name: "count selects nth next list", | |||
| 47 | - | from: Cursor{BlockID: "p0"}, | |||
| 48 | - | count: 2, | |||
| 49 | - | want: Cursor{BlockID: "list2"}, | |||
| 50 | - | ok: true, | |||
| 51 | - | }, | |||
| 52 | - | { | |||
| 53 | - | name: "no later list returns false", | |||
| 54 | - | from: Cursor{BlockID: "list3"}, | |||
| 55 | - | }, | |||
| 56 | - | { | |||
| 57 | - | name: "count past later lists returns false", | |||
| 58 | - | from: Cursor{BlockID: "list1"}, | |||
| 59 | - | count: 3, | |||
| 60 | - | }, | |||
| 61 | - | { | |||
| 62 | - | name: "zero cursor returns false", | |||
| 63 | - | from: Cursor{}, | |||
| 64 | - | }, | |||
| 65 | - | { | |||
| 66 | - | name: "missing cursor returns false", | |||
| 67 | - | from: Cursor{BlockID: "missing"}, | |||
| 68 | - | }, | |||
| 69 | - | } | |||
| 70 | - | ||||
| 71 | - | for _, tt := range tests { | |||
| 72 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 73 | - | before := doc.Blocks() | |||
| 74 | - | got, ok := doc.NextBlockOfKind(tt.from, BlockKindList, tt.count) | |||
| 75 | - | if ok != tt.ok || got != tt.want { | |||
| 76 | - | t.Fatalf("NextBlockOfKind(%#v, BlockKindList, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, got, ok, tt.want, tt.ok) | |||
| 77 | - | } | |||
| 78 | - | if after := doc.Blocks(); !reflect.DeepEqual(after, before) { | |||
| 79 | - | t.Fatalf("NextBlockOfKind mutated document: before %#v after %#v", before, after) | |||
| 80 | - | } | |||
| 81 | - | }) | |||
| 82 | - | } | |||
| 83 | - | } | |||
| 84 | - | ||||
| 85 | - | func TestListPreviousBlockOfKindSupportsStrictBackwardNavigation(t *testing.T) { | |||
| 86 | - | doc := listMotionDocument(t) | |||
| 87 | - | ||||
| 88 | - | tests := []struct { | |||
| 89 | - | name string | |||
| 90 | - | from Cursor | |||
| 91 | - | count int | |||
| 92 | - | want Cursor | |||
| 93 | - | ok bool | |||
| 94 | - | }{ | |||
| 95 | - | { | |||
| 96 | - | name: "backward list motion skips non lists", | |||
| 97 | - | from: Cursor{BlockID: "table1"}, | |||
| 98 | - | want: Cursor{BlockID: "list3"}, | |||
| 99 | - | ok: true, | |||
| 100 | - | }, | |||
| 101 | - | { | |||
| 102 | - | name: "count below one means one", | |||
| 103 | - | from: Cursor{BlockID: "table1"}, | |||
| 104 | - | count: -1, | |||
| 105 | - | want: Cursor{BlockID: "list3"}, | |||
| 106 | - | ok: true, | |||
| 107 | - | }, | |||
| 108 | - | { | |||
| 109 | - | name: "count zero means one", | |||
| 110 | - | from: Cursor{BlockID: "code1"}, | |||
| 111 | - | count: 0, | |||
| 112 | - | want: Cursor{BlockID: "list1"}, | |||
| 113 | - | ok: true, | |||
| 114 | - | }, | |||
| 115 | - | { | |||
| 116 | - | name: "strict backward list motion does not match current list", | |||
| 117 | - | from: Cursor{BlockID: "list3"}, | |||
| 118 | - | count: 1, | |||
| 119 | - | want: Cursor{BlockID: "list2"}, | |||
| 120 | - | ok: true, | |||
| 121 | - | }, | |||
| 122 | - | { | |||
| 123 | - | name: "count selects nth previous list", | |||
| 124 | - | from: Cursor{BlockID: "list3"}, | |||
| 125 | - | count: 2, | |||
| 126 | - | want: Cursor{BlockID: "list1"}, | |||
| 127 | - | ok: true, | |||
| 128 | - | }, | |||
| 129 | - | { | |||
| 130 | - | name: "no earlier list returns false", | |||
| 131 | - | from: Cursor{BlockID: "list0"}, | |||
| 132 | - | }, | |||
| 133 | - | { | |||
| 134 | - | name: "count past earlier lists returns false", | |||
| 135 | - | from: Cursor{BlockID: "list2"}, | |||
| 136 | - | count: 3, | |||
| 137 | - | }, | |||
| 138 | - | { | |||
| 139 | - | name: "zero cursor returns false", | |||
| 140 | - | from: Cursor{}, | |||
| 141 | - | }, | |||
| 142 | - | { | |||
| 143 | - | name: "missing cursor returns false", | |||
| 144 | - | from: Cursor{BlockID: "missing"}, | |||
| 145 | - | }, | |||
| 146 | - | } | |||
| 147 | - | ||||
| 148 | - | for _, tt := range tests { | |||
| 149 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 150 | - | before := doc.Blocks() | |||
| 151 | - | got, ok := doc.PreviousBlockOfKind(tt.from, BlockKindList, tt.count) | |||
| 152 | - | if ok != tt.ok || got != tt.want { | |||
| 153 | - | t.Fatalf("PreviousBlockOfKind(%#v, BlockKindList, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, got, ok, tt.want, tt.ok) | |||
| 154 | - | } | |||
| 155 | - | if after := doc.Blocks(); !reflect.DeepEqual(after, before) { | |||
| 156 | - | t.Fatalf("PreviousBlockOfKind mutated document: before %#v after %#v", before, after) | |||
| 157 | - | } | |||
| 158 | - | }) | |||
| 159 | - | } | |||
| 160 | - | } | |||
| 161 | - | ||||
| 162 | - | func listMotionDocument(t *testing.T) Document { | |||
| 163 | - | t.Helper() | |||
| 164 | - | ||||
| 165 | - | return mustDocument(t, []Block{ | |||
| 166 | - | {ID: "list0", Kind: BlockKindList}, | |||
| 167 | - | {ID: "p0", Kind: BlockKindParagraph}, | |||
| 168 | - | {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1}, | |||
| 169 | - | {ID: "list1", Kind: BlockKindList}, | |||
| 170 | - | {ID: "code1", Kind: BlockKindCode}, | |||
| 171 | - | {ID: "list2", Kind: BlockKindList}, | |||
| 172 | - | {ID: "image1", Kind: BlockKindImage}, | |||
| 173 | - | {ID: "list3", Kind: BlockKindList}, | |||
| 174 | - | {ID: "table1", Kind: BlockKindTable}, | |||
| 175 | - | }) | |||
| 176 | - | } | |||
D internal/domain/docmodel/motions.go +0 -86
| @@ -1,86 +0,0 @@ | |||
| 1 | - | package docmodel | |
| 2 | - | ||
| 3 | - | // NextHeading returns the cursor for the count-th heading after from. Motions | |
| 4 | - | // are strict: a heading at from is not considered a match. A count less than or | |
| 5 | - | // equal to zero is treated as one. When levels are provided, only headings with | |
| 6 | - | // one of those levels match. | |
| 7 | - | func (d Document) NextHeading(from Cursor, count int, levels ...int) (Cursor, bool) { | |
| 8 | - | return d.headingMotion(from, count, 1, levels) | |
| 9 | - | } | |
| 10 | - | ||
| 11 | - | // PreviousHeading returns the cursor for the count-th heading before from. | |
| 12 | - | // Motions are strict: a heading at from is not considered a match. A count less | |
| 13 | - | // than or equal to zero is treated as one. When levels are provided, only | |
| 14 | - | // headings with one of those levels match. | |
| 15 | - | func (d Document) PreviousHeading(from Cursor, count int, levels ...int) (Cursor, bool) { | |
| 16 | - | return d.headingMotion(from, count, -1, levels) | |
| 17 | - | } | |
| 18 | - | ||
| 19 | - | // NextBlockOfKind returns the cursor for the count-th block of kind after from. | |
| 20 | - | // Motions are strict: a block at from is not considered a match. A count less | |
| 21 | - | // than or equal to zero is treated as one. Empty kinds never match. | |
| 22 | - | func (d Document) NextBlockOfKind(from Cursor, kind BlockKind, count int) (Cursor, bool) { | |
| 23 | - | return d.blockKindMotion(from, kind, count, 1) | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | // PreviousBlockOfKind returns the cursor for the count-th block of kind before | |
| 27 | - | // from. Motions are strict: a block at from is not considered a match. A count | |
| 28 | - | // less than or equal to zero is treated as one. Empty kinds never match. | |
| 29 | - | func (d Document) PreviousBlockOfKind(from Cursor, kind BlockKind, count int) (Cursor, bool) { | |
| 30 | - | return d.blockKindMotion(from, kind, count, -1) | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | func (d Document) headingMotion(from Cursor, count, direction int, levels []int) (Cursor, bool) { | |
| 34 | - | return d.matchingBlockMotion(from, count, direction, func(block Block) bool { | |
| 35 | - | return block.IsHeading() && headingLevelMatches(block.HeadingLevel, levels) | |
| 36 | - | }) | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | func (d Document) blockKindMotion(from Cursor, kind BlockKind, count, direction int) (Cursor, bool) { | |
| 40 | - | if kind == "" { | |
| 41 | - | return Cursor{}, false | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | return d.matchingBlockMotion(from, count, direction, func(block Block) bool { | |
| 45 | - | return block.Kind == kind | |
| 46 | - | }) | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | func (d Document) matchingBlockMotion(from Cursor, count, direction int, matches func(Block) bool) (Cursor, bool) { | |
| 50 | - | start, ok := d.indexByID[from.BlockID] | |
| 51 | - | if !ok { | |
| 52 | - | return Cursor{}, false | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | if count <= 0 { | |
| 56 | - | count = 1 | |
| 57 | - | } | |
| 58 | - | ||
| 59 | - | remaining := count | |
| 60 | - | for i := start + direction; i >= 0 && i < len(d.blocks); i += direction { | |
| 61 | - | block := d.blocks[i] | |
| 62 | - | if !matches(block) { | |
| 63 | - | continue | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | remaining-- | |
| 67 | - | if remaining == 0 { | |
| 68 | - | return Cursor{BlockID: block.ID}, true | |
| 69 | - | } | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | return Cursor{}, false | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | func headingLevelMatches(level int, levels []int) bool { | |
| 76 | - | if len(levels) == 0 { | |
| 77 | - | return true | |
| 78 | - | } | |
| 79 | - | ||
| 80 | - | for _, allowed := range levels { | |
| 81 | - | if level == allowed { | |
| 82 | - | return true | |
| 83 | - | } | |
| 84 | - | } | |
| 85 | - | return false | |
| 86 | - | } | |
| @@ -1,86 +0,0 @@ | |||||
| 1 | - | package docmodel | |||
| 2 | - | ||||
| 3 | - | // NextHeading returns the cursor for the count-th heading after from. Motions | |||
| 4 | - | // are strict: a heading at from is not considered a match. A count less than or | |||
| 5 | - | // equal to zero is treated as one. When levels are provided, only headings with | |||
| 6 | - | // one of those levels match. | |||
| 7 | - | func (d Document) NextHeading(from Cursor, count int, levels ...int) (Cursor, bool) { | |||
| 8 | - | return d.headingMotion(from, count, 1, levels) | |||
| 9 | - | } | |||
| 10 | - | ||||
| 11 | - | // PreviousHeading returns the cursor for the count-th heading before from. | |||
| 12 | - | // Motions are strict: a heading at from is not considered a match. A count less | |||
| 13 | - | // than or equal to zero is treated as one. When levels are provided, only | |||
| 14 | - | // headings with one of those levels match. | |||
| 15 | - | func (d Document) PreviousHeading(from Cursor, count int, levels ...int) (Cursor, bool) { | |||
| 16 | - | return d.headingMotion(from, count, -1, levels) | |||
| 17 | - | } | |||
| 18 | - | ||||
| 19 | - | // NextBlockOfKind returns the cursor for the count-th block of kind after from. | |||
| 20 | - | // Motions are strict: a block at from is not considered a match. A count less | |||
| 21 | - | // than or equal to zero is treated as one. Empty kinds never match. | |||
| 22 | - | func (d Document) NextBlockOfKind(from Cursor, kind BlockKind, count int) (Cursor, bool) { | |||
| 23 | - | return d.blockKindMotion(from, kind, count, 1) | |||
| 24 | - | } | |||
| 25 | - | ||||
| 26 | - | // PreviousBlockOfKind returns the cursor for the count-th block of kind before | |||
| 27 | - | // from. Motions are strict: a block at from is not considered a match. A count | |||
| 28 | - | // less than or equal to zero is treated as one. Empty kinds never match. | |||
| 29 | - | func (d Document) PreviousBlockOfKind(from Cursor, kind BlockKind, count int) (Cursor, bool) { | |||
| 30 | - | return d.blockKindMotion(from, kind, count, -1) | |||
| 31 | - | } | |||
| 32 | - | ||||
| 33 | - | func (d Document) headingMotion(from Cursor, count, direction int, levels []int) (Cursor, bool) { | |||
| 34 | - | return d.matchingBlockMotion(from, count, direction, func(block Block) bool { | |||
| 35 | - | return block.IsHeading() && headingLevelMatches(block.HeadingLevel, levels) | |||
| 36 | - | }) | |||
| 37 | - | } | |||
| 38 | - | ||||
| 39 | - | func (d Document) blockKindMotion(from Cursor, kind BlockKind, count, direction int) (Cursor, bool) { | |||
| 40 | - | if kind == "" { | |||
| 41 | - | return Cursor{}, false | |||
| 42 | - | } | |||
| 43 | - | ||||
| 44 | - | return d.matchingBlockMotion(from, count, direction, func(block Block) bool { | |||
| 45 | - | return block.Kind == kind | |||
| 46 | - | }) | |||
| 47 | - | } | |||
| 48 | - | ||||
| 49 | - | func (d Document) matchingBlockMotion(from Cursor, count, direction int, matches func(Block) bool) (Cursor, bool) { | |||
| 50 | - | start, ok := d.indexByID[from.BlockID] | |||
| 51 | - | if !ok { | |||
| 52 | - | return Cursor{}, false | |||
| 53 | - | } | |||
| 54 | - | ||||
| 55 | - | if count <= 0 { | |||
| 56 | - | count = 1 | |||
| 57 | - | } | |||
| 58 | - | ||||
| 59 | - | remaining := count | |||
| 60 | - | for i := start + direction; i >= 0 && i < len(d.blocks); i += direction { | |||
| 61 | - | block := d.blocks[i] | |||
| 62 | - | if !matches(block) { | |||
| 63 | - | continue | |||
| 64 | - | } | |||
| 65 | - | ||||
| 66 | - | remaining-- | |||
| 67 | - | if remaining == 0 { | |||
| 68 | - | return Cursor{BlockID: block.ID}, true | |||
| 69 | - | } | |||
| 70 | - | } | |||
| 71 | - | ||||
| 72 | - | return Cursor{}, false | |||
| 73 | - | } | |||
| 74 | - | ||||
| 75 | - | func headingLevelMatches(level int, levels []int) bool { | |||
| 76 | - | if len(levels) == 0 { | |||
| 77 | - | return true | |||
| 78 | - | } | |||
| 79 | - | ||||
| 80 | - | for _, allowed := range levels { | |||
| 81 | - | if level == allowed { | |||
| 82 | - | return true | |||
| 83 | - | } | |||
| 84 | - | } | |||
| 85 | - | return false | |||
| 86 | - | } | |||
D internal/domain/docmodel/paragraph_motions_test.go +0 -175
| @@ -1,175 +0,0 @@ | |||
| 1 | - | package docmodel | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "reflect" | |
| 5 | - | "testing" | |
| 6 | - | ) | |
| 7 | - | ||
| 8 | - | func TestParagraphNextBlockOfKindProvidesVimCloseBraceSemantics(t *testing.T) { | |
| 9 | - | doc := paragraphMotionDocument(t) | |
| 10 | - | ||
| 11 | - | tests := []struct { | |
| 12 | - | name string | |
| 13 | - | from Cursor | |
| 14 | - | count int | |
| 15 | - | want Cursor | |
| 16 | - | ok bool | |
| 17 | - | }{ | |
| 18 | - | { | |
| 19 | - | name: "forward paragraph motion skips non paragraphs", | |
| 20 | - | from: Cursor{BlockID: "h1"}, | |
| 21 | - | want: Cursor{BlockID: "p1"}, | |
| 22 | - | ok: true, | |
| 23 | - | }, | |
| 24 | - | { | |
| 25 | - | name: "count below one means one", | |
| 26 | - | from: Cursor{BlockID: "h1"}, | |
| 27 | - | count: -1, | |
| 28 | - | want: Cursor{BlockID: "p1"}, | |
| 29 | - | ok: true, | |
| 30 | - | }, | |
| 31 | - | { | |
| 32 | - | name: "count zero means one", | |
| 33 | - | from: Cursor{BlockID: "code1"}, | |
| 34 | - | count: 0, | |
| 35 | - | want: Cursor{BlockID: "p2"}, | |
| 36 | - | ok: true, | |
| 37 | - | }, | |
| 38 | - | { | |
| 39 | - | name: "strict forward paragraph motion does not match current paragraph", | |
| 40 | - | from: Cursor{BlockID: "p1"}, | |
| 41 | - | count: 1, | |
| 42 | - | want: Cursor{BlockID: "p2"}, | |
| 43 | - | ok: true, | |
| 44 | - | }, | |
| 45 | - | { | |
| 46 | - | name: "count selects nth next paragraph", | |
| 47 | - | from: Cursor{BlockID: "p0"}, | |
| 48 | - | count: 2, | |
| 49 | - | want: Cursor{BlockID: "p2"}, | |
| 50 | - | ok: true, | |
| 51 | - | }, | |
| 52 | - | { | |
| 53 | - | name: "no later paragraph returns false", | |
| 54 | - | from: Cursor{BlockID: "p3"}, | |
| 55 | - | }, | |
| 56 | - | { | |
| 57 | - | name: "count past later paragraphs returns false", | |
| 58 | - | from: Cursor{BlockID: "p1"}, | |
| 59 | - | count: 3, | |
| 60 | - | }, | |
| 61 | - | { | |
| 62 | - | name: "zero cursor returns false", | |
| 63 | - | from: Cursor{}, | |
| 64 | - | }, | |
| 65 | - | { | |
| 66 | - | name: "missing cursor returns false", | |
| 67 | - | from: Cursor{BlockID: "missing"}, | |
| 68 | - | }, | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | for _, tt := range tests { | |
| 72 | - | t.Run(tt.name, func(t *testing.T) { | |
| 73 | - | before := doc.Blocks() | |
| 74 | - | got, ok := doc.NextBlockOfKind(tt.from, BlockKindParagraph, tt.count) | |
| 75 | - | if ok != tt.ok || got != tt.want { | |
| 76 | - | t.Fatalf("NextBlockOfKind(%#v, BlockKindParagraph, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, got, ok, tt.want, tt.ok) | |
| 77 | - | } | |
| 78 | - | if after := doc.Blocks(); !reflect.DeepEqual(after, before) { | |
| 79 | - | t.Fatalf("NextBlockOfKind mutated document: before %#v after %#v", before, after) | |
| 80 | - | } | |
| 81 | - | }) | |
| 82 | - | } | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | func TestParagraphPreviousBlockOfKindProvidesVimOpenBraceSemantics(t *testing.T) { | |
| 86 | - | doc := paragraphMotionDocument(t) | |
| 87 | - | ||
| 88 | - | tests := []struct { | |
| 89 | - | name string | |
| 90 | - | from Cursor | |
| 91 | - | count int | |
| 92 | - | want Cursor | |
| 93 | - | ok bool | |
| 94 | - | }{ | |
| 95 | - | { | |
| 96 | - | name: "backward paragraph motion skips non paragraphs", | |
| 97 | - | from: Cursor{BlockID: "table1"}, | |
| 98 | - | want: Cursor{BlockID: "p3"}, | |
| 99 | - | ok: true, | |
| 100 | - | }, | |
| 101 | - | { | |
| 102 | - | name: "count below one means one", | |
| 103 | - | from: Cursor{BlockID: "table1"}, | |
| 104 | - | count: -1, | |
| 105 | - | want: Cursor{BlockID: "p3"}, | |
| 106 | - | ok: true, | |
| 107 | - | }, | |
| 108 | - | { | |
| 109 | - | name: "count zero means one", | |
| 110 | - | from: Cursor{BlockID: "code1"}, | |
| 111 | - | count: 0, | |
| 112 | - | want: Cursor{BlockID: "p1"}, | |
| 113 | - | ok: true, | |
| 114 | - | }, | |
| 115 | - | { | |
| 116 | - | name: "strict backward paragraph motion does not match current paragraph", | |
| 117 | - | from: Cursor{BlockID: "p3"}, | |
| 118 | - | count: 1, | |
| 119 | - | want: Cursor{BlockID: "p2"}, | |
| 120 | - | ok: true, | |
| 121 | - | }, | |
| 122 | - | { | |
| 123 | - | name: "count selects nth previous paragraph", | |
| 124 | - | from: Cursor{BlockID: "p3"}, | |
| 125 | - | count: 2, | |
| 126 | - | want: Cursor{BlockID: "p1"}, | |
| 127 | - | ok: true, | |
| 128 | - | }, | |
| 129 | - | { | |
| 130 | - | name: "no earlier paragraph returns false", | |
| 131 | - | from: Cursor{BlockID: "p0"}, | |
| 132 | - | }, | |
| 133 | - | { | |
| 134 | - | name: "count past earlier paragraphs returns false", | |
| 135 | - | from: Cursor{BlockID: "p2"}, | |
| 136 | - | count: 3, | |
| 137 | - | }, | |
| 138 | - | { | |
| 139 | - | name: "zero cursor returns false", | |
| 140 | - | from: Cursor{}, | |
| 141 | - | }, | |
| 142 | - | { | |
| 143 | - | name: "missing cursor returns false", | |
| 144 | - | from: Cursor{BlockID: "missing"}, | |
| 145 | - | }, | |
| 146 | - | } | |
| 147 | - | ||
| 148 | - | for _, tt := range tests { | |
| 149 | - | t.Run(tt.name, func(t *testing.T) { | |
| 150 | - | before := doc.Blocks() | |
| 151 | - | got, ok := doc.PreviousBlockOfKind(tt.from, BlockKindParagraph, tt.count) | |
| 152 | - | if ok != tt.ok || got != tt.want { | |
| 153 | - | t.Fatalf("PreviousBlockOfKind(%#v, BlockKindParagraph, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, got, ok, tt.want, tt.ok) | |
| 154 | - | } | |
| 155 | - | if after := doc.Blocks(); !reflect.DeepEqual(after, before) { | |
| 156 | - | t.Fatalf("PreviousBlockOfKind mutated document: before %#v after %#v", before, after) | |
| 157 | - | } | |
| 158 | - | }) | |
| 159 | - | } | |
| 160 | - | } | |
| 161 | - | ||
| 162 | - | func paragraphMotionDocument(t *testing.T) Document { | |
| 163 | - | t.Helper() | |
| 164 | - | ||
| 165 | - | return mustDocument(t, []Block{ | |
| 166 | - | {ID: "p0", Kind: BlockKindParagraph}, | |
| 167 | - | {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1}, | |
| 168 | - | {ID: "p1", Kind: BlockKindParagraph}, | |
| 169 | - | {ID: "code1", Kind: BlockKindCode}, | |
| 170 | - | {ID: "p2", Kind: BlockKindParagraph}, | |
| 171 | - | {ID: "image1", Kind: BlockKindImage}, | |
| 172 | - | {ID: "p3", Kind: BlockKindParagraph}, | |
| 173 | - | {ID: "table1", Kind: BlockKindTable}, | |
| 174 | - | }) | |
| 175 | - | } | |
| @@ -1,175 +0,0 @@ | |||||
| 1 | - | package docmodel | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "reflect" | |||
| 5 | - | "testing" | |||
| 6 | - | ) | |||
| 7 | - | ||||
| 8 | - | func TestParagraphNextBlockOfKindProvidesVimCloseBraceSemantics(t *testing.T) { | |||
| 9 | - | doc := paragraphMotionDocument(t) | |||
| 10 | - | ||||
| 11 | - | tests := []struct { | |||
| 12 | - | name string | |||
| 13 | - | from Cursor | |||
| 14 | - | count int | |||
| 15 | - | want Cursor | |||
| 16 | - | ok bool | |||
| 17 | - | }{ | |||
| 18 | - | { | |||
| 19 | - | name: "forward paragraph motion skips non paragraphs", | |||
| 20 | - | from: Cursor{BlockID: "h1"}, | |||
| 21 | - | want: Cursor{BlockID: "p1"}, | |||
| 22 | - | ok: true, | |||
| 23 | - | }, | |||
| 24 | - | { | |||
| 25 | - | name: "count below one means one", | |||
| 26 | - | from: Cursor{BlockID: "h1"}, | |||
| 27 | - | count: -1, | |||
| 28 | - | want: Cursor{BlockID: "p1"}, | |||
| 29 | - | ok: true, | |||
| 30 | - | }, | |||
| 31 | - | { | |||
| 32 | - | name: "count zero means one", | |||
| 33 | - | from: Cursor{BlockID: "code1"}, | |||
| 34 | - | count: 0, | |||
| 35 | - | want: Cursor{BlockID: "p2"}, | |||
| 36 | - | ok: true, | |||
| 37 | - | }, | |||
| 38 | - | { | |||
| 39 | - | name: "strict forward paragraph motion does not match current paragraph", | |||
| 40 | - | from: Cursor{BlockID: "p1"}, | |||
| 41 | - | count: 1, | |||
| 42 | - | want: Cursor{BlockID: "p2"}, | |||
| 43 | - | ok: true, | |||
| 44 | - | }, | |||
| 45 | - | { | |||
| 46 | - | name: "count selects nth next paragraph", | |||
| 47 | - | from: Cursor{BlockID: "p0"}, | |||
| 48 | - | count: 2, | |||
| 49 | - | want: Cursor{BlockID: "p2"}, | |||
| 50 | - | ok: true, | |||
| 51 | - | }, | |||
| 52 | - | { | |||
| 53 | - | name: "no later paragraph returns false", | |||
| 54 | - | from: Cursor{BlockID: "p3"}, | |||
| 55 | - | }, | |||
| 56 | - | { | |||
| 57 | - | name: "count past later paragraphs returns false", | |||
| 58 | - | from: Cursor{BlockID: "p1"}, | |||
| 59 | - | count: 3, | |||
| 60 | - | }, | |||
| 61 | - | { | |||
| 62 | - | name: "zero cursor returns false", | |||
| 63 | - | from: Cursor{}, | |||
| 64 | - | }, | |||
| 65 | - | { | |||
| 66 | - | name: "missing cursor returns false", | |||
| 67 | - | from: Cursor{BlockID: "missing"}, | |||
| 68 | - | }, | |||
| 69 | - | } | |||
| 70 | - | ||||
| 71 | - | for _, tt := range tests { | |||
| 72 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 73 | - | before := doc.Blocks() | |||
| 74 | - | got, ok := doc.NextBlockOfKind(tt.from, BlockKindParagraph, tt.count) | |||
| 75 | - | if ok != tt.ok || got != tt.want { | |||
| 76 | - | t.Fatalf("NextBlockOfKind(%#v, BlockKindParagraph, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, got, ok, tt.want, tt.ok) | |||
| 77 | - | } | |||
| 78 | - | if after := doc.Blocks(); !reflect.DeepEqual(after, before) { | |||
| 79 | - | t.Fatalf("NextBlockOfKind mutated document: before %#v after %#v", before, after) | |||
| 80 | - | } | |||
| 81 | - | }) | |||
| 82 | - | } | |||
| 83 | - | } | |||
| 84 | - | ||||
| 85 | - | func TestParagraphPreviousBlockOfKindProvidesVimOpenBraceSemantics(t *testing.T) { | |||
| 86 | - | doc := paragraphMotionDocument(t) | |||
| 87 | - | ||||
| 88 | - | tests := []struct { | |||
| 89 | - | name string | |||
| 90 | - | from Cursor | |||
| 91 | - | count int | |||
| 92 | - | want Cursor | |||
| 93 | - | ok bool | |||
| 94 | - | }{ | |||
| 95 | - | { | |||
| 96 | - | name: "backward paragraph motion skips non paragraphs", | |||
| 97 | - | from: Cursor{BlockID: "table1"}, | |||
| 98 | - | want: Cursor{BlockID: "p3"}, | |||
| 99 | - | ok: true, | |||
| 100 | - | }, | |||
| 101 | - | { | |||
| 102 | - | name: "count below one means one", | |||
| 103 | - | from: Cursor{BlockID: "table1"}, | |||
| 104 | - | count: -1, | |||
| 105 | - | want: Cursor{BlockID: "p3"}, | |||
| 106 | - | ok: true, | |||
| 107 | - | }, | |||
| 108 | - | { | |||
| 109 | - | name: "count zero means one", | |||
| 110 | - | from: Cursor{BlockID: "code1"}, | |||
| 111 | - | count: 0, | |||
| 112 | - | want: Cursor{BlockID: "p1"}, | |||
| 113 | - | ok: true, | |||
| 114 | - | }, | |||
| 115 | - | { | |||
| 116 | - | name: "strict backward paragraph motion does not match current paragraph", | |||
| 117 | - | from: Cursor{BlockID: "p3"}, | |||
| 118 | - | count: 1, | |||
| 119 | - | want: Cursor{BlockID: "p2"}, | |||
| 120 | - | ok: true, | |||
| 121 | - | }, | |||
| 122 | - | { | |||
| 123 | - | name: "count selects nth previous paragraph", | |||
| 124 | - | from: Cursor{BlockID: "p3"}, | |||
| 125 | - | count: 2, | |||
| 126 | - | want: Cursor{BlockID: "p1"}, | |||
| 127 | - | ok: true, | |||
| 128 | - | }, | |||
| 129 | - | { | |||
| 130 | - | name: "no earlier paragraph returns false", | |||
| 131 | - | from: Cursor{BlockID: "p0"}, | |||
| 132 | - | }, | |||
| 133 | - | { | |||
| 134 | - | name: "count past earlier paragraphs returns false", | |||
| 135 | - | from: Cursor{BlockID: "p2"}, | |||
| 136 | - | count: 3, | |||
| 137 | - | }, | |||
| 138 | - | { | |||
| 139 | - | name: "zero cursor returns false", | |||
| 140 | - | from: Cursor{}, | |||
| 141 | - | }, | |||
| 142 | - | { | |||
| 143 | - | name: "missing cursor returns false", | |||
| 144 | - | from: Cursor{BlockID: "missing"}, | |||
| 145 | - | }, | |||
| 146 | - | } | |||
| 147 | - | ||||
| 148 | - | for _, tt := range tests { | |||
| 149 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 150 | - | before := doc.Blocks() | |||
| 151 | - | got, ok := doc.PreviousBlockOfKind(tt.from, BlockKindParagraph, tt.count) | |||
| 152 | - | if ok != tt.ok || got != tt.want { | |||
| 153 | - | t.Fatalf("PreviousBlockOfKind(%#v, BlockKindParagraph, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, got, ok, tt.want, tt.ok) | |||
| 154 | - | } | |||
| 155 | - | if after := doc.Blocks(); !reflect.DeepEqual(after, before) { | |||
| 156 | - | t.Fatalf("PreviousBlockOfKind mutated document: before %#v after %#v", before, after) | |||
| 157 | - | } | |||
| 158 | - | }) | |||
| 159 | - | } | |||
| 160 | - | } | |||
| 161 | - | ||||
| 162 | - | func paragraphMotionDocument(t *testing.T) Document { | |||
| 163 | - | t.Helper() | |||
| 164 | - | ||||
| 165 | - | return mustDocument(t, []Block{ | |||
| 166 | - | {ID: "p0", Kind: BlockKindParagraph}, | |||
| 167 | - | {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1}, | |||
| 168 | - | {ID: "p1", Kind: BlockKindParagraph}, | |||
| 169 | - | {ID: "code1", Kind: BlockKindCode}, | |||
| 170 | - | {ID: "p2", Kind: BlockKindParagraph}, | |||
| 171 | - | {ID: "image1", Kind: BlockKindImage}, | |||
| 172 | - | {ID: "p3", Kind: BlockKindParagraph}, | |||
| 173 | - | {ID: "table1", Kind: BlockKindTable}, | |||
| 174 | - | }) | |||
| 175 | - | } | |||
D internal/domain/docmodel/section.go +0 -41
| @@ -1,41 +0,0 @@ | |||
| 1 | - | package docmodel | |
| 2 | - | ||
| 3 | - | // SectionRange returns the half-open range for the section containing from. | |
| 4 | - | // The range starts at the containing heading and ends before the first | |
| 5 | - | // subsequent heading whose level is less than or equal to the start heading's | |
| 6 | - | // level. A zero EndExclusive cursor represents the end of the document. | |
| 7 | - | func (d Document) SectionRange(from Cursor) (BlockRange, bool) { | |
| 8 | - | fromIndex, ok := d.indexByID[from.BlockID] | |
| 9 | - | if !ok { | |
| 10 | - | return BlockRange{}, false | |
| 11 | - | } | |
| 12 | - | ||
| 13 | - | startIndex, ok := d.containingHeadingIndex(fromIndex) | |
| 14 | - | if !ok { | |
| 15 | - | return BlockRange{}, false | |
| 16 | - | } | |
| 17 | - | ||
| 18 | - | start := d.blocks[startIndex] | |
| 19 | - | section := BlockRange{ | |
| 20 | - | StartInclusive: Cursor{BlockID: start.ID}, | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | for i := startIndex + 1; i < len(d.blocks); i++ { | |
| 24 | - | block := d.blocks[i] | |
| 25 | - | if block.IsHeading() && block.HeadingLevel <= start.HeadingLevel { | |
| 26 | - | section.EndExclusive = Cursor{BlockID: block.ID} | |
| 27 | - | return section, true | |
| 28 | - | } | |
| 29 | - | } | |
| 30 | - | ||
| 31 | - | return section, true | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | func (d Document) containingHeadingIndex(fromIndex int) (int, bool) { | |
| 35 | - | for i := fromIndex; i >= 0; i-- { | |
| 36 | - | if d.blocks[i].IsHeading() { | |
| 37 | - | return i, true | |
| 38 | - | } | |
| 39 | - | } | |
| 40 | - | return 0, false | |
| 41 | - | } | |
| @@ -1,41 +0,0 @@ | |||||
| 1 | - | package docmodel | |||
| 2 | - | ||||
| 3 | - | // SectionRange returns the half-open range for the section containing from. | |||
| 4 | - | // The range starts at the containing heading and ends before the first | |||
| 5 | - | // subsequent heading whose level is less than or equal to the start heading's | |||
| 6 | - | // level. A zero EndExclusive cursor represents the end of the document. | |||
| 7 | - | func (d Document) SectionRange(from Cursor) (BlockRange, bool) { | |||
| 8 | - | fromIndex, ok := d.indexByID[from.BlockID] | |||
| 9 | - | if !ok { | |||
| 10 | - | return BlockRange{}, false | |||
| 11 | - | } | |||
| 12 | - | ||||
| 13 | - | startIndex, ok := d.containingHeadingIndex(fromIndex) | |||
| 14 | - | if !ok { | |||
| 15 | - | return BlockRange{}, false | |||
| 16 | - | } | |||
| 17 | - | ||||
| 18 | - | start := d.blocks[startIndex] | |||
| 19 | - | section := BlockRange{ | |||
| 20 | - | StartInclusive: Cursor{BlockID: start.ID}, | |||
| 21 | - | } | |||
| 22 | - | ||||
| 23 | - | for i := startIndex + 1; i < len(d.blocks); i++ { | |||
| 24 | - | block := d.blocks[i] | |||
| 25 | - | if block.IsHeading() && block.HeadingLevel <= start.HeadingLevel { | |||
| 26 | - | section.EndExclusive = Cursor{BlockID: block.ID} | |||
| 27 | - | return section, true | |||
| 28 | - | } | |||
| 29 | - | } | |||
| 30 | - | ||||
| 31 | - | return section, true | |||
| 32 | - | } | |||
| 33 | - | ||||
| 34 | - | func (d Document) containingHeadingIndex(fromIndex int) (int, bool) { | |||
| 35 | - | for i := fromIndex; i >= 0; i-- { | |||
| 36 | - | if d.blocks[i].IsHeading() { | |||
| 37 | - | return i, true | |||
| 38 | - | } | |||
| 39 | - | } | |||
| 40 | - | return 0, false | |||
| 41 | - | } | |||
D internal/domain/docmodel/section_test.go +0 -197
| @@ -1,197 +0,0 @@ | |||
| 1 | - | package docmodel | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "reflect" | |
| 5 | - | "testing" | |
| 6 | - | ) | |
| 7 | - | ||
| 8 | - | func TestSectionRange(t *testing.T) { | |
| 9 | - | doc := sectionRangeDocument(t) | |
| 10 | - | ||
| 11 | - | tests := []struct { | |
| 12 | - | name string | |
| 13 | - | from Cursor | |
| 14 | - | want BlockRange | |
| 15 | - | ok bool | |
| 16 | - | }{ | |
| 17 | - | { | |
| 18 | - | name: "top level section includes descendant headings and stops before next top level heading", | |
| 19 | - | from: Cursor{BlockID: "h1"}, | |
| 20 | - | want: BlockRange{ | |
| 21 | - | StartInclusive: Cursor{BlockID: "h1"}, | |
| 22 | - | EndExclusive: Cursor{BlockID: "h1b"}, | |
| 23 | - | }, | |
| 24 | - | ok: true, | |
| 25 | - | }, | |
| 26 | - | { | |
| 27 | - | name: "non-heading anchor resolves to nearest preceding heading", | |
| 28 | - | from: Cursor{BlockID: "p-h2"}, | |
| 29 | - | want: BlockRange{ | |
| 30 | - | StartInclusive: Cursor{BlockID: "h2"}, | |
| 31 | - | EndExclusive: Cursor{BlockID: "h2b"}, | |
| 32 | - | }, | |
| 33 | - | ok: true, | |
| 34 | - | }, | |
| 35 | - | { | |
| 36 | - | name: "non-heading anchor resolves to innermost preceding heading", | |
| 37 | - | from: Cursor{BlockID: "p-h3"}, | |
| 38 | - | want: BlockRange{ | |
| 39 | - | StartInclusive: Cursor{BlockID: "h3"}, | |
| 40 | - | EndExclusive: Cursor{BlockID: "h3b"}, | |
| 41 | - | }, | |
| 42 | - | ok: true, | |
| 43 | - | }, | |
| 44 | - | { | |
| 45 | - | name: "nested section includes deeper descendant heading", | |
| 46 | - | from: Cursor{BlockID: "h2"}, | |
| 47 | - | want: BlockRange{ | |
| 48 | - | StartInclusive: Cursor{BlockID: "h2"}, | |
| 49 | - | EndExclusive: Cursor{BlockID: "h2b"}, | |
| 50 | - | }, | |
| 51 | - | ok: true, | |
| 52 | - | }, | |
| 53 | - | { | |
| 54 | - | name: "descendant heading section ends before next heading at same level", | |
| 55 | - | from: Cursor{BlockID: "h3"}, | |
| 56 | - | want: BlockRange{ | |
| 57 | - | StartInclusive: Cursor{BlockID: "h3"}, | |
| 58 | - | EndExclusive: Cursor{BlockID: "h3b"}, | |
| 59 | - | }, | |
| 60 | - | ok: true, | |
| 61 | - | }, | |
| 62 | - | { | |
| 63 | - | name: "skipped heading levels are valid descendants", | |
| 64 | - | from: Cursor{BlockID: "h2-skip"}, | |
| 65 | - | want: BlockRange{ | |
| 66 | - | StartInclusive: Cursor{BlockID: "h2-skip"}, | |
| 67 | - | EndExclusive: Cursor{BlockID: "h2-sibling"}, | |
| 68 | - | }, | |
| 69 | - | ok: true, | |
| 70 | - | }, | |
| 71 | - | { | |
| 72 | - | name: "adjacent same-level heading yields empty body range ending at adjacent heading", | |
| 73 | - | from: Cursor{BlockID: "h2-adjacent"}, | |
| 74 | - | want: BlockRange{ | |
| 75 | - | StartInclusive: Cursor{BlockID: "h2-adjacent"}, | |
| 76 | - | EndExclusive: Cursor{BlockID: "h2-after-adjacent"}, | |
| 77 | - | }, | |
| 78 | - | ok: true, | |
| 79 | - | }, | |
| 80 | - | { | |
| 81 | - | name: "lower-level heading ends nested section", | |
| 82 | - | from: Cursor{BlockID: "h2-after-adjacent"}, | |
| 83 | - | want: BlockRange{ | |
| 84 | - | StartInclusive: Cursor{BlockID: "h2-after-adjacent"}, | |
| 85 | - | EndExclusive: Cursor{BlockID: "h1-final"}, | |
| 86 | - | }, | |
| 87 | - | ok: true, | |
| 88 | - | }, | |
| 89 | - | { | |
| 90 | - | name: "EOF section uses zero end cursor", | |
| 91 | - | from: Cursor{BlockID: "h1-final"}, | |
| 92 | - | want: BlockRange{ | |
| 93 | - | StartInclusive: Cursor{BlockID: "h1-final"}, | |
| 94 | - | }, | |
| 95 | - | ok: true, | |
| 96 | - | }, | |
| 97 | - | { | |
| 98 | - | name: "non-heading anchor in EOF section uses zero end cursor", | |
| 99 | - | from: Cursor{BlockID: "p-final"}, | |
| 100 | - | want: BlockRange{ | |
| 101 | - | StartInclusive: Cursor{BlockID: "h1-final"}, | |
| 102 | - | }, | |
| 103 | - | ok: true, | |
| 104 | - | }, | |
| 105 | - | { | |
| 106 | - | name: "before first heading returns false", | |
| 107 | - | from: Cursor{BlockID: "intro"}, | |
| 108 | - | }, | |
| 109 | - | { | |
| 110 | - | name: "zero cursor returns false", | |
| 111 | - | from: Cursor{}, | |
| 112 | - | }, | |
| 113 | - | { | |
| 114 | - | name: "missing cursor returns false", | |
| 115 | - | from: Cursor{BlockID: "missing"}, | |
| 116 | - | }, | |
| 117 | - | } | |
| 118 | - | ||
| 119 | - | for _, tt := range tests { | |
| 120 | - | t.Run(tt.name, func(t *testing.T) { | |
| 121 | - | before := doc.Blocks() | |
| 122 | - | got, ok := doc.SectionRange(tt.from) | |
| 123 | - | if ok != tt.ok || got != tt.want { | |
| 124 | - | t.Fatalf("SectionRange(%#v) = (%#v, %v), want (%#v, %v)", tt.from, got, ok, tt.want, tt.ok) | |
| 125 | - | } | |
| 126 | - | if after := doc.Blocks(); !reflect.DeepEqual(after, before) { | |
| 127 | - | t.Fatalf("SectionRange mutated document: before %#v after %#v", before, after) | |
| 128 | - | } | |
| 129 | - | }) | |
| 130 | - | } | |
| 131 | - | } | |
| 132 | - | ||
| 133 | - | func TestSectionRangeNoSectionDocuments(t *testing.T) { | |
| 134 | - | tests := []struct { | |
| 135 | - | name string | |
| 136 | - | blocks []Block | |
| 137 | - | from Cursor | |
| 138 | - | }{ | |
| 139 | - | { | |
| 140 | - | name: "empty document", | |
| 141 | - | from: Cursor{BlockID: "missing"}, | |
| 142 | - | }, | |
| 143 | - | { | |
| 144 | - | name: "headingless document", | |
| 145 | - | blocks: []Block{ | |
| 146 | - | {ID: "p0", Kind: BlockKindParagraph}, | |
| 147 | - | {ID: "code", Kind: BlockKindCode}, | |
| 148 | - | {ID: "p1", Kind: BlockKindParagraph}, | |
| 149 | - | }, | |
| 150 | - | from: Cursor{BlockID: "code"}, | |
| 151 | - | }, | |
| 152 | - | } | |
| 153 | - | ||
| 154 | - | for _, tt := range tests { | |
| 155 | - | t.Run(tt.name, func(t *testing.T) { | |
| 156 | - | doc := mustDocument(t, tt.blocks) | |
| 157 | - | before := doc.Blocks() | |
| 158 | - | ||
| 159 | - | got, ok := doc.SectionRange(tt.from) | |
| 160 | - | if ok || got != (BlockRange{}) { | |
| 161 | - | t.Fatalf("SectionRange(%#v) = (%#v, %v), want zero range and false", tt.from, got, ok) | |
| 162 | - | } | |
| 163 | - | if after := doc.Blocks(); !reflect.DeepEqual(after, before) { | |
| 164 | - | t.Fatalf("SectionRange mutated document: before %#v after %#v", before, after) | |
| 165 | - | } | |
| 166 | - | }) | |
| 167 | - | } | |
| 168 | - | } | |
| 169 | - | ||
| 170 | - | func sectionRangeDocument(t *testing.T) Document { | |
| 171 | - | t.Helper() | |
| 172 | - | ||
| 173 | - | return mustDocument(t, []Block{ | |
| 174 | - | {ID: "intro", Kind: BlockKindParagraph}, | |
| 175 | - | {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1}, | |
| 176 | - | {ID: "p-h1", Kind: BlockKindParagraph}, | |
| 177 | - | {ID: "h2", Kind: BlockKindHeading, HeadingLevel: 2}, | |
| 178 | - | {ID: "p-h2", Kind: BlockKindParagraph}, | |
| 179 | - | {ID: "h3", Kind: BlockKindHeading, HeadingLevel: 3}, | |
| 180 | - | {ID: "p-h3", Kind: BlockKindParagraph}, | |
| 181 | - | {ID: "h3b", Kind: BlockKindHeading, HeadingLevel: 3}, | |
| 182 | - | {ID: "p-h3b", Kind: BlockKindParagraph}, | |
| 183 | - | {ID: "h2b", Kind: BlockKindHeading, HeadingLevel: 2}, | |
| 184 | - | {ID: "p-h2b", Kind: BlockKindParagraph}, | |
| 185 | - | {ID: "h1b", Kind: BlockKindHeading, HeadingLevel: 1}, | |
| 186 | - | {ID: "h2-skip", Kind: BlockKindHeading, HeadingLevel: 2}, | |
| 187 | - | {ID: "h5-skip", Kind: BlockKindHeading, HeadingLevel: 5}, | |
| 188 | - | {ID: "p-skip", Kind: BlockKindParagraph}, | |
| 189 | - | {ID: "h2-sibling", Kind: BlockKindHeading, HeadingLevel: 2}, | |
| 190 | - | {ID: "h2-adjacent", Kind: BlockKindHeading, HeadingLevel: 2}, | |
| 191 | - | {ID: "h2-after-adjacent", Kind: BlockKindHeading, HeadingLevel: 2}, | |
| 192 | - | {ID: "p-after-adjacent", Kind: BlockKindParagraph}, | |
| 193 | - | {ID: "h1-final", Kind: BlockKindHeading, HeadingLevel: 1}, | |
| 194 | - | {ID: "p-final", Kind: BlockKindParagraph}, | |
| 195 | - | {ID: "h2-final-child", Kind: BlockKindHeading, HeadingLevel: 2}, | |
| 196 | - | }) | |
| 197 | - | } | |
| @@ -1,197 +0,0 @@ | |||||
| 1 | - | package docmodel | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "reflect" | |||
| 5 | - | "testing" | |||
| 6 | - | ) | |||
| 7 | - | ||||
| 8 | - | func TestSectionRange(t *testing.T) { | |||
| 9 | - | doc := sectionRangeDocument(t) | |||
| 10 | - | ||||
| 11 | - | tests := []struct { | |||
| 12 | - | name string | |||
| 13 | - | from Cursor | |||
| 14 | - | want BlockRange | |||
| 15 | - | ok bool | |||
| 16 | - | }{ | |||
| 17 | - | { | |||
| 18 | - | name: "top level section includes descendant headings and stops before next top level heading", | |||
| 19 | - | from: Cursor{BlockID: "h1"}, | |||
| 20 | - | want: BlockRange{ | |||
| 21 | - | StartInclusive: Cursor{BlockID: "h1"}, | |||
| 22 | - | EndExclusive: Cursor{BlockID: "h1b"}, | |||
| 23 | - | }, | |||
| 24 | - | ok: true, | |||
| 25 | - | }, | |||
| 26 | - | { | |||
| 27 | - | name: "non-heading anchor resolves to nearest preceding heading", | |||
| 28 | - | from: Cursor{BlockID: "p-h2"}, | |||
| 29 | - | want: BlockRange{ | |||
| 30 | - | StartInclusive: Cursor{BlockID: "h2"}, | |||
| 31 | - | EndExclusive: Cursor{BlockID: "h2b"}, | |||
| 32 | - | }, | |||
| 33 | - | ok: true, | |||
| 34 | - | }, | |||
| 35 | - | { | |||
| 36 | - | name: "non-heading anchor resolves to innermost preceding heading", | |||
| 37 | - | from: Cursor{BlockID: "p-h3"}, | |||
| 38 | - | want: BlockRange{ | |||
| 39 | - | StartInclusive: Cursor{BlockID: "h3"}, | |||
| 40 | - | EndExclusive: Cursor{BlockID: "h3b"}, | |||
| 41 | - | }, | |||
| 42 | - | ok: true, | |||
| 43 | - | }, | |||
| 44 | - | { | |||
| 45 | - | name: "nested section includes deeper descendant heading", | |||
| 46 | - | from: Cursor{BlockID: "h2"}, | |||
| 47 | - | want: BlockRange{ | |||
| 48 | - | StartInclusive: Cursor{BlockID: "h2"}, | |||
| 49 | - | EndExclusive: Cursor{BlockID: "h2b"}, | |||
| 50 | - | }, | |||
| 51 | - | ok: true, | |||
| 52 | - | }, | |||
| 53 | - | { | |||
| 54 | - | name: "descendant heading section ends before next heading at same level", | |||
| 55 | - | from: Cursor{BlockID: "h3"}, | |||
| 56 | - | want: BlockRange{ | |||
| 57 | - | StartInclusive: Cursor{BlockID: "h3"}, | |||
| 58 | - | EndExclusive: Cursor{BlockID: "h3b"}, | |||
| 59 | - | }, | |||
| 60 | - | ok: true, | |||
| 61 | - | }, | |||
| 62 | - | { | |||
| 63 | - | name: "skipped heading levels are valid descendants", | |||
| 64 | - | from: Cursor{BlockID: "h2-skip"}, | |||
| 65 | - | want: BlockRange{ | |||
| 66 | - | StartInclusive: Cursor{BlockID: "h2-skip"}, | |||
| 67 | - | EndExclusive: Cursor{BlockID: "h2-sibling"}, | |||
| 68 | - | }, | |||
| 69 | - | ok: true, | |||
| 70 | - | }, | |||
| 71 | - | { | |||
| 72 | - | name: "adjacent same-level heading yields empty body range ending at adjacent heading", | |||
| 73 | - | from: Cursor{BlockID: "h2-adjacent"}, | |||
| 74 | - | want: BlockRange{ | |||
| 75 | - | StartInclusive: Cursor{BlockID: "h2-adjacent"}, | |||
| 76 | - | EndExclusive: Cursor{BlockID: "h2-after-adjacent"}, | |||
| 77 | - | }, | |||
| 78 | - | ok: true, | |||
| 79 | - | }, | |||
| 80 | - | { | |||
| 81 | - | name: "lower-level heading ends nested section", | |||
| 82 | - | from: Cursor{BlockID: "h2-after-adjacent"}, | |||
| 83 | - | want: BlockRange{ | |||
| 84 | - | StartInclusive: Cursor{BlockID: "h2-after-adjacent"}, | |||
| 85 | - | EndExclusive: Cursor{BlockID: "h1-final"}, | |||
| 86 | - | }, | |||
| 87 | - | ok: true, | |||
| 88 | - | }, | |||
| 89 | - | { | |||
| 90 | - | name: "EOF section uses zero end cursor", | |||
| 91 | - | from: Cursor{BlockID: "h1-final"}, | |||
| 92 | - | want: BlockRange{ | |||
| 93 | - | StartInclusive: Cursor{BlockID: "h1-final"}, | |||
| 94 | - | }, | |||
| 95 | - | ok: true, | |||
| 96 | - | }, | |||
| 97 | - | { | |||
| 98 | - | name: "non-heading anchor in EOF section uses zero end cursor", | |||
| 99 | - | from: Cursor{BlockID: "p-final"}, | |||
| 100 | - | want: BlockRange{ | |||
| 101 | - | StartInclusive: Cursor{BlockID: "h1-final"}, | |||
| 102 | - | }, | |||
| 103 | - | ok: true, | |||
| 104 | - | }, | |||
| 105 | - | { | |||
| 106 | - | name: "before first heading returns false", | |||
| 107 | - | from: Cursor{BlockID: "intro"}, | |||
| 108 | - | }, | |||
| 109 | - | { | |||
| 110 | - | name: "zero cursor returns false", | |||
| 111 | - | from: Cursor{}, | |||
| 112 | - | }, | |||
| 113 | - | { | |||
| 114 | - | name: "missing cursor returns false", | |||
| 115 | - | from: Cursor{BlockID: "missing"}, | |||
| 116 | - | }, | |||
| 117 | - | } | |||
| 118 | - | ||||
| 119 | - | for _, tt := range tests { | |||
| 120 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 121 | - | before := doc.Blocks() | |||
| 122 | - | got, ok := doc.SectionRange(tt.from) | |||
| 123 | - | if ok != tt.ok || got != tt.want { | |||
| 124 | - | t.Fatalf("SectionRange(%#v) = (%#v, %v), want (%#v, %v)", tt.from, got, ok, tt.want, tt.ok) | |||
| 125 | - | } | |||
| 126 | - | if after := doc.Blocks(); !reflect.DeepEqual(after, before) { | |||
| 127 | - | t.Fatalf("SectionRange mutated document: before %#v after %#v", before, after) | |||
| 128 | - | } | |||
| 129 | - | }) | |||
| 130 | - | } | |||
| 131 | - | } | |||
| 132 | - | ||||
| 133 | - | func TestSectionRangeNoSectionDocuments(t *testing.T) { | |||
| 134 | - | tests := []struct { | |||
| 135 | - | name string | |||
| 136 | - | blocks []Block | |||
| 137 | - | from Cursor | |||
| 138 | - | }{ | |||
| 139 | - | { | |||
| 140 | - | name: "empty document", | |||
| 141 | - | from: Cursor{BlockID: "missing"}, | |||
| 142 | - | }, | |||
| 143 | - | { | |||
| 144 | - | name: "headingless document", | |||
| 145 | - | blocks: []Block{ | |||
| 146 | - | {ID: "p0", Kind: BlockKindParagraph}, | |||
| 147 | - | {ID: "code", Kind: BlockKindCode}, | |||
| 148 | - | {ID: "p1", Kind: BlockKindParagraph}, | |||
| 149 | - | }, | |||
| 150 | - | from: Cursor{BlockID: "code"}, | |||
| 151 | - | }, | |||
| 152 | - | } | |||
| 153 | - | ||||
| 154 | - | for _, tt := range tests { | |||
| 155 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 156 | - | doc := mustDocument(t, tt.blocks) | |||
| 157 | - | before := doc.Blocks() | |||
| 158 | - | ||||
| 159 | - | got, ok := doc.SectionRange(tt.from) | |||
| 160 | - | if ok || got != (BlockRange{}) { | |||
| 161 | - | t.Fatalf("SectionRange(%#v) = (%#v, %v), want zero range and false", tt.from, got, ok) | |||
| 162 | - | } | |||
| 163 | - | if after := doc.Blocks(); !reflect.DeepEqual(after, before) { | |||
| 164 | - | t.Fatalf("SectionRange mutated document: before %#v after %#v", before, after) | |||
| 165 | - | } | |||
| 166 | - | }) | |||
| 167 | - | } | |||
| 168 | - | } | |||
| 169 | - | ||||
| 170 | - | func sectionRangeDocument(t *testing.T) Document { | |||
| 171 | - | t.Helper() | |||
| 172 | - | ||||
| 173 | - | return mustDocument(t, []Block{ | |||
| 174 | - | {ID: "intro", Kind: BlockKindParagraph}, | |||
| 175 | - | {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1}, | |||
| 176 | - | {ID: "p-h1", Kind: BlockKindParagraph}, | |||
| 177 | - | {ID: "h2", Kind: BlockKindHeading, HeadingLevel: 2}, | |||
| 178 | - | {ID: "p-h2", Kind: BlockKindParagraph}, | |||
| 179 | - | {ID: "h3", Kind: BlockKindHeading, HeadingLevel: 3}, | |||
| 180 | - | {ID: "p-h3", Kind: BlockKindParagraph}, | |||
| 181 | - | {ID: "h3b", Kind: BlockKindHeading, HeadingLevel: 3}, | |||
| 182 | - | {ID: "p-h3b", Kind: BlockKindParagraph}, | |||
| 183 | - | {ID: "h2b", Kind: BlockKindHeading, HeadingLevel: 2}, | |||
| 184 | - | {ID: "p-h2b", Kind: BlockKindParagraph}, | |||
| 185 | - | {ID: "h1b", Kind: BlockKindHeading, HeadingLevel: 1}, | |||
| 186 | - | {ID: "h2-skip", Kind: BlockKindHeading, HeadingLevel: 2}, | |||
| 187 | - | {ID: "h5-skip", Kind: BlockKindHeading, HeadingLevel: 5}, | |||
| 188 | - | {ID: "p-skip", Kind: BlockKindParagraph}, | |||
| 189 | - | {ID: "h2-sibling", Kind: BlockKindHeading, HeadingLevel: 2}, | |||
| 190 | - | {ID: "h2-adjacent", Kind: BlockKindHeading, HeadingLevel: 2}, | |||
| 191 | - | {ID: "h2-after-adjacent", Kind: BlockKindHeading, HeadingLevel: 2}, | |||
| 192 | - | {ID: "p-after-adjacent", Kind: BlockKindParagraph}, | |||
| 193 | - | {ID: "h1-final", Kind: BlockKindHeading, HeadingLevel: 1}, | |||
| 194 | - | {ID: "p-final", Kind: BlockKindParagraph}, | |||
| 195 | - | {ID: "h2-final-child", Kind: BlockKindHeading, HeadingLevel: 2}, | |||
| 196 | - | }) | |||
| 197 | - | } | |||
M internal/infrastructure/config/schema_provider_test.go +0 -10
| @@ -18,9 +18,6 @@ func TestConfigurationReferenceCoversSchemaKeys(t *testing.T) { | |||
| 18 | 18 | ||
| 19 | 19 | var missing []string | |
| 20 | 20 | for _, key := range NewSchemaProvider().GetSchema() { | |
| 21 | - | if _, pending := schemaKeysPendingProductDocs[key.Key]; pending { | |
| 22 | - | continue | |
| 23 | - | } | |
| 24 | 21 | if configurationReferenceCoversKey(docKeys, key.Key) { | |
| 25 | 22 | continue | |
| 26 | 23 | } | |
| @@ -31,13 +28,6 @@ func TestConfigurationReferenceCoversSchemaKeys(t *testing.T) { | |||
| 31 | 28 | require.Empty(t, missing, "docs/reference/configuration.md should cover every SchemaProvider key") | |
| 32 | 29 | } | |
| 33 | 30 | ||
| 34 | - | // schemaKeysPendingProductDocs lists SchemaProvider keys intentionally omitted from | |
| 35 | - | // product docs until a dedicated docs task lands them. | |
| 36 | - | var schemaKeysPendingProductDocs = map[string]struct{}{ | |
| 37 | - | "workspace.vim_mode.sequence_timeout_ms": {}, | |
| 38 | - | "workspace.vim_mode.preload_accessibility": {}, | |
| 39 | - | } | |
| 40 | - | ||
| 41 | 31 | func configurationReferenceKeys(t *testing.T) map[string]struct{} { | |
| 42 | 32 | t.Helper() | |
| 43 | 33 | ||
| @@ -18,9 +18,6 @@ func TestConfigurationReferenceCoversSchemaKeys(t *testing.T) { | |||||
| 18 | 18 | ||||
| 19 | var missing []string | 19 | var missing []string | ||
| 20 | for _, key := range NewSchemaProvider().GetSchema() { | 20 | for _, key := range NewSchemaProvider().GetSchema() { | ||
| 21 | - | if _, pending := schemaKeysPendingProductDocs[key.Key]; pending { | |||
| 22 | - | continue | |||
| 23 | - | } | |||
| 24 | if configurationReferenceCoversKey(docKeys, key.Key) { | 21 | if configurationReferenceCoversKey(docKeys, key.Key) { | ||
| 25 | continue | 22 | continue | ||
| 26 | } | 23 | } | ||
| @@ -31,13 +28,6 @@ func TestConfigurationReferenceCoversSchemaKeys(t *testing.T) { | |||||
| 31 | require.Empty(t, missing, "docs/reference/configuration.md should cover every SchemaProvider key") | 28 | require.Empty(t, missing, "docs/reference/configuration.md should cover every SchemaProvider key") | ||
| 32 | } | 29 | } | ||
| 33 | 30 | ||||
| 34 | - | // schemaKeysPendingProductDocs lists SchemaProvider keys intentionally omitted from | |||
| 35 | - | // product docs until a dedicated docs task lands them. | |||
| 36 | - | var schemaKeysPendingProductDocs = map[string]struct{}{ | |||
| 37 | - | "workspace.vim_mode.sequence_timeout_ms": {}, | |||
| 38 | - | "workspace.vim_mode.preload_accessibility": {}, | |||
| 39 | - | } | |||
| 40 | - | ||||
| 41 | func configurationReferenceKeys(t *testing.T) map[string]struct{} { | 31 | func configurationReferenceKeys(t *testing.T) map[string]struct{} { | ||
| 42 | t.Helper() | 32 | t.Helper() | ||
| 43 | 33 | ||||
M internal/infrastructure/webkit/injector.go +3 -0
| @@ -194,8 +194,11 @@ func buildAccentDetectionScript(token string) string { | |||
| 194 | 194 | const accentKeys = new Set([%s]); | |
| 195 | 195 | const editableFocusToken = %q; | |
| 196 | 196 | let pressedKey = null; | |
| 197 | + | let lastEditableFocusState = null; | |
| 197 | 198 | ||
| 198 | 199 | function postEditableFocus(editable) { | |
| 200 | + | if (lastEditableFocusState === editable) return; | |
| 201 | + | lastEditableFocusState = editable; | |
| 199 | 202 | if (!editableFocusToken) return; | |
| 200 | 203 | if (!(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber)) return; | |
| 201 | 204 | window.webkit.messageHandlers.dumber.postMessage({ | |
| @@ -194,8 +194,11 @@ func buildAccentDetectionScript(token string) string { | |||||
| 194 | const accentKeys = new Set([%s]); | 194 | const accentKeys = new Set([%s]); | ||
| 195 | const editableFocusToken = %q; | 195 | const editableFocusToken = %q; | ||
| 196 | let pressedKey = null; | 196 | let pressedKey = null; | ||
| 197 | + | let lastEditableFocusState = null; | |||
| 197 | 198 | ||||
| 198 | function postEditableFocus(editable) { | 199 | function postEditableFocus(editable) { | ||
| 200 | + | if (lastEditableFocusState === editable) return; | |||
| 201 | + | lastEditableFocusState = editable; | |||
| 199 | if (!editableFocusToken) return; | 202 | if (!editableFocusToken) return; | ||
| 200 | if (!(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber)) return; | 203 | if (!(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber)) return; | ||
| 201 | window.webkit.messageHandlers.dumber.postMessage({ | 204 | window.webkit.messageHandlers.dumber.postMessage({ | ||
M internal/infrastructure/webkit/injector_accent_test.go +2 -0
| @@ -38,6 +38,8 @@ func TestAccentDetectionScriptPostsEditableFocusChangedMessages(t *testing.T) { | |||
| 38 | 38 | assert.Contains(t, accentDetectionScript, "document.addEventListener('focusout'") | |
| 39 | 39 | assert.Contains(t, accentDetectionScript, `const editableFocusToken = "";`) | |
| 40 | 40 | assert.Contains(t, accentDetectionScript, `payload: { editable: editable, token: editableFocusToken }`) | |
| 41 | + | assert.Contains(t, accentDetectionScript, "let lastEditableFocusState = null;") | |
| 42 | + | assert.Contains(t, accentDetectionScript, "if (lastEditableFocusState === editable) return;") | |
| 41 | 43 | assert.Contains(t, accentDetectionScript, `postEditableFocus(true)`) | |
| 42 | 44 | assert.Contains(t, accentDetectionScript, `postEditableFocus(false)`) | |
| 43 | 45 | assert.Contains(t, accentDetectionScript, `e && e.isTrusted === false`) | |
| @@ -38,6 +38,8 @@ func TestAccentDetectionScriptPostsEditableFocusChangedMessages(t *testing.T) { | |||||
| 38 | assert.Contains(t, accentDetectionScript, "document.addEventListener('focusout'") | 38 | assert.Contains(t, accentDetectionScript, "document.addEventListener('focusout'") | ||
| 39 | assert.Contains(t, accentDetectionScript, `const editableFocusToken = "";`) | 39 | assert.Contains(t, accentDetectionScript, `const editableFocusToken = "";`) | ||
| 40 | assert.Contains(t, accentDetectionScript, `payload: { editable: editable, token: editableFocusToken }`) | 40 | assert.Contains(t, accentDetectionScript, `payload: { editable: editable, token: editableFocusToken }`) | ||
| 41 | + | assert.Contains(t, accentDetectionScript, "let lastEditableFocusState = null;") | |||
| 42 | + | assert.Contains(t, accentDetectionScript, "if (lastEditableFocusState === editable) return;") | |||
| 41 | assert.Contains(t, accentDetectionScript, `postEditableFocus(true)`) | 43 | assert.Contains(t, accentDetectionScript, `postEditableFocus(true)`) | ||
| 42 | assert.Contains(t, accentDetectionScript, `postEditableFocus(false)`) | 44 | assert.Contains(t, accentDetectionScript, `postEditableFocus(false)`) | ||
| 43 | assert.Contains(t, accentDetectionScript, `e && e.isTrusted === false`) | 45 | assert.Contains(t, accentDetectionScript, `e && e.isTrusted === false`) | ||
M internal/ui/app.go +31 -36
| @@ -124,6 +124,7 @@ type App struct { | |||
| 124 | 124 | focusMgr *focus.Manager | |
| 125 | 125 | ||
| 126 | 126 | vimModePolicyUC *usecase.VimModePolicyUseCase | |
| 127 | + | vimNavigationUC *usecase.VimNavigationUseCase | |
| 127 | 128 | pageEditableFocusByPane map[entity.PaneID]bool | |
| 128 | 129 | ||
| 129 | 130 | resizeModeBorderTarget layout.Widget | |
| @@ -217,6 +218,7 @@ func New(deps *Dependencies) (*App, error) { | |||
| 217 | 218 | floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession), | |
| 218 | 219 | browserWindows: make(map[string]*browserWindow), | |
| 219 | 220 | vimModePolicyUC: usecase.NewVimModePolicyUseCase(), | |
| 221 | + | vimNavigationUC: usecase.NewVimNavigationUseCase(), | |
| 220 | 222 | pageEditableFocusByPane: make(map[entity.PaneID]bool), | |
| 221 | 223 | dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult { | |
| 222 | 224 | if fn != nil { | |
| @@ -2171,54 +2173,34 @@ func (a *App) navigatePageFocus(_ context.Context, bw *browserWindow, backward b | |||
| 2171 | 2173 | if paneID == "" || !a.pageEditableFocused(paneID) { | |
| 2172 | 2174 | return false | |
| 2173 | 2175 | } | |
| 2174 | - | navigator, ok := wv.(port.PageFocusNavigator) | |
| 2175 | - | if !ok { | |
| 2176 | + | navigationUC := a.vimNavigationUseCase() | |
| 2177 | + | if navigationUC == nil { | |
| 2176 | 2178 | return false | |
| 2177 | 2179 | } | |
| 2178 | - | navigator.NavigatePageFocus(backward) | |
| 2179 | - | return true | |
| 2180 | + | return navigationUC.NavigatePageFocus(wv, backward) | |
| 2180 | 2181 | } | |
| 2181 | 2182 | ||
| 2182 | - | func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, action string, count int) { | |
| 2183 | - | if action == "focus-input" { | |
| 2184 | - | _, wv := a.activeWebViewForBrowserWindow(bw) | |
| 2185 | - | if focuser, ok := wv.(port.PageInputFocuser); ok { | |
| 2186 | - | focuser.FocusNextInput() | |
| 2187 | - | } | |
| 2188 | - | return | |
| 2183 | + | func (a *App) vimNavigationUseCase() *usecase.VimNavigationUseCase { | |
| 2184 | + | if a == nil { | |
| 2185 | + | return nil | |
| 2189 | 2186 | } | |
| 2190 | - | ||
| 2191 | - | var request port.SemanticNavigationRequest | |
| 2192 | - | switch action { | |
| 2193 | - | case "heading-next": | |
| 2194 | - | request = port.SemanticNavigationRequest{ | |
| 2195 | - | Target: port.SemanticNavigationTargetHeading, | |
| 2196 | - | Direction: port.SemanticNavigationForward, | |
| 2197 | - | Count: count, | |
| 2198 | - | HighlightColor: a.vimNavigationHighlightColor(), | |
| 2199 | - | } | |
| 2200 | - | case "heading-prev": | |
| 2201 | - | request = port.SemanticNavigationRequest{ | |
| 2202 | - | Target: port.SemanticNavigationTargetHeading, | |
| 2203 | - | Direction: port.SemanticNavigationBackward, | |
| 2204 | - | Count: count, | |
| 2205 | - | HighlightColor: a.vimNavigationHighlightColor(), | |
| 2206 | - | } | |
| 2207 | - | default: | |
| 2208 | - | return | |
| 2187 | + | if a.vimNavigationUC == nil { | |
| 2188 | + | a.vimNavigationUC = usecase.NewVimNavigationUseCase() | |
| 2209 | 2189 | } | |
| 2190 | + | return a.vimNavigationUC | |
| 2191 | + | } | |
| 2210 | 2192 | ||
| 2211 | - | paneID, wv := a.activeWebViewForBrowserWindow(bw) | |
| 2212 | - | navigator, ok := wv.(port.SemanticNavigable) | |
| 2213 | - | if !ok { | |
| 2193 | + | func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, action string, count int) { | |
| 2194 | + | _, wv := a.activeWebViewForBrowserWindow(bw) | |
| 2195 | + | navigationUC := a.vimNavigationUseCase() | |
| 2196 | + | if navigationUC == nil { | |
| 2214 | 2197 | return | |
| 2215 | 2198 | } | |
| 2216 | - | if err := navigator.NavigateSemantic(ctx, request); err != nil { | |
| 2199 | + | if err := navigationUC.Execute(ctx, wv, action, count, a.vimNavigationHighlightColor()); err != nil { | |
| 2217 | 2200 | logging.FromContext(ctx).Debug(). | |
| 2218 | 2201 | Err(err). | |
| 2219 | 2202 | Str("action", action). | |
| 2220 | - | Str("pane_id", string(paneID)). | |
| 2221 | - | Msg("vim semantic navigation unavailable") | |
| 2203 | + | Msg("vim navigation unavailable") | |
| 2222 | 2204 | } | |
| 2223 | 2205 | } | |
| 2224 | 2206 | ||
| @@ -2249,6 +2231,18 @@ func (a *App) enableAccessibilityForVimMode(ctx context.Context, bw *browserWind | |||
| 2249 | 2231 | Msg("webview accessibility enabled for vim mode") | |
| 2250 | 2232 | } | |
| 2251 | 2233 | ||
| 2234 | + | func (a *App) preloadAccessibilityForShownPane(paneID entity.PaneID) { | |
| 2235 | + | if a == nil || paneID == "" || !a.runtimeConfigSnapshot().UI.Workspace.VimMode.PreloadAccessibility || a.contentCoord == nil { | |
| 2236 | + | return | |
| 2237 | + | } | |
| 2238 | + | wv := a.contentCoord.GetWebView(paneID) | |
| 2239 | + | enabler, ok := wv.(port.AccessibilityEnabler) | |
| 2240 | + | if !ok { | |
| 2241 | + | return | |
| 2242 | + | } | |
| 2243 | + | enabler.EnableAccessibility() | |
| 2244 | + | } | |
| 2245 | + | ||
| 2252 | 2246 | // omniboxNavigateForBrowserWindow returns an omnibox OnNavigate callback that routes | |
| 2253 | 2247 | // navigation through navigate for the supplied browser window. The closure captures | |
| 2254 | 2248 | // bw at creation time, so navigation targets the owning window even when another | |
| @@ -3389,6 +3383,7 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 3389 | 3383 | a.deps.OnFirstWebViewShown(ctx) | |
| 3390 | 3384 | }) | |
| 3391 | 3385 | } | |
| 3386 | + | a.preloadAccessibilityForShownPane(paneID) | |
| 3392 | 3387 | ||
| 3393 | 3388 | // This only updates focus when the shown pane belongs to the last-focused | |
| 3394 | 3389 | // window's active workspace. activeWorkspace() delegates to | |
| @@ -124,6 +124,7 @@ type App struct { | |||||
| 124 | focusMgr *focus.Manager | 124 | focusMgr *focus.Manager | ||
| 125 | 125 | ||||
| 126 | vimModePolicyUC *usecase.VimModePolicyUseCase | 126 | vimModePolicyUC *usecase.VimModePolicyUseCase | ||
| 127 | + | vimNavigationUC *usecase.VimNavigationUseCase | |||
| 127 | pageEditableFocusByPane map[entity.PaneID]bool | 128 | pageEditableFocusByPane map[entity.PaneID]bool | ||
| 128 | 129 | ||||
| 129 | resizeModeBorderTarget layout.Widget | 130 | resizeModeBorderTarget layout.Widget | ||
| @@ -217,6 +218,7 @@ func New(deps *Dependencies) (*App, error) { | |||||
| 217 | floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession), | 218 | floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession), | ||
| 218 | browserWindows: make(map[string]*browserWindow), | 219 | browserWindows: make(map[string]*browserWindow), | ||
| 219 | vimModePolicyUC: usecase.NewVimModePolicyUseCase(), | 220 | vimModePolicyUC: usecase.NewVimModePolicyUseCase(), | ||
| 221 | + | vimNavigationUC: usecase.NewVimNavigationUseCase(), | |||
| 220 | pageEditableFocusByPane: make(map[entity.PaneID]bool), | 222 | pageEditableFocusByPane: make(map[entity.PaneID]bool), | ||
| 221 | dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult { | 223 | dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult { | ||
| 222 | if fn != nil { | 224 | if fn != nil { | ||
| @@ -2171,54 +2173,34 @@ func (a *App) navigatePageFocus(_ context.Context, bw *browserWindow, backward b | |||||
| 2171 | if paneID == "" || !a.pageEditableFocused(paneID) { | 2173 | if paneID == "" || !a.pageEditableFocused(paneID) { | ||
| 2172 | return false | 2174 | return false | ||
| 2173 | } | 2175 | } | ||
| 2174 | - | navigator, ok := wv.(port.PageFocusNavigator) | 2176 | + | navigationUC := a.vimNavigationUseCase() |
| 2175 | - | if !ok { | 2177 | + | if navigationUC == nil { |
| 2176 | return false | 2178 | return false | ||
| 2177 | } | 2179 | } | ||
| 2178 | - | navigator.NavigatePageFocus(backward) | 2180 | + | return navigationUC.NavigatePageFocus(wv, backward) |
| 2179 | - | return true | |||
| 2180 | } | 2181 | } | ||
| 2181 | 2182 | ||||
| 2182 | - | func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, action string, count int) { | 2183 | + | func (a *App) vimNavigationUseCase() *usecase.VimNavigationUseCase { |
| 2183 | - | if action == "focus-input" { | 2184 | + | if a == nil { |
| 2184 | - | _, wv := a.activeWebViewForBrowserWindow(bw) | 2185 | + | return nil |
| 2185 | - | if focuser, ok := wv.(port.PageInputFocuser); ok { | |||
| 2186 | - | focuser.FocusNextInput() | |||
| 2187 | - | } | |||
| 2188 | - | return | |||
| 2189 | } | 2186 | } | ||
| 2190 | - | 2187 | + | if a.vimNavigationUC == nil { | |
| 2191 | - | var request port.SemanticNavigationRequest | 2188 | + | a.vimNavigationUC = usecase.NewVimNavigationUseCase() |
| 2192 | - | switch action { | |||
| 2193 | - | case "heading-next": | |||
| 2194 | - | request = port.SemanticNavigationRequest{ | |||
| 2195 | - | Target: port.SemanticNavigationTargetHeading, | |||
| 2196 | - | Direction: port.SemanticNavigationForward, | |||
| 2197 | - | Count: count, | |||
| 2198 | - | HighlightColor: a.vimNavigationHighlightColor(), | |||
| 2199 | - | } | |||
| 2200 | - | case "heading-prev": | |||
| 2201 | - | request = port.SemanticNavigationRequest{ | |||
| 2202 | - | Target: port.SemanticNavigationTargetHeading, | |||
| 2203 | - | Direction: port.SemanticNavigationBackward, | |||
| 2204 | - | Count: count, | |||
| 2205 | - | HighlightColor: a.vimNavigationHighlightColor(), | |||
| 2206 | - | } | |||
| 2207 | - | default: | |||
| 2208 | - | return | |||
| 2209 | } | 2189 | } | ||
| 2190 | + | return a.vimNavigationUC | |||
| 2191 | + | } | |||
| 2210 | 2192 | ||||
| 2211 | - | paneID, wv := a.activeWebViewForBrowserWindow(bw) | 2193 | + | func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, action string, count int) { |
| 2212 | - | navigator, ok := wv.(port.SemanticNavigable) | 2194 | + | _, wv := a.activeWebViewForBrowserWindow(bw) |
| 2213 | - | if !ok { | 2195 | + | navigationUC := a.vimNavigationUseCase() |
| 2196 | + | if navigationUC == nil { | |||
| 2214 | return | 2197 | return | ||
| 2215 | } | 2198 | } | ||
| 2216 | - | if err := navigator.NavigateSemantic(ctx, request); err != nil { | 2199 | + | if err := navigationUC.Execute(ctx, wv, action, count, a.vimNavigationHighlightColor()); err != nil { |
| 2217 | logging.FromContext(ctx).Debug(). | 2200 | logging.FromContext(ctx).Debug(). | ||
| 2218 | Err(err). | 2201 | Err(err). | ||
| 2219 | Str("action", action). | 2202 | Str("action", action). | ||
| 2220 | - | Str("pane_id", string(paneID)). | 2203 | + | Msg("vim navigation unavailable") |
| 2221 | - | Msg("vim semantic navigation unavailable") | |||
| 2222 | } | 2204 | } | ||
| 2223 | } | 2205 | } | ||
| 2224 | 2206 | ||||
| @@ -2249,6 +2231,18 @@ func (a *App) enableAccessibilityForVimMode(ctx context.Context, bw *browserWind | |||||
| 2249 | Msg("webview accessibility enabled for vim mode") | 2231 | Msg("webview accessibility enabled for vim mode") | ||
| 2250 | } | 2232 | } | ||
| 2251 | 2233 | ||||
| 2234 | + | func (a *App) preloadAccessibilityForShownPane(paneID entity.PaneID) { | |||
| 2235 | + | if a == nil || paneID == "" || !a.runtimeConfigSnapshot().UI.Workspace.VimMode.PreloadAccessibility || a.contentCoord == nil { | |||
| 2236 | + | return | |||
| 2237 | + | } | |||
| 2238 | + | wv := a.contentCoord.GetWebView(paneID) | |||
| 2239 | + | enabler, ok := wv.(port.AccessibilityEnabler) | |||
| 2240 | + | if !ok { | |||
| 2241 | + | return | |||
| 2242 | + | } | |||
| 2243 | + | enabler.EnableAccessibility() | |||
| 2244 | + | } | |||
| 2245 | + | ||||
| 2252 | // omniboxNavigateForBrowserWindow returns an omnibox OnNavigate callback that routes | 2246 | // omniboxNavigateForBrowserWindow returns an omnibox OnNavigate callback that routes | ||
| 2253 | // navigation through navigate for the supplied browser window. The closure captures | 2247 | // navigation through navigate for the supplied browser window. The closure captures | ||
| 2254 | // bw at creation time, so navigation targets the owning window even when another | 2248 | // bw at creation time, so navigation targets the owning window even when another | ||
| @@ -3389,6 +3383,7 @@ func (a *App) initCoordinators(ctx context.Context) { | |||||
| 3389 | a.deps.OnFirstWebViewShown(ctx) | 3383 | a.deps.OnFirstWebViewShown(ctx) | ||
| 3390 | }) | 3384 | }) | ||
| 3391 | } | 3385 | } | ||
| 3386 | + | a.preloadAccessibilityForShownPane(paneID) | |||
| 3392 | 3387 | ||||
| 3393 | // This only updates focus when the shown pane belongs to the last-focused | 3388 | // This only updates focus when the shown pane belongs to the last-focused | ||
| 3394 | // window's active workspace. activeWorkspace() delegates to | 3389 | // window's active workspace. activeWorkspace() delegates to | ||
M internal/ui/app_vim_mode_test.go +25 -0
| @@ -454,6 +454,31 @@ func TestVimMode_Enter_AccessibilityNoopsWhenActiveWebViewMissing(t *testing.T) | |||
| 454 | 454 | app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim) | |
| 455 | 455 | } | |
| 456 | 456 | ||
| 457 | + | func TestVimMode_PreloadAccessibilityEnablesShownWebViewWhenConfigured(t *testing.T) { | |
| 458 | + | app, _, paneID := newVimModeAccessibilityFixture(t) | |
| 459 | + | wv := newAccessibilityEnablingWebView(t, 4) | |
| 460 | + | app.contentCoord.RegisterPopupWebView(paneID, wv) | |
| 461 | + | app.runtimeConfig = runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{ | |
| 462 | + | UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{ | |
| 463 | + | VimMode: entity.VimModeConfig{PreloadAccessibility: true}, | |
| 464 | + | }}, | |
| 465 | + | }) | |
| 466 | + | ||
| 467 | + | app.preloadAccessibilityForShownPane(paneID) | |
| 468 | + | ||
| 469 | + | assert.Equal(t, 1, wv.enableAccessibilityCalls) | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | func TestVimMode_PreloadAccessibilityNoopsWhenDisabled(t *testing.T) { | |
| 473 | + | app, _, paneID := newVimModeAccessibilityFixture(t) | |
| 474 | + | wv := newAccessibilityEnablingWebView(t, 5) | |
| 475 | + | app.contentCoord.RegisterPopupWebView(paneID, wv) | |
| 476 | + | ||
| 477 | + | app.preloadAccessibilityForShownPane(paneID) | |
| 478 | + | ||
| 479 | + | assert.Equal(t, 0, wv.enableAccessibilityCalls) | |
| 480 | + | } | |
| 481 | + | ||
| 457 | 482 | func TestVimNavigationHighlightColorUsesCurrentThemeAccent(t *testing.T) { | |
| 458 | 483 | resolved := entity.ResolvedTheme{ | |
| 459 | 484 | PrefersDark: true, | |
| @@ -454,6 +454,31 @@ func TestVimMode_Enter_AccessibilityNoopsWhenActiveWebViewMissing(t *testing.T) | |||||
| 454 | app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim) | 454 | app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim) | ||
| 455 | } | 455 | } | ||
| 456 | 456 | ||||
| 457 | + | func TestVimMode_PreloadAccessibilityEnablesShownWebViewWhenConfigured(t *testing.T) { | |||
| 458 | + | app, _, paneID := newVimModeAccessibilityFixture(t) | |||
| 459 | + | wv := newAccessibilityEnablingWebView(t, 4) | |||
| 460 | + | app.contentCoord.RegisterPopupWebView(paneID, wv) | |||
| 461 | + | app.runtimeConfig = runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{ | |||
| 462 | + | UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{ | |||
| 463 | + | VimMode: entity.VimModeConfig{PreloadAccessibility: true}, | |||
| 464 | + | }}, | |||
| 465 | + | }) | |||
| 466 | + | ||||
| 467 | + | app.preloadAccessibilityForShownPane(paneID) | |||
| 468 | + | ||||
| 469 | + | assert.Equal(t, 1, wv.enableAccessibilityCalls) | |||
| 470 | + | } | |||
| 471 | + | ||||
| 472 | + | func TestVimMode_PreloadAccessibilityNoopsWhenDisabled(t *testing.T) { | |||
| 473 | + | app, _, paneID := newVimModeAccessibilityFixture(t) | |||
| 474 | + | wv := newAccessibilityEnablingWebView(t, 5) | |||
| 475 | + | app.contentCoord.RegisterPopupWebView(paneID, wv) | |||
| 476 | + | ||||
| 477 | + | app.preloadAccessibilityForShownPane(paneID) | |||
| 478 | + | ||||
| 479 | + | assert.Equal(t, 0, wv.enableAccessibilityCalls) | |||
| 480 | + | } | |||
| 481 | + | ||||
| 457 | func TestVimNavigationHighlightColorUsesCurrentThemeAccent(t *testing.T) { | 482 | func TestVimNavigationHighlightColorUsesCurrentThemeAccent(t *testing.T) { | ||
| 458 | resolved := entity.ResolvedTheme{ | 483 | resolved := entity.ResolvedTheme{ | ||
| 459 | PrefersDark: true, | 484 | PrefersDark: true, | ||