All checks were successful
Run Test Suite / test (push) Successful in 39s
There are some odd things being done in the testing files, that should get reviewed.
2646 lines
77 KiB
Go
2646 lines
77 KiB
Go
package action
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"git.gophernest.net/azpect/TextEditor/internal/core"
|
|
"git.gophernest.net/azpect/TextEditor/internal/style"
|
|
)
|
|
|
|
// ==================================================
|
|
// Mock Model Implementation
|
|
// ==================================================
|
|
|
|
type mockModel struct {
|
|
windows []*core.Window
|
|
activeWindow *core.Window
|
|
buffers []*core.Buffer
|
|
settings core.EditorSettings
|
|
mode core.Mode
|
|
registers map[rune]core.Register
|
|
insertKeys []string
|
|
command string
|
|
commandCursor int
|
|
commandOutput *core.CommandOutput
|
|
lastFind core.LastFindCommand
|
|
styles style.Styles
|
|
}
|
|
|
|
func newMockModel() *mockModel {
|
|
buf := core.NewBufferBuilder().
|
|
WithLines([]string{""}).
|
|
Build()
|
|
|
|
win := core.NewWindowBuilder().
|
|
WithBuffer(&buf).
|
|
WithHeight(24).
|
|
WithWidth(80).
|
|
Build()
|
|
|
|
return &mockModel{
|
|
windows: []*core.Window{&win},
|
|
activeWindow: &win,
|
|
buffers: []*core.Buffer{&buf},
|
|
settings: core.NewDefaultSettings(),
|
|
mode: core.NormalMode,
|
|
registers: core.DefaultRegisters(),
|
|
}
|
|
}
|
|
|
|
func newMockModelWithBuffer(buf *core.Buffer) *mockModel {
|
|
win := core.NewWindowBuilder().
|
|
WithBuffer(buf).
|
|
WithHeight(24).
|
|
WithWidth(80).
|
|
Build()
|
|
|
|
return &mockModel{
|
|
windows: []*core.Window{&win},
|
|
activeWindow: &win,
|
|
buffers: []*core.Buffer{buf},
|
|
settings: core.NewDefaultSettings(),
|
|
mode: core.NormalMode,
|
|
registers: core.DefaultRegisters(),
|
|
}
|
|
}
|
|
|
|
func newMockModelWithWindow(win *core.Window) *mockModel {
|
|
return &mockModel{
|
|
windows: []*core.Window{win},
|
|
activeWindow: win,
|
|
buffers: []*core.Buffer{win.Buffer},
|
|
settings: core.NewDefaultSettings(),
|
|
mode: core.NormalMode,
|
|
registers: core.DefaultRegisters(),
|
|
}
|
|
}
|
|
|
|
// Core Data Access
|
|
func (m *mockModel) Windows() []*core.Window { return m.windows }
|
|
func (m *mockModel) ActiveWindow() *core.Window { return m.activeWindow }
|
|
func (m *mockModel) Buffers() []*core.Buffer { return m.buffers }
|
|
func (m *mockModel) SetBuffers(bufs []*core.Buffer) { m.buffers = bufs }
|
|
func (m *mockModel) ActiveBuffer() *core.Buffer { return m.activeWindow.Buffer }
|
|
|
|
// Insert Mode State
|
|
func (m *mockModel) InsertKeys() []string { return m.insertKeys }
|
|
func (m *mockModel) SetInsertKeys(keys []string) { m.insertKeys = keys }
|
|
func (m *mockModel) SetInsertRecording(count int, action Action) {}
|
|
func (m *mockModel) ExitInsertMode() {}
|
|
func (m *mockModel) SetLastFind(char string, forward, inclusive bool) {
|
|
m.lastFind = core.LastFindCommand{Char: char, Forward: forward, Inclusive: inclusive}
|
|
}
|
|
func (m *mockModel) GetLastFind() *core.LastFindCommand { return &m.lastFind }
|
|
|
|
// Command Mode State
|
|
func (m *mockModel) Command() string { return m.command }
|
|
func (m *mockModel) SetCommand(cmd string) { m.command = cmd }
|
|
func (m *mockModel) CommandCursor() int { return m.commandCursor }
|
|
func (m *mockModel) SetCommandCursor(cur int) { m.commandCursor = cur }
|
|
func (m *mockModel) CommandOutput() *core.CommandOutput { return m.commandOutput }
|
|
func (m *mockModel) SetCommandOutput(out *core.CommandOutput) { m.commandOutput = out }
|
|
|
|
// Editor-wide State
|
|
func (m *mockModel) Mode() core.Mode { return m.mode }
|
|
func (m *mockModel) SetMode(mode core.Mode) { m.mode = mode }
|
|
func (m *mockModel) Settings() core.EditorSettings { return m.settings }
|
|
func (m *mockModel) SetSettings(s core.EditorSettings) { m.settings = s }
|
|
func (m *mockModel) Styles() style.Styles { return m.styles }
|
|
func (m *mockModel) SetStyles(s style.Styles) { m.styles = s }
|
|
|
|
// Registers
|
|
func (m *mockModel) Registers() map[rune]core.Register { return m.registers }
|
|
func (m *mockModel) GetRegister(name rune) (core.Register, bool) {
|
|
reg, ok := m.registers[name]
|
|
return reg, ok
|
|
}
|
|
func (m *mockModel) SetRegister(name rune, t core.RegisterType, cnt []string) error {
|
|
m.registers[name] = core.Register{Type: t, Content: cnt}
|
|
return nil
|
|
}
|
|
func (m *mockModel) UpdateDefaultRegister(t core.RegisterType, cnt []string) {
|
|
m.registers['"'] = core.Register{Type: t, Content: cnt}
|
|
}
|
|
|
|
// ==================================================
|
|
// 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")
|
|
}
|
|
})
|
|
}
|