package action import ( "testing" "git.gophernest.net/azpect/TextEditor/internal/core" ) // ================================================== // f (find forward inclusive) Tests // ================================================== func TestFindCharForward(t *testing.T) { t.Run("finds next character on same line", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"hello world"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 0). // At 'h' Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "o", Forward: true, Inclusive: true, Count: 1, } action.Execute(m) // Should move to first 'o' at position 4 if m.ActiveWindow().Cursor.Col != 4 { t.Errorf("cursor col = %d, want 4", m.ActiveWindow().Cursor.Col) } }) t.Run("finds character at current position", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"hello world"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 4). // At first 'o' Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "o", Forward: true, Inclusive: true, Count: 1, } action.Execute(m) // Cursor is on 'o' at 4; 'f' skips current position and finds next 'o' at 7 if m.ActiveWindow().Cursor.Col != 7 { t.Errorf("cursor col = %d, want 7", m.ActiveWindow().Cursor.Col) } }) t.Run("character not found does not move cursor", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"hello world"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 0). Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "x", Forward: true, Inclusive: true, Count: 1, } action.Execute(m) // Cursor should stay at position 0 if m.ActiveWindow().Cursor.Col != 0 { t.Errorf("cursor col = %d, want 0", m.ActiveWindow().Cursor.Col) } }) t.Run("finds character near end of line", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"hello world"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 0). Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "d", Forward: true, Inclusive: true, Count: 1, } action.Execute(m) // Should move to 'd' at position 10 if m.ActiveWindow().Cursor.Col != 10 { t.Errorf("cursor col = %d, want 10", m.ActiveWindow().Cursor.Col) } }) t.Run("finds second occurrence from middle of line", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"hello world"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 5). // At space after 'hello' Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "o", Forward: true, Inclusive: true, Count: 1, } action.Execute(m) // Should find 'o' in 'world' at position 7 if m.ActiveWindow().Cursor.Col != 7 { t.Errorf("cursor col = %d, want 7", m.ActiveWindow().Cursor.Col) } }) t.Run("handles empty line", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{""}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 0). Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "a", Forward: true, Inclusive: true, Count: 1, } action.Execute(m) // Cursor should stay at 0 if m.ActiveWindow().Cursor.Col != 0 { t.Errorf("cursor col = %d, want 0", m.ActiveWindow().Cursor.Col) } }) t.Run("handles single character line", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"a"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 0). Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "a", Forward: true, Inclusive: true, Count: 1, } action.Execute(m) // Should find 'a' at position 0 if m.ActiveWindow().Cursor.Col != 0 { t.Errorf("cursor col = %d, want 0", m.ActiveWindow().Cursor.Col) } }) t.Run("finds space character", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"hello world"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 0). Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: " ", Forward: true, Inclusive: true, Count: 1, } action.Execute(m) // Should find space at position 5 if m.ActiveWindow().Cursor.Col != 5 { t.Errorf("cursor col = %d, want 5", m.ActiveWindow().Cursor.Col) } }) t.Run("multiple same characters finds first", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"aaaaabbbbb"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 0). Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "b", Forward: true, Inclusive: true, Count: 1, } action.Execute(m) // Should find first 'b' at position 5 if m.ActiveWindow().Cursor.Col != 5 { t.Errorf("cursor col = %d, want 5", m.ActiveWindow().Cursor.Col) } }) } // ================================================== // F (find backward inclusive) Tests // ================================================== func TestFindCharBackward(t *testing.T) { t.Run("finds previous character on same line", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"hello world"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 10). // At 'd' Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "o", Forward: false, Inclusive: true, Count: 1, } action.Execute(m) // Should move to 'o' in 'world' at position 7 if m.ActiveWindow().Cursor.Col != 7 { t.Errorf("cursor col = %d, want 7", m.ActiveWindow().Cursor.Col) } }) t.Run("finds character at current position", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"hello world"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 7). // At 'o' in 'world' Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "o", Forward: false, Inclusive: true, Count: 1, } action.Execute(m) // Cursor is on 'o' at 7; 'F' skips current position and finds prev 'o' at 4 if m.ActiveWindow().Cursor.Col != 4 { t.Errorf("cursor col = %d, want 4", m.ActiveWindow().Cursor.Col) } }) t.Run("character not found does not move cursor", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"hello world"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 10). Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "x", Forward: false, Inclusive: true, Count: 1, } action.Execute(m) // Cursor should stay at position 10 if m.ActiveWindow().Cursor.Col != 10 { t.Errorf("cursor col = %d, want 10", m.ActiveWindow().Cursor.Col) } }) t.Run("finds character at beginning of line", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"hello world"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 10). Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "h", Forward: false, Inclusive: true, Count: 1, } action.Execute(m) // Should move to 'h' at position 0 if m.ActiveWindow().Cursor.Col != 0 { t.Errorf("cursor col = %d, want 0", m.ActiveWindow().Cursor.Col) } }) t.Run("finds first occurrence from middle of line", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"hello world"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 6). // At 'w' Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "o", Forward: false, Inclusive: true, Count: 1, } action.Execute(m) // Should find 'o' in 'hello' at position 4 if m.ActiveWindow().Cursor.Col != 4 { t.Errorf("cursor col = %d, want 4", m.ActiveWindow().Cursor.Col) } }) t.Run("handles empty line", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{""}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 0). Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "a", Forward: false, Inclusive: true, Count: 1, } action.Execute(m) // Cursor should stay at 0 if m.ActiveWindow().Cursor.Col != 0 { t.Errorf("cursor col = %d, want 0", m.ActiveWindow().Cursor.Col) } }) t.Run("handles single character line", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"a"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 0). Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "a", Forward: false, Inclusive: true, Count: 1, } action.Execute(m) // Should find 'a' at position 0 if m.ActiveWindow().Cursor.Col != 0 { t.Errorf("cursor col = %d, want 0", m.ActiveWindow().Cursor.Col) } }) t.Run("finds space character backward", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"hello world"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 10). Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: " ", Forward: false, Inclusive: true, Count: 1, } action.Execute(m) // Should find space at position 5 if m.ActiveWindow().Cursor.Col != 5 { t.Errorf("cursor col = %d, want 5", m.ActiveWindow().Cursor.Col) } }) t.Run("multiple same characters finds first going backward", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"aaaaabbbbb"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 9). // At last 'b' Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "a", Forward: false, Inclusive: true, Count: 1, } action.Execute(m) // Should find last 'a' at position 4 if m.ActiveWindow().Cursor.Col != 4 { t.Errorf("cursor col = %d, want 4", m.ActiveWindow().Cursor.Col) } }) } // ================================================== // t (till forward exclusive) Tests // ================================================== func TestTillCharForward(t *testing.T) { t.Run("stops before next character", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"hello world"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 0). // At 'h' Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "o", Forward: true, Inclusive: false, Count: 1, } action.Execute(m) // Should move to position 3 (one before 'o' at position 4) if m.ActiveWindow().Cursor.Col != 3 { t.Errorf("cursor col = %d, want 3", m.ActiveWindow().Cursor.Col) } }) t.Run("character not found does not move cursor", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"hello world"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 0). Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "x", Forward: true, Inclusive: false, Count: 1, } action.Execute(m) // Cursor should stay at position 0 if m.ActiveWindow().Cursor.Col != 0 { t.Errorf("cursor col = %d, want 0", m.ActiveWindow().Cursor.Col) } }) t.Run("handles adjacent character", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"ab"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 0). // At 'a' Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "b", Forward: true, Inclusive: false, Count: 1, } action.Execute(m) // Should move to position 0 (one before 'b' at position 1) // This will be the same position, which matches vim behavior if m.ActiveWindow().Cursor.Col != 0 { t.Errorf("cursor col = %d, want 0", m.ActiveWindow().Cursor.Col) } }) t.Run("stops before character near end of line", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"hello world"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 0). Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "d", Forward: true, Inclusive: false, Count: 1, } action.Execute(m) // Should move to position 9 (one before 'd' at position 10) if m.ActiveWindow().Cursor.Col != 9 { t.Errorf("cursor col = %d, want 9", m.ActiveWindow().Cursor.Col) } }) t.Run("handles empty line", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{""}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 0). Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "a", Forward: true, Inclusive: false, Count: 1, } action.Execute(m) // Cursor should stay at 0 if m.ActiveWindow().Cursor.Col != 0 { t.Errorf("cursor col = %d, want 0", m.ActiveWindow().Cursor.Col) } }) t.Run("stops before space character", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"hello world"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 0). Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: " ", Forward: true, Inclusive: false, Count: 1, } action.Execute(m) // Should move to position 4 (one before space at position 5) if m.ActiveWindow().Cursor.Col != 4 { t.Errorf("cursor col = %d, want 4", m.ActiveWindow().Cursor.Col) } }) t.Run("stops before second occurrence", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"hello world"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 5). // At space Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "o", Forward: true, Inclusive: false, Count: 1, } action.Execute(m) // Should move to position 6 (one before 'o' in 'world' at position 7) if m.ActiveWindow().Cursor.Col != 6 { t.Errorf("cursor col = %d, want 6", m.ActiveWindow().Cursor.Col) } }) } // ================================================== // T (till backward exclusive) Tests // ================================================== func TestTillCharBackward(t *testing.T) { t.Run("stops after previous character", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"hello world"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 10). // At 'd' Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "o", Forward: false, Inclusive: false, Count: 1, } action.Execute(m) // Should move to position 8 (one after 'o' in 'world' at position 7) if m.ActiveWindow().Cursor.Col != 8 { t.Errorf("cursor col = %d, want 8", m.ActiveWindow().Cursor.Col) } }) t.Run("character not found does not move cursor", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"hello world"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 10). Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "x", Forward: false, Inclusive: false, Count: 1, } action.Execute(m) // Cursor should stay at position 10 if m.ActiveWindow().Cursor.Col != 10 { t.Errorf("cursor col = %d, want 10", m.ActiveWindow().Cursor.Col) } }) t.Run("handles adjacent character", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"ab"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 1). // At 'b' Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "a", Forward: false, Inclusive: false, Count: 1, } action.Execute(m) // Should move to position 1 (one after 'a' at position 0) // This will be the same position, which matches vim behavior if m.ActiveWindow().Cursor.Col != 1 { t.Errorf("cursor col = %d, want 1", m.ActiveWindow().Cursor.Col) } }) t.Run("stops after character at beginning of line", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"hello world"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 10). Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "h", Forward: false, Inclusive: false, Count: 1, } action.Execute(m) // Should move to position 1 (one after 'h' at position 0) if m.ActiveWindow().Cursor.Col != 1 { t.Errorf("cursor col = %d, want 1", m.ActiveWindow().Cursor.Col) } }) t.Run("handles empty line", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{""}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 0). Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "a", Forward: false, Inclusive: false, Count: 1, } action.Execute(m) // Cursor should stay at 0 if m.ActiveWindow().Cursor.Col != 0 { t.Errorf("cursor col = %d, want 0", m.ActiveWindow().Cursor.Col) } }) t.Run("stops after space character backward", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"hello world"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 10). Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: " ", Forward: false, Inclusive: false, Count: 1, } action.Execute(m) // Should move to position 6 (one after space at position 5) if m.ActiveWindow().Cursor.Col != 6 { t.Errorf("cursor col = %d, want 6", m.ActiveWindow().Cursor.Col) } }) t.Run("stops after first occurrence going backward", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"hello world"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 6). // At 'w' Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "o", Forward: false, Inclusive: false, Count: 1, } action.Execute(m) // Should move to position 5 (one after 'o' in 'hello' at position 4) if m.ActiveWindow().Cursor.Col != 5 { t.Errorf("cursor col = %d, want 5", m.ActiveWindow().Cursor.Col) } }) t.Run("multiple same characters stops after last going backward", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"aaaaabbbbb"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 9). // At last 'b' Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "a", Forward: false, Inclusive: false, Count: 1, } action.Execute(m) // Should move to position 5 (one after last 'a' at position 4) if m.ActiveWindow().Cursor.Col != 5 { t.Errorf("cursor col = %d, want 5", m.ActiveWindow().Cursor.Col) } }) } // ================================================== // Count Tests (f with count) // ================================================== func TestFindCharForwardWithCount(t *testing.T) { t.Run("2f finds second occurrence", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"hello world"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 0). // At 'h' Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "l", Forward: true, Inclusive: true, Count: 2, } action.Execute(m) // Should find 2nd 'l' at position 3 // "hello world" // 01234567890 // ^ (first 'l' at 2) // ^ (second 'l' at 3) if m.ActiveWindow().Cursor.Col != 3 { t.Errorf("cursor col = %d, want 3", m.ActiveWindow().Cursor.Col) } }) t.Run("3f finds third occurrence", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"hello world"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 0). Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "l", Forward: true, Inclusive: true, Count: 3, } action.Execute(m) // Should find 3rd 'l' at position 9 // "hello world" // 01234567890 // ^^ (first two 'l's at 2, 3) // ^ (third 'l' at 9) if m.ActiveWindow().Cursor.Col != 9 { t.Errorf("cursor col = %d, want 9", m.ActiveWindow().Cursor.Col) } }) t.Run("count exceeds available matches does not move cursor", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"hello world"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 0). Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "l", Forward: true, Inclusive: true, Count: 5, // Only 3 'l's in the line } action.Execute(m) // Cursor should stay at 0 if m.ActiveWindow().Cursor.Col != 0 { t.Errorf("cursor col = %d, want 0 (should not move)", m.ActiveWindow().Cursor.Col) } }) t.Run("2f with only one occurrence does not move", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"hello world"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 0). Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "w", Forward: true, Inclusive: true, Count: 2, // Only 1 'w' in the line } action.Execute(m) // Cursor should stay at 0 if m.ActiveWindow().Cursor.Col != 0 { t.Errorf("cursor col = %d, want 0 (should not move)", m.ActiveWindow().Cursor.Col) } }) t.Run("5f finds fifth occurrence", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"aaaaa bbbbb"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 0). Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "a", Forward: true, Inclusive: true, Count: 5, } action.Execute(m) // Cursor is on 'a' at 0; 'f' skips it, leaving only 4 more 'a's (1-4). // Count 5 exceeds available matches, so cursor does not move. if m.ActiveWindow().Cursor.Col != 0 { t.Errorf("cursor col = %d, want 0 (count exceeds matches, should not move)", m.ActiveWindow().Cursor.Col) } }) t.Run("count from middle of line", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"aaa bbb aaa"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 4). // At first 'b' Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "a", Forward: true, Inclusive: true, Count: 2, } action.Execute(m) // From position 4, should find 2nd 'a' ahead at position 9 // "aaa bbb aaa" // 01234567890 // ^ (starting at 4) // ^^ (first 'a' at 8, second at 9) if m.ActiveWindow().Cursor.Col != 9 { t.Errorf("cursor col = %d, want 9", m.ActiveWindow().Cursor.Col) } }) } // ================================================== // Count Tests (F with count) // ================================================== func TestFindCharBackwardWithCount(t *testing.T) { t.Run("2F finds second occurrence backward", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"hello world"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 10). // At 'd' Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "l", Forward: false, Inclusive: true, Count: 2, } action.Execute(m) // Going backward from 'd', should find 2nd 'l' at position 3 // "hello world" // 01234567890 // ^ (first 'l' backward at 9) // ^ (second 'l' backward at 3) if m.ActiveWindow().Cursor.Col != 3 { t.Errorf("cursor col = %d, want 3", m.ActiveWindow().Cursor.Col) } }) t.Run("3F finds third occurrence backward", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"hello world"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 10). Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "l", Forward: false, Inclusive: true, Count: 3, } action.Execute(m) // Going backward, should find 3rd 'l' at position 2 // "hello world" // 01234567890 // ^ (first 'l' backward at 9) // ^ (second 'l' backward at 3) // ^ (third 'l' backward at 2) if m.ActiveWindow().Cursor.Col != 2 { t.Errorf("cursor col = %d, want 2", m.ActiveWindow().Cursor.Col) } }) t.Run("count exceeds available matches does not move cursor", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"hello world"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 10). Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "l", Forward: false, Inclusive: true, Count: 5, // Only 3 'l's in the line } action.Execute(m) // Cursor should stay at 10 if m.ActiveWindow().Cursor.Col != 10 { t.Errorf("cursor col = %d, want 10 (should not move)", m.ActiveWindow().Cursor.Col) } }) t.Run("5F finds fifth occurrence backward", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"aaaaa bbbbb"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 10). // At last 'b' Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "b", Forward: false, Inclusive: true, Count: 5, } action.Execute(m) // Cursor is on 'b' at 10; 'F' skips it, leaving only 4 more 'b's (6-9). // Count 5 exceeds available matches, so cursor does not move. if m.ActiveWindow().Cursor.Col != 10 { t.Errorf("cursor col = %d, want 10 (count exceeds matches, should not move)", m.ActiveWindow().Cursor.Col) } }) t.Run("count from middle of line backward", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"aaa bbb aaa"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 6). // At middle 'b' Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "a", Forward: false, Inclusive: true, Count: 2, } action.Execute(m) // From position 6 going backward, should find 2nd 'a' at position 1 // "aaa bbb aaa" // 01234567890 // ^ (starting at 6) // ^^ (first 'a' backward at 2, second at 1) if m.ActiveWindow().Cursor.Col != 1 { t.Errorf("cursor col = %d, want 1", m.ActiveWindow().Cursor.Col) } }) } // ================================================== // Count Tests (t with count) // ================================================== func TestTillCharForwardWithCount(t *testing.T) { t.Run("2t stops before second occurrence", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"hello world"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 0). Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "l", Forward: true, Inclusive: false, Count: 2, } action.Execute(m) // Should stop before 2nd 'l' at position 2 (one before position 3) if m.ActiveWindow().Cursor.Col != 2 { t.Errorf("cursor col = %d, want 2", m.ActiveWindow().Cursor.Col) } }) t.Run("3t stops before third occurrence", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"hello world"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 0). Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "l", Forward: true, Inclusive: false, Count: 3, } action.Execute(m) // Should stop before 3rd 'l' at position 8 (one before position 9) if m.ActiveWindow().Cursor.Col != 8 { t.Errorf("cursor col = %d, want 8", m.ActiveWindow().Cursor.Col) } }) t.Run("count exceeds available matches does not move cursor", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"hello world"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 0). Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "l", Forward: true, Inclusive: false, Count: 5, } action.Execute(m) // Cursor should stay at 0 if m.ActiveWindow().Cursor.Col != 0 { t.Errorf("cursor col = %d, want 0 (should not move)", m.ActiveWindow().Cursor.Col) } }) t.Run("5t stops before fifth occurrence", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"aaaaa bbbbb"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 0). Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "a", Forward: true, Inclusive: false, Count: 5, } action.Execute(m) // Cursor is on 'a' at 0; 't' skips it, leaving only 4 more 'a's (1-4). // Count 5 exceeds available matches, so cursor does not move. if m.ActiveWindow().Cursor.Col != 0 { t.Errorf("cursor col = %d, want 0 (count exceeds matches, should not move)", m.ActiveWindow().Cursor.Col) } }) } // ================================================== // Count Tests (T with count) // ================================================== func TestTillCharBackwardWithCount(t *testing.T) { t.Run("2T stops after second occurrence backward", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"hello world"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 10). Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "l", Forward: false, Inclusive: false, Count: 2, } action.Execute(m) // Going backward, should stop after 2nd 'l' at position 4 (one after position 3) if m.ActiveWindow().Cursor.Col != 4 { t.Errorf("cursor col = %d, want 4", m.ActiveWindow().Cursor.Col) } }) t.Run("3T stops after third occurrence backward", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"hello world"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 10). Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "l", Forward: false, Inclusive: false, Count: 3, } action.Execute(m) // Going backward, should stop after 3rd 'l' at position 3 (one after position 2) if m.ActiveWindow().Cursor.Col != 3 { t.Errorf("cursor col = %d, want 3", m.ActiveWindow().Cursor.Col) } }) t.Run("count exceeds available matches does not move cursor", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"hello world"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 10). Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "l", Forward: false, Inclusive: false, Count: 5, } action.Execute(m) // Cursor should stay at 10 if m.ActiveWindow().Cursor.Col != 10 { t.Errorf("cursor col = %d, want 10 (should not move)", m.ActiveWindow().Cursor.Col) } }) t.Run("5T stops after fifth occurrence backward", func(t *testing.T) { buf := core.NewBufferBuilder(). WithLines([]string{"aaaaa bbbbb"}). Build() win := core.NewWindowBuilder(). WithBuffer(&buf). WithCursorPos(0, 10). Build() m := NewMockModelWithWindow(&win) action := FindChar{ Char: "b", Forward: false, Inclusive: false, Count: 5, } action.Execute(m) // Cursor is on 'b' at 10; 'T' skips it, leaving only 4 more 'b's (6-9). // Count 5 exceeds available matches, so cursor does not move. if m.ActiveWindow().Cursor.Col != 10 { t.Errorf("cursor col = %d, want 10 (count exceeds matches, should not move)", m.ActiveWindow().Cursor.Col) } }) } // ================================================== // Motion Type Tests // ================================================== func TestFindCharType(t *testing.T) { t.Run("f returns CharwiseInclusive", func(t *testing.T) { action := FindChar{ Char: "a", Forward: true, Inclusive: true, } if action.Type() != core.CharwiseInclusive { t.Errorf("Type() = %v, want CharwiseInclusive", action.Type()) } }) t.Run("t returns CharwiseExclusive", func(t *testing.T) { action := FindChar{ Char: "a", Forward: true, Inclusive: false, } if action.Type() != core.CharwiseExclusive { t.Errorf("Type() = %v, want CharwiseExclusive", action.Type()) } }) t.Run("F returns CharwiseInclusive", func(t *testing.T) { action := FindChar{ Char: "a", Forward: false, Inclusive: true, } if action.Type() != core.CharwiseInclusive { t.Errorf("Type() = %v, want CharwiseInclusive", action.Type()) } }) t.Run("T returns CharwiseExclusive", func(t *testing.T) { action := FindChar{ Char: "a", Forward: false, Inclusive: false, } if action.Type() != core.CharwiseExclusive { t.Errorf("Type() = %v, want CharwiseExclusive", action.Type()) } }) } // ================================================== // WithChar Tests // ================================================== func TestFindCharWithChar(t *testing.T) { t.Run("WithChar sets the character", func(t *testing.T) { action := FindChar{ Forward: true, Inclusive: true, } newAction := action.WithChar("x") findAction, ok := newAction.(FindChar) if !ok { t.Fatal("WithChar should return FindChar") } if findAction.Char != "x" { t.Errorf("Char = %q, want %q", findAction.Char, "x") } }) t.Run("WithChar preserves other fields", func(t *testing.T) { action := FindChar{ Forward: true, Inclusive: true, Count: 3, } newAction := action.WithChar("y") findAction, ok := newAction.(FindChar) if !ok { t.Fatal("WithChar should return FindChar") } if !findAction.Forward { t.Error("Forward should be preserved") } if !findAction.Inclusive { t.Error("Inclusive should be preserved") } if findAction.Count != 3 { t.Errorf("Count = %d, want 3", findAction.Count) } }) } // ================================================== // RepeatFind — ; (same direction) and , (reverse direction) // // Layout used throughout: "hello world" // 01234567890 // 'h'=0 'e'=1 'l'=2 'l'=3 'o'=4 ' '=5 'w'=6 'o'=7 'r'=8 'l'=9 'd'=10 // ================================================== // -------------------------------------------------- // ; after f (repeat forward-inclusive) // -------------------------------------------------- func TestRepeatFind_Semicolon_After_f(t *testing.T) { // `fo` from col 0 → col 4. `;` should find next 'o' at col 7. t.Run("basic: lands on next inclusive match", func(t *testing.T) { buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 4).Build() m := NewMockModelWithWindow(&win) m.SetLastFind("o", true, true) FindChar{Char: "o", Forward: true, Inclusive: true, Count: 1, Repeated: true}.Execute(m) if m.ActiveWindow().Cursor.Col != 7 { t.Errorf("col = %d, want 7", m.ActiveWindow().Cursor.Col) } }) // Cursor on last 'o' (col 7); no further 'o' — cursor must not move. t.Run("no further match: cursor stays", func(t *testing.T) { buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 7).Build() m := NewMockModelWithWindow(&win) m.SetLastFind("o", true, true) FindChar{Char: "o", Forward: true, Inclusive: true, Count: 1, Repeated: true}.Execute(m) if m.ActiveWindow().Cursor.Col != 7 { t.Errorf("col = %d, want 7 (no move)", m.ActiveWindow().Cursor.Col) } }) // Cursor at last col (10 = 'd'); searching forward finds nothing. t.Run("cursor at end of line: no move", func(t *testing.T) { buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 10).Build() m := NewMockModelWithWindow(&win) m.SetLastFind("o", true, true) FindChar{Char: "o", Forward: true, Inclusive: true, Count: 1, Repeated: true}.Execute(m) if m.ActiveWindow().Cursor.Col != 10 { t.Errorf("col = %d, want 10 (no move)", m.ActiveWindow().Cursor.Col) } }) // Count=2: skip first upcoming match, land on second. // "aXbXcX" 0123456 — cursor at 0 (before first X at 1). // `;` with count 2 should land on X at 5. t.Run("count=2 skips first match lands on second", func(t *testing.T) { buf := core.NewBufferBuilder().WithLines([]string{"aXbXcX"}).Build() win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 1).Build() m := NewMockModelWithWindow(&win) m.SetLastFind("X", true, true) FindChar{Char: "X", Forward: true, Inclusive: true, Count: 2, Repeated: true}.Execute(m) if m.ActiveWindow().Cursor.Col != 5 { t.Errorf("col = %d, want 5", m.ActiveWindow().Cursor.Col) } }) // Repeated=true must NOT overwrite lastFind. t.Run("does not overwrite lastFind when Repeated", func(t *testing.T) { buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 4).Build() m := NewMockModelWithWindow(&win) m.SetLastFind("o", true, true) FindChar{Char: "o", Forward: true, Inclusive: true, Count: 1, Repeated: true}.Execute(m) lf := m.GetLastFind() if lf.Char != "o" || !lf.Forward || !lf.Inclusive { t.Errorf("lastFind modified: got {%q,%v,%v}", lf.Char, lf.Forward, lf.Inclusive) } }) } // -------------------------------------------------- // ; after F (repeat backward-inclusive) // -------------------------------------------------- func TestRepeatFind_Semicolon_After_F(t *testing.T) { // `Fo` from col 10 → col 7. `;` should find prev 'o' at col 4. t.Run("basic: lands on previous inclusive match", func(t *testing.T) { buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 7).Build() m := NewMockModelWithWindow(&win) m.SetLastFind("o", false, true) FindChar{Char: "o", Forward: false, Inclusive: true, Count: 1, Repeated: true}.Execute(m) if m.ActiveWindow().Cursor.Col != 4 { t.Errorf("col = %d, want 4", m.ActiveWindow().Cursor.Col) } }) // Cursor on first 'o' (col 4); no earlier 'o' — must not move. t.Run("no earlier match: cursor stays", func(t *testing.T) { buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 4).Build() m := NewMockModelWithWindow(&win) m.SetLastFind("o", false, true) FindChar{Char: "o", Forward: false, Inclusive: true, Count: 1, Repeated: true}.Execute(m) if m.ActiveWindow().Cursor.Col != 4 { t.Errorf("col = %d, want 4 (no move)", m.ActiveWindow().Cursor.Col) } }) // Cursor at col 0; searching backward finds nothing. t.Run("cursor at start of line: no move", func(t *testing.T) { buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 0).Build() m := NewMockModelWithWindow(&win) m.SetLastFind("o", false, true) FindChar{Char: "o", Forward: false, Inclusive: true, Count: 1, Repeated: true}.Execute(m) if m.ActiveWindow().Cursor.Col != 0 { t.Errorf("col = %d, want 0 (no move)", m.ActiveWindow().Cursor.Col) } }) // Count=2 backward: "XaXbX" 01234 — cursor at 4 (on last X). // `;` F count 2 → skip X at 2, land on X at 0. t.Run("count=2 backward skips one lands on second", func(t *testing.T) { buf := core.NewBufferBuilder().WithLines([]string{"XaXbX"}).Build() win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 4).Build() m := NewMockModelWithWindow(&win) m.SetLastFind("X", false, true) FindChar{Char: "X", Forward: false, Inclusive: true, Count: 2, Repeated: true}.Execute(m) if m.ActiveWindow().Cursor.Col != 0 { t.Errorf("col = %d, want 0", m.ActiveWindow().Cursor.Col) } }) } // -------------------------------------------------- // ; after t (repeat forward-exclusive) // The key invariant: when Repeated && !Inclusive, start at col+2 not col+1, // so that the char the cursor is already adjacent to is skipped. // -------------------------------------------------- func TestRepeatFind_Semicolon_After_t(t *testing.T) { // `to` col 0 → col 3 (one before 'o' at 4). // `;` must skip 'o' at 4 and land at col 6 (one before 'o' at 7). t.Run("basic: skips adjacent target, lands before next", func(t *testing.T) { buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 3).Build() m := NewMockModelWithWindow(&win) m.SetLastFind("o", true, false) FindChar{Char: "o", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m) if m.ActiveWindow().Cursor.Col != 6 { t.Errorf("col = %d, want 6", m.ActiveWindow().Cursor.Col) } }) // After landing at col 6 (before 'o' at 7), another `;` looks from col 8: // no further 'o' → cursor stays at 6. t.Run("no further match after second repeat: cursor stays", func(t *testing.T) { buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 6).Build() m := NewMockModelWithWindow(&win) m.SetLastFind("o", true, false) FindChar{Char: "o", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m) if m.ActiveWindow().Cursor.Col != 6 { t.Errorf("col = %d, want 6 (no move)", m.ActiveWindow().Cursor.Col) } }) // Cursor at second-to-last col: col+2 would be out of bounds → no move. // "Xo" — cursor at 0 (just did `to` landing at 0 = x-1 where x=1). // `;` would start at col 2 which is past end → no move. t.Run("col+2 out of bounds: no move", func(t *testing.T) { buf := core.NewBufferBuilder().WithLines([]string{"Xo"}).Build() win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 0).Build() m := NewMockModelWithWindow(&win) m.SetLastFind("o", true, false) FindChar{Char: "o", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m) if m.ActiveWindow().Cursor.Col != 0 { t.Errorf("col = %d, want 0 (no move)", m.ActiveWindow().Cursor.Col) } }) // Three successive `;` after `t`: "aXbXcXd" 0123456 // `tX` col 0 → col 0 (adjacent: x=1, land at x-1=0). // `;`1 col 0 → col 2 (skip X at 1, find X at 3, land at 2). // `;`2 col 2 → col 4 (skip X at 3, find X at 5, land at 4). t.Run("three chained repeats advance correctly", func(t *testing.T) { buf := core.NewBufferBuilder().WithLines([]string{"aXbXcXd"}).Build() win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 0).Build() m := NewMockModelWithWindow(&win) m.SetLastFind("X", true, false) // First repeat FindChar{Char: "X", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m) if m.ActiveWindow().Cursor.Col != 2 { t.Errorf("after 1st repeat: col = %d, want 2", m.ActiveWindow().Cursor.Col) } // Second repeat FindChar{Char: "X", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m) if m.ActiveWindow().Cursor.Col != 4 { t.Errorf("after 2nd repeat: col = %d, want 4", m.ActiveWindow().Cursor.Col) } // Third repeat: no more X after col 6 → no move FindChar{Char: "X", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m) if m.ActiveWindow().Cursor.Col != 4 { t.Errorf("after 3rd repeat (no match): col = %d, want 4", m.ActiveWindow().Cursor.Col) } }) // count=2 on a repeated exclusive forward find. // "aXbXcX" 0123456 — cursor at 0 (as if `tX` just landed at 0). // `;` count=2 must skip X at 1, find X at 3 (count--), find X at 5 (count==1) → land at 4. t.Run("count=2 repeated exclusive forward", func(t *testing.T) { buf := core.NewBufferBuilder().WithLines([]string{"aXbXcX"}).Build() win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 0).Build() m := NewMockModelWithWindow(&win) m.SetLastFind("X", true, false) FindChar{Char: "X", Forward: true, Inclusive: false, Count: 2, Repeated: true}.Execute(m) if m.ActiveWindow().Cursor.Col != 4 { t.Errorf("col = %d, want 4", m.ActiveWindow().Cursor.Col) } }) } // -------------------------------------------------- // ; after T (repeat backward-exclusive) // When Repeated && !Inclusive, start at col-2 to skip the adjacent char. // -------------------------------------------------- func TestRepeatFind_Semicolon_After_T(t *testing.T) { // `To` col 10 → col 8 (one after 'o' at 7). // `;` must skip 'o' at 7, starting search at col 6, find 'o' at 4 → land at 5. t.Run("basic: skips adjacent target, lands after previous", func(t *testing.T) { buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 8).Build() m := NewMockModelWithWindow(&win) m.SetLastFind("o", false, false) FindChar{Char: "o", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m) if m.ActiveWindow().Cursor.Col != 5 { t.Errorf("col = %d, want 5", m.ActiveWindow().Cursor.Col) } }) // After landing at col 5, another `;` starts at col 3: no 'o' → stays at 5. t.Run("no earlier match after second repeat: cursor stays", func(t *testing.T) { buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 5).Build() m := NewMockModelWithWindow(&win) m.SetLastFind("o", false, false) FindChar{Char: "o", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m) if m.ActiveWindow().Cursor.Col != 5 { t.Errorf("col = %d, want 5 (no move)", m.ActiveWindow().Cursor.Col) } }) // col-2 goes negative: col=1, backwardStart=-1 → loop never runs → no move. t.Run("col-2 negative: no move", func(t *testing.T) { buf := core.NewBufferBuilder().WithLines([]string{"oX"}).Build() // Cursor at col 1 (as if `TX` landed at x+1=1 where x=0). win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 1).Build() m := NewMockModelWithWindow(&win) m.SetLastFind("X", false, false) FindChar{Char: "X", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m) if m.ActiveWindow().Cursor.Col != 1 { t.Errorf("col = %d, want 1 (no move)", m.ActiveWindow().Cursor.Col) } }) // Three chained repeats: "dXcXbXa" 0123456 // After `TX` col 6 → col 6 (adjacent: x=5, land at x+1=6). // `;`1 col 6 → col 4 (skip X at 5, find X at 3, land at 4). // `;`2 col 4 → col 2 (skip X at 3, find X at 1, land at 2). // `;`3 col 2 → no X before col 0 → stays at 2. t.Run("three chained repeats advance correctly backward", func(t *testing.T) { buf := core.NewBufferBuilder().WithLines([]string{"dXcXbXa"}).Build() win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 6).Build() m := NewMockModelWithWindow(&win) m.SetLastFind("X", false, false) FindChar{Char: "X", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m) if m.ActiveWindow().Cursor.Col != 4 { t.Errorf("after 1st repeat: col = %d, want 4", m.ActiveWindow().Cursor.Col) } FindChar{Char: "X", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m) if m.ActiveWindow().Cursor.Col != 2 { t.Errorf("after 2nd repeat: col = %d, want 2", m.ActiveWindow().Cursor.Col) } FindChar{Char: "X", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m) if m.ActiveWindow().Cursor.Col != 2 { t.Errorf("after 3rd repeat (no match): col = %d, want 2", m.ActiveWindow().Cursor.Col) } }) // count=2 repeated exclusive backward. // "XcXbXa" 01234 — cursor at 6 (as if `TX` just landed). // "dXcXbXa" 0123456 cursor at 6. // `;` count=2: skip X at 5, count-- (find X at 3), count-- → find X at 1 → land at 2. t.Run("count=2 repeated exclusive backward", func(t *testing.T) { buf := core.NewBufferBuilder().WithLines([]string{"dXcXbXa"}).Build() win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 6).Build() m := NewMockModelWithWindow(&win) m.SetLastFind("X", false, false) FindChar{Char: "X", Forward: false, Inclusive: false, Count: 2, Repeated: true}.Execute(m) if m.ActiveWindow().Cursor.Col != 2 { t.Errorf("col = %d, want 2", m.ActiveWindow().Cursor.Col) } }) } // -------------------------------------------------- // Counted repeats: 2; and 3; equivalence with pressing ; multiple times // // The invariant: N; must land on the same column as pressing ; N times. // This section verifies that the Count path through FindChar.Execute // gives identical results to chaining individual Repeated executions. // -------------------------------------------------- func TestRepeatFind_CountedRepeats(t *testing.T) { // Line: "aXbXcXdXe" 0123456789 (wait: let's count) // a X b X c X d X e // 0 1 2 3 4 5 6 7 8 // X appears at cols 1, 3, 5, 7. // ---- 2; after f (forward inclusive) ---- // `fX` from col 0 → col 1. // `;` from col 1 → col 3 (first ;) // `2;` from col 1 should also land at col 5 (second ; equivalent), because: // count=2 starts at col+1=2, skips X at 3 (count--), lands at X at 5. // Wait — let me re-examine. "2;" means repeat the find 2 times from current pos. // Pressing ; once from col 1: start at 2, find X at 3 → land 3. (not Repeated ≠ same as what we do) // Actually after `fX` cursor is ON X at col 1. A single `;` (Repeated=true, inclusive) // starts at col+1=2 → finds X at 3 → land at 3. // A `2;` (Count=2, Repeated=true, inclusive) starts at col+1=2: // X at 3 → count-- (→1); X at 5 → land at 5. // Equivalently: press ; once (land 3), press ; again (start at 4, land at 5). ✓ t.Run("2; after f: lands same as two ; presses (inclusive forward)", func(t *testing.T) { line := "aXbXcXdXe" buf := core.NewBufferBuilder().WithLines([]string{line}).Build() // Simulate with two sequential ; presses win1 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 1).Build() m1 := NewMockModelWithWindow(&win1) m1.SetLastFind("X", true, true) FindChar{Char: "X", Forward: true, Inclusive: true, Count: 1, Repeated: true}.Execute(m1) FindChar{Char: "X", Forward: true, Inclusive: true, Count: 1, Repeated: true}.Execute(m1) twoSemicolons := m1.ActiveWindow().Cursor.Col // Simulate with a single 2; (Count=2) win2 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 1).Build() m2 := NewMockModelWithWindow(&win2) m2.SetLastFind("X", true, true) FindChar{Char: "X", Forward: true, Inclusive: true, Count: 2, Repeated: true}.Execute(m2) counted := m2.ActiveWindow().Cursor.Col if twoSemicolons != counted { t.Errorf("2; landed at %d but two ; presses landed at %d", counted, twoSemicolons) } }) t.Run("3; after f: lands same as three ; presses (inclusive forward)", func(t *testing.T) { line := "aXbXcXdXe" buf := core.NewBufferBuilder().WithLines([]string{line}).Build() win1 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 1).Build() m1 := NewMockModelWithWindow(&win1) m1.SetLastFind("X", true, true) FindChar{Char: "X", Forward: true, Inclusive: true, Count: 1, Repeated: true}.Execute(m1) FindChar{Char: "X", Forward: true, Inclusive: true, Count: 1, Repeated: true}.Execute(m1) FindChar{Char: "X", Forward: true, Inclusive: true, Count: 1, Repeated: true}.Execute(m1) threeSemicolons := m1.ActiveWindow().Cursor.Col win2 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 1).Build() m2 := NewMockModelWithWindow(&win2) m2.SetLastFind("X", true, true) FindChar{Char: "X", Forward: true, Inclusive: true, Count: 3, Repeated: true}.Execute(m2) counted := m2.ActiveWindow().Cursor.Col if threeSemicolons != counted { t.Errorf("3; landed at %d but three ; presses landed at %d", counted, threeSemicolons) } }) // ---- 2; after F (backward inclusive) ---- // X at 1,3,5,7. Cursor at col 7 (after `FX`). // ; from 7: start at 6, find X at 5 → land 5. // ; from 5: start at 4, find X at 3 → land 3. // 2; from 7: count=2, start at 6: X at 5 (count--), X at 3 → land 3. ✓ t.Run("2; after F: lands same as two ; presses (inclusive backward)", func(t *testing.T) { line := "aXbXcXdXe" buf := core.NewBufferBuilder().WithLines([]string{line}).Build() win1 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 7).Build() m1 := NewMockModelWithWindow(&win1) m1.SetLastFind("X", false, true) FindChar{Char: "X", Forward: false, Inclusive: true, Count: 1, Repeated: true}.Execute(m1) FindChar{Char: "X", Forward: false, Inclusive: true, Count: 1, Repeated: true}.Execute(m1) twoSemicolons := m1.ActiveWindow().Cursor.Col win2 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 7).Build() m2 := NewMockModelWithWindow(&win2) m2.SetLastFind("X", false, true) FindChar{Char: "X", Forward: false, Inclusive: true, Count: 2, Repeated: true}.Execute(m2) counted := m2.ActiveWindow().Cursor.Col if twoSemicolons != counted { t.Errorf("2; after F landed at %d but two ; presses landed at %d", counted, twoSemicolons) } }) // ---- 2; after t (forward exclusive) ---- // Line "aXbXcXdXe", X at 1,3,5,7. // `tX` from col 0 → land at col 0 (x=1, x-1=0, cursor stays put — adjacent). // ; from 0: forwardStart=0+2=2, find X at 3 → land at 2. // ; from 2: forwardStart=2+2=4, find X at 5 → land at 4. // 2; from 0: count=2, forwardStart=2: X at 3 (count--), X at 5 → land at 4. ✓ t.Run("2; after t: lands same as two ; presses (exclusive forward)", func(t *testing.T) { line := "aXbXcXdXe" buf := core.NewBufferBuilder().WithLines([]string{line}).Build() win1 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 0).Build() m1 := NewMockModelWithWindow(&win1) m1.SetLastFind("X", true, false) FindChar{Char: "X", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m1) FindChar{Char: "X", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m1) twoSemicolons := m1.ActiveWindow().Cursor.Col win2 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 0).Build() m2 := NewMockModelWithWindow(&win2) m2.SetLastFind("X", true, false) FindChar{Char: "X", Forward: true, Inclusive: false, Count: 2, Repeated: true}.Execute(m2) counted := m2.ActiveWindow().Cursor.Col if twoSemicolons != counted { t.Errorf("2; after t landed at %d but two ; presses landed at %d", counted, twoSemicolons) } }) t.Run("3; after t: lands same as three ; presses (exclusive forward)", func(t *testing.T) { line := "aXbXcXdXe" buf := core.NewBufferBuilder().WithLines([]string{line}).Build() win1 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 0).Build() m1 := NewMockModelWithWindow(&win1) m1.SetLastFind("X", true, false) FindChar{Char: "X", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m1) FindChar{Char: "X", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m1) FindChar{Char: "X", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m1) threeSemicolons := m1.ActiveWindow().Cursor.Col win2 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 0).Build() m2 := NewMockModelWithWindow(&win2) m2.SetLastFind("X", true, false) FindChar{Char: "X", Forward: true, Inclusive: false, Count: 3, Repeated: true}.Execute(m2) counted := m2.ActiveWindow().Cursor.Col if threeSemicolons != counted { t.Errorf("3; after t landed at %d but three ; presses landed at %d", counted, threeSemicolons) } }) // ---- 2; after T (backward exclusive) ---- // Line "aXbXcXdXe", X at 1,3,5,7. Cursor at col 8 (after `TX`, x=7, land x+1=8). // ; from 8: backwardStart=8-2=6, find X at 5 → land at 6. // ; from 6: backwardStart=6-2=4, find X at 3 → land at 4. // 2; from 8: count=2, backwardStart=6: X at 5 (count--), X at 3 → land at 4. ✓ t.Run("2; after T: lands same as two ; presses (exclusive backward)", func(t *testing.T) { line := "aXbXcXdXe" buf := core.NewBufferBuilder().WithLines([]string{line}).Build() win1 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 8).Build() m1 := NewMockModelWithWindow(&win1) m1.SetLastFind("X", false, false) FindChar{Char: "X", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m1) FindChar{Char: "X", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m1) twoSemicolons := m1.ActiveWindow().Cursor.Col win2 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 8).Build() m2 := NewMockModelWithWindow(&win2) m2.SetLastFind("X", false, false) FindChar{Char: "X", Forward: false, Inclusive: false, Count: 2, Repeated: true}.Execute(m2) counted := m2.ActiveWindow().Cursor.Col if twoSemicolons != counted { t.Errorf("2; after T landed at %d but two ; presses landed at %d", counted, twoSemicolons) } }) t.Run("3; after T: lands same as three ; presses (exclusive backward)", func(t *testing.T) { line := "aXbXcXdXe" buf := core.NewBufferBuilder().WithLines([]string{line}).Build() win1 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 8).Build() m1 := NewMockModelWithWindow(&win1) m1.SetLastFind("X", false, false) FindChar{Char: "X", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m1) FindChar{Char: "X", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m1) FindChar{Char: "X", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m1) threeSemicolons := m1.ActiveWindow().Cursor.Col win2 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 8).Build() m2 := NewMockModelWithWindow(&win2) m2.SetLastFind("X", false, false) FindChar{Char: "X", Forward: false, Inclusive: false, Count: 3, Repeated: true}.Execute(m2) counted := m2.ActiveWindow().Cursor.Col if threeSemicolons != counted { t.Errorf("3; after T landed at %d but three ; presses landed at %d", counted, threeSemicolons) } }) } // -------------------------------------------------- // , after f (reverse of forward-inclusive = backward-inclusive) // Reverse: true causes Forward XOR true → backward search, still inclusive. // -------------------------------------------------- func TestRepeatFind_Comma_After_f(t *testing.T) { // `fo` col 0 → col 4. `,` reverses: backward inclusive from col 4. // The search is NOT repeated/exclusive, so it starts at col-1=3. // No 'o' before col 4 going left → cursor stays at 4. // // But wait: `fo` stored Forward=true. Resolve with Reverse=true → Forward=false. // Repeated=true, Inclusive=true → backwardStart = col-1 (no extra skip for inclusive). // Starting at col 3 going left through "hell" — no 'o' → no move. t.Run("no previous match after fo: cursor stays", func(t *testing.T) { buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 4).Build() m := NewMockModelWithWindow(&win) // Simulate: lastFind was set by `fo` m.SetLastFind("o", true, true) // , = RepeatFind with Reverse=true resolved to backward-inclusive FindChar{Char: "o", Forward: false, Inclusive: true, Count: 1, Repeated: true}.Execute(m) if m.ActiveWindow().Cursor.Col != 4 { t.Errorf("col = %d, want 4 (no move)", m.ActiveWindow().Cursor.Col) } }) // `fo` from col 0 → col 4, `;` → col 7. // `,` from col 7: backward inclusive → should find 'o' at col 4. t.Run("after ;, comma returns to previous match", func(t *testing.T) { buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 7).Build() m := NewMockModelWithWindow(&win) m.SetLastFind("o", true, true) // , reversed → backward inclusive from col 7, start at col 6: finds 'o' at 4 FindChar{Char: "o", Forward: false, Inclusive: true, Count: 1, Repeated: true}.Execute(m) if m.ActiveWindow().Cursor.Col != 4 { t.Errorf("col = %d, want 4", m.ActiveWindow().Cursor.Col) } }) } // -------------------------------------------------- // , after F (reverse of backward-inclusive = forward-inclusive) // -------------------------------------------------- func TestRepeatFind_Comma_After_F(t *testing.T) { // `Fo` from col 10 → col 7. `,` reverses: forward inclusive from col 7. // Start at col+1=8: finds nothing ('r','l','d' — no 'o') → no move. t.Run("no further match forward: cursor stays", func(t *testing.T) { buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 7).Build() m := NewMockModelWithWindow(&win) m.SetLastFind("o", false, true) // , reversed → forward inclusive FindChar{Char: "o", Forward: true, Inclusive: true, Count: 1, Repeated: true}.Execute(m) if m.ActiveWindow().Cursor.Col != 7 { t.Errorf("col = %d, want 7 (no move)", m.ActiveWindow().Cursor.Col) } }) // `Fo` col 10 → col 7 → `;` → col 4. `,` from col 4: forward → finds 'o' at 7. t.Run("after ;, comma returns forward to next match", func(t *testing.T) { buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 4).Build() m := NewMockModelWithWindow(&win) m.SetLastFind("o", false, true) // , reversed → forward inclusive from col 4, start at col 5: finds 'o' at 7 FindChar{Char: "o", Forward: true, Inclusive: true, Count: 1, Repeated: true}.Execute(m) if m.ActiveWindow().Cursor.Col != 7 { t.Errorf("col = %d, want 7", m.ActiveWindow().Cursor.Col) } }) } // -------------------------------------------------- // , after t (reverse of forward-exclusive = backward-exclusive) // Inclusive=false and Repeated=true → backwardStart = col-2. // -------------------------------------------------- func TestRepeatFind_Comma_After_t(t *testing.T) { // `to` col 0 → col 3. `,` reverses: backward exclusive from col 3. // backwardStart = 3-2 = 1. Searching left from 1: no 'o' in "hel" → no move. t.Run("no previous exclusive match: cursor stays", func(t *testing.T) { buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 3).Build() m := NewMockModelWithWindow(&win) m.SetLastFind("o", true, false) // , reversed → backward exclusive, repeated FindChar{Char: "o", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m) if m.ActiveWindow().Cursor.Col != 3 { t.Errorf("col = %d, want 3 (no move)", m.ActiveWindow().Cursor.Col) } }) // `to` col 0 → col 3, `;` → col 6. // `,` from col 6: backward exclusive → backwardStart=4, finds 'o' at 4 → land at 5. t.Run("after ;, comma goes backward exclusive", func(t *testing.T) { buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 6).Build() m := NewMockModelWithWindow(&win) m.SetLastFind("o", true, false) FindChar{Char: "o", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m) if m.ActiveWindow().Cursor.Col != 5 { t.Errorf("col = %d, want 5", m.ActiveWindow().Cursor.Col) } }) } // -------------------------------------------------- // , after T (reverse of backward-exclusive = forward-exclusive) // Inclusive=false and Repeated=true → forwardStart = col+2. // -------------------------------------------------- func TestRepeatFind_Comma_After_T(t *testing.T) { // `To` col 10 → col 8. `,` reverses: forward exclusive from col 8. // forwardStart = 8+2 = 10: line[10]='d' ≠ 'o' → no more 'o' → no move. t.Run("no further exclusive match forward: cursor stays", func(t *testing.T) { buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 8).Build() m := NewMockModelWithWindow(&win) m.SetLastFind("o", false, false) // , reversed → forward exclusive, repeated FindChar{Char: "o", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m) if m.ActiveWindow().Cursor.Col != 8 { t.Errorf("col = %d, want 8 (no move)", m.ActiveWindow().Cursor.Col) } }) // `To` col 10 → col 8. `;` → col 5. `,` from col 5: forward exclusive. // forwardStart = 5+2 = 7: line[7]='o' → land at 6. t.Run("after ;, comma goes forward exclusive", func(t *testing.T) { buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 5).Build() m := NewMockModelWithWindow(&win) m.SetLastFind("o", false, false) FindChar{Char: "o", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m) if m.ActiveWindow().Cursor.Col != 6 { t.Errorf("col = %d, want 6", m.ActiveWindow().Cursor.Col) } }) } // -------------------------------------------------- // RepeatFind.Resolve — unit tests for the Resolve helper // -------------------------------------------------- func TestRepeatFind_Resolve(t *testing.T) { makeMock := func(char string, forward, inclusive bool) *MockModel { buf := core.NewBufferBuilder().WithLines([]string{"x"}).Build() win := core.NewWindowBuilder().WithBuffer(&buf).Build() m := NewMockModelWithWindow(&win) m.SetLastFind(char, forward, inclusive) return m } t.Run("; after f: Forward=true Reverse=false → Forward=true Inclusive=true", func(t *testing.T) { m := makeMock("o", true, true) fc := RepeatFind{Count: 1, Reverse: false}.Resolve(m).(FindChar) if !fc.Forward || !fc.Inclusive || fc.Char != "o" || !fc.Repeated { t.Errorf("unexpected resolved FindChar: %+v", fc) } }) t.Run(", after f: Forward=true Reverse=true → Forward=false Inclusive=true", func(t *testing.T) { m := makeMock("o", true, true) fc := RepeatFind{Count: 1, Reverse: true}.Resolve(m).(FindChar) if fc.Forward || !fc.Inclusive || fc.Char != "o" || !fc.Repeated { t.Errorf("unexpected resolved FindChar: %+v", fc) } }) t.Run("; after F: Forward=false Reverse=false → Forward=false Inclusive=true", func(t *testing.T) { m := makeMock("o", false, true) fc := RepeatFind{Count: 1, Reverse: false}.Resolve(m).(FindChar) if fc.Forward || !fc.Inclusive || fc.Char != "o" || !fc.Repeated { t.Errorf("unexpected resolved FindChar: %+v", fc) } }) t.Run(", after F: Forward=false Reverse=true → Forward=true Inclusive=true", func(t *testing.T) { m := makeMock("o", false, true) fc := RepeatFind{Count: 1, Reverse: true}.Resolve(m).(FindChar) if !fc.Forward || !fc.Inclusive || fc.Char != "o" || !fc.Repeated { t.Errorf("unexpected resolved FindChar: %+v", fc) } }) t.Run("; after t: Forward=true Reverse=false → Forward=true Inclusive=false", func(t *testing.T) { m := makeMock("o", true, false) fc := RepeatFind{Count: 1, Reverse: false}.Resolve(m).(FindChar) if !fc.Forward || fc.Inclusive || fc.Char != "o" || !fc.Repeated { t.Errorf("unexpected resolved FindChar: %+v", fc) } }) t.Run(", after t: Forward=true Reverse=true → Forward=false Inclusive=false", func(t *testing.T) { m := makeMock("o", true, false) fc := RepeatFind{Count: 1, Reverse: true}.Resolve(m).(FindChar) if fc.Forward || fc.Inclusive || fc.Char != "o" || !fc.Repeated { t.Errorf("unexpected resolved FindChar: %+v", fc) } }) t.Run("; after T: Forward=false Reverse=false → Forward=false Inclusive=false", func(t *testing.T) { m := makeMock("o", false, false) fc := RepeatFind{Count: 1, Reverse: false}.Resolve(m).(FindChar) if fc.Forward || fc.Inclusive || fc.Char != "o" || !fc.Repeated { t.Errorf("unexpected resolved FindChar: %+v", fc) } }) t.Run(", after T: Forward=false Reverse=true → Forward=true Inclusive=false", func(t *testing.T) { m := makeMock("o", false, false) fc := RepeatFind{Count: 1, Reverse: true}.Resolve(m).(FindChar) if !fc.Forward || fc.Inclusive || fc.Char != "o" || !fc.Repeated { t.Errorf("unexpected resolved FindChar: %+v", fc) } }) t.Run("Count is forwarded to resolved FindChar", func(t *testing.T) { m := makeMock("x", true, true) fc := RepeatFind{Count: 3, Reverse: false}.Resolve(m).(FindChar) if fc.Count != 3 { t.Errorf("Count = %d, want 3", fc.Count) } }) t.Run("Char is preserved from lastFind", func(t *testing.T) { m := makeMock("z", true, true) fc := RepeatFind{Count: 1, Reverse: false}.Resolve(m).(FindChar) if fc.Char != "z" { t.Errorf("Char = %q, want %q", fc.Char, "z") } }) } // -------------------------------------------------- // RepeatFind — Type, WithCount, and Execute delegation // -------------------------------------------------- func TestRepeatFind_Type(t *testing.T) { t.Run("Type returns CharwiseExclusive (fallback)", func(t *testing.T) { rf := RepeatFind{Count: 1, Reverse: false} if rf.Type() != core.CharwiseExclusive { t.Errorf("Type() = %v, want CharwiseExclusive", rf.Type()) } }) } func TestRepeatFind_WithCount(t *testing.T) { t.Run("WithCount sets Count", func(t *testing.T) { rf := RepeatFind{Count: 1, Reverse: false} updated := rf.WithCount(5).(RepeatFind) if updated.Count != 5 { t.Errorf("Count = %d, want 5", updated.Count) } }) t.Run("WithCount preserves Reverse", func(t *testing.T) { rf := RepeatFind{Count: 1, Reverse: true} updated := rf.WithCount(2).(RepeatFind) if !updated.Reverse { t.Error("Reverse should be preserved as true") } }) } func TestRepeatFind_Execute(t *testing.T) { // Execute should delegate to Resolve(m).Execute(m) and actually move the cursor. t.Run("Execute via ; after f moves cursor correctly", func(t *testing.T) { buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 4).Build() m := NewMockModelWithWindow(&win) m.SetLastFind("o", true, true) // ; = RepeatFind{Reverse: false} RepeatFind{Count: 1, Reverse: false}.Execute(m) if m.ActiveWindow().Cursor.Col != 7 { t.Errorf("col = %d, want 7", m.ActiveWindow().Cursor.Col) } }) t.Run("Execute via , after f reverses and moves cursor correctly", func(t *testing.T) { buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 7).Build() m := NewMockModelWithWindow(&win) m.SetLastFind("o", true, true) // , = RepeatFind{Reverse: true} RepeatFind{Count: 1, Reverse: true}.Execute(m) if m.ActiveWindow().Cursor.Col != 4 { t.Errorf("col = %d, want 4", m.ActiveWindow().Cursor.Col) } }) t.Run("Execute via ; after t skips adjacent and moves cursor", func(t *testing.T) { buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 3).Build() m := NewMockModelWithWindow(&win) m.SetLastFind("o", true, false) RepeatFind{Count: 1, Reverse: false}.Execute(m) if m.ActiveWindow().Cursor.Col != 6 { t.Errorf("col = %d, want 6", m.ActiveWindow().Cursor.Col) } }) t.Run("Execute via ; after T skips adjacent backward and moves cursor", func(t *testing.T) { buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 8).Build() m := NewMockModelWithWindow(&win) m.SetLastFind("o", false, false) RepeatFind{Count: 1, Reverse: false}.Execute(m) if m.ActiveWindow().Cursor.Col != 5 { t.Errorf("col = %d, want 5", m.ActiveWindow().Cursor.Col) } }) } // ================================================== // WithCount Tests // ================================================== func TestFindCharWithCount(t *testing.T) { t.Run("WithCount sets the count", func(t *testing.T) { action := FindChar{ Char: "a", Forward: true, Inclusive: true, } newAction := action.WithCount(5) findAction, ok := newAction.(FindChar) if !ok { t.Fatal("WithCount should return FindChar") } if findAction.Count != 5 { t.Errorf("Count = %d, want 5", findAction.Count) } }) t.Run("WithCount preserves other fields", func(t *testing.T) { action := FindChar{ Char: "x", Forward: false, Inclusive: false, } newAction := action.WithCount(2) findAction, ok := newAction.(FindChar) if !ok { t.Fatal("WithCount should return FindChar") } if findAction.Char != "x" { t.Errorf("Char = %q, want %q", findAction.Char, "x") } if findAction.Forward { t.Error("Forward should be preserved as false") } if findAction.Inclusive { t.Error("Inclusive should be preserved as false") } }) }