Compare commits

..

2 Commits

Author SHA1 Message Date
Hayden Hargreaves
a01369f407 fix: cleaned up the testing mocking. New single module
All checks were successful
Run Test Suite / test (push) Successful in 42s
2026-03-19 17:31:53 -07:00
Hayden Hargreaves
3c98dca777 feat: implement command history, tested
The tests are starting to get messy, lots of duplication. Going to
resolve that. Lots of this is due to AI generation of tests.
2026-03-19 15:23:44 -07:00
13 changed files with 1715 additions and 1181 deletions

View File

@ -11,6 +11,7 @@ type ExitCommandMode struct{}
// ExitCommandMode.Execute: Exits command mode and returns to normal mode (Esc key).
func (a ExitCommandMode) Execute(m Model) tea.Cmd {
m.SetCommandCursor(0)
m.SetCommandHistoryCursor(0)
m.SetCommand("")
m.SetCommandOutput(&core.CommandOutput{})
m.SetMode(core.NormalMode)
@ -127,12 +128,17 @@ func (a CommandExecute) Execute(m Model) tea.Cmd {
// Clear command state and return to normal mode
m.SetCommandCursor(0)
m.SetCommandHistoryCursor(0)
m.SetMode(core.NormalMode)
if a.Registry == nil || cmdLine == "" {
return nil
}
history := append([]string{cmdLine}, m.CommandHistory()...)
// history = append([]string{cmdLine}, history...)
m.SetCommandHistory(history)
cmd, err := a.Registry.Execute(m, cmdLine)
if err != nil {
out := core.CommandOutput{

View File

@ -0,0 +1,133 @@
package action
import (
"testing"
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea"
)
// mockRegistry for testing command execution without actual command handlers
type mockRegistry struct{}
func (r *mockRegistry) Execute(m Model, cmdLine string) (tea.Cmd, error) {
// Mock implementation - just returns nil
return nil, nil
}
// TestCommandExecuteUpdatesHistory tests that executing commands adds them to history
func TestCommandExecuteUpdatesHistory(t *testing.T) {
t.Run("first command added to empty history", func(t *testing.T) {
m := NewMockModel()
m.ModeVal = core.CommandMode
m.CommandVal = "w test.txt"
m.CommandHistoryList = []string{}
m.CommandHistoryCur = 0
registry := &mockRegistry{}
action := CommandExecute{Registry: registry}
action.Execute(m)
history := m.CommandHistory()
if len(history) != 1 {
t.Errorf("History length = %d, want 1", len(history))
}
if history[0] != "w test.txt" {
t.Errorf("History[0] = %q, want %q", history[0], "w test.txt")
}
})
t.Run("multiple commands prepended to history", func(t *testing.T) {
m := NewMockModel()
m.ModeVal = core.CommandMode
m.CommandVal = "w file1.txt"
m.CommandHistoryList = []string{}
m.CommandHistoryCur = 0
registry := &mockRegistry{}
action := CommandExecute{Registry: registry}
// Execute first command
action.Execute(m)
// Execute second command
m.CommandVal = "q"
action.Execute(m)
// Execute third command
m.CommandVal = "set number"
action.Execute(m)
history := m.CommandHistory()
if len(history) != 3 {
t.Errorf("History length = %d, want 3", len(history))
}
// Most recent should be first (prepended)
want := []string{"set number", "q", "w file1.txt"}
for i, cmd := range want {
if history[i] != cmd {
t.Errorf("History[%d] = %q, want %q", i, history[i], cmd)
}
}
})
t.Run("empty command not added to history", func(t *testing.T) {
m := NewMockModel()
m.ModeVal = core.CommandMode
m.CommandVal = ""
m.CommandHistoryList = []string{"previous command"}
m.CommandHistoryCur = 0
registry := &mockRegistry{}
action := CommandExecute{Registry: registry}
action.Execute(m)
history := m.CommandHistory()
if len(history) != 1 {
t.Errorf("History length = %d, want 1 (empty command should not be added)", len(history))
}
if history[0] != "previous command" {
t.Errorf("History[0] = %q, want %q", history[0], "previous command")
}
})
t.Run("history cursor resets on execute", func(t *testing.T) {
m := NewMockModel()
m.ModeVal = core.CommandMode
m.CommandVal = "w"
m.CommandHistoryList = []string{}
m.CommandHistoryCur = 5 // Set to non-zero
registry := &mockRegistry{}
action := CommandExecute{Registry: registry}
action.Execute(m)
if m.CommandHistoryCursor() != 0 {
t.Errorf("CommandHistoryCursor = %d, want 0 after execute", m.CommandHistoryCursor())
}
})
t.Run("duplicate commands are added to history", func(t *testing.T) {
m := NewMockModel()
m.ModeVal = core.CommandMode
m.CommandVal = "w"
m.CommandHistoryList = []string{"w"}
m.CommandHistoryCur = 0
registry := &mockRegistry{}
action := CommandExecute{Registry: registry}
action.Execute(m)
history := m.CommandHistory()
if len(history) != 2 {
t.Errorf("History length = %d, want 2 (duplicates should be added)", len(history))
}
if history[0] != "w" || history[1] != "w" {
t.Errorf("History = %v, want ['w', 'w']", history)
}
})
}

View File

@ -4,124 +4,8 @@ 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
// ==================================================
@ -137,7 +21,7 @@ func TestFindCharForward(t *testing.T) {
WithCursorPos(0, 0). // At 'h'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "o",
@ -164,7 +48,7 @@ func TestFindCharForward(t *testing.T) {
WithCursorPos(0, 4). // At first 'o'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "o",
@ -191,7 +75,7 @@ func TestFindCharForward(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "x",
@ -218,7 +102,7 @@ func TestFindCharForward(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "d",
@ -245,7 +129,7 @@ func TestFindCharForward(t *testing.T) {
WithCursorPos(0, 5). // At space after 'hello'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "o",
@ -272,7 +156,7 @@ func TestFindCharForward(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "a",
@ -299,7 +183,7 @@ func TestFindCharForward(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "a",
@ -326,7 +210,7 @@ func TestFindCharForward(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: " ",
@ -353,7 +237,7 @@ func TestFindCharForward(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "b",
@ -386,7 +270,7 @@ func TestFindCharBackward(t *testing.T) {
WithCursorPos(0, 10). // At 'd'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "o",
@ -413,7 +297,7 @@ func TestFindCharBackward(t *testing.T) {
WithCursorPos(0, 7). // At 'o' in 'world'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "o",
@ -440,7 +324,7 @@ func TestFindCharBackward(t *testing.T) {
WithCursorPos(0, 10).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "x",
@ -467,7 +351,7 @@ func TestFindCharBackward(t *testing.T) {
WithCursorPos(0, 10).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "h",
@ -494,7 +378,7 @@ func TestFindCharBackward(t *testing.T) {
WithCursorPos(0, 6). // At 'w'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "o",
@ -521,7 +405,7 @@ func TestFindCharBackward(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "a",
@ -548,7 +432,7 @@ func TestFindCharBackward(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "a",
@ -575,7 +459,7 @@ func TestFindCharBackward(t *testing.T) {
WithCursorPos(0, 10).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: " ",
@ -602,7 +486,7 @@ func TestFindCharBackward(t *testing.T) {
WithCursorPos(0, 9). // At last 'b'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "a",
@ -635,7 +519,7 @@ func TestTillCharForward(t *testing.T) {
WithCursorPos(0, 0). // At 'h'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "o",
@ -662,7 +546,7 @@ func TestTillCharForward(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "x",
@ -689,7 +573,7 @@ func TestTillCharForward(t *testing.T) {
WithCursorPos(0, 0). // At 'a'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "b",
@ -717,7 +601,7 @@ func TestTillCharForward(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "d",
@ -744,7 +628,7 @@ func TestTillCharForward(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "a",
@ -771,7 +655,7 @@ func TestTillCharForward(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: " ",
@ -798,7 +682,7 @@ func TestTillCharForward(t *testing.T) {
WithCursorPos(0, 5). // At space
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "o",
@ -831,7 +715,7 @@ func TestTillCharBackward(t *testing.T) {
WithCursorPos(0, 10). // At 'd'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "o",
@ -858,7 +742,7 @@ func TestTillCharBackward(t *testing.T) {
WithCursorPos(0, 10).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "x",
@ -885,7 +769,7 @@ func TestTillCharBackward(t *testing.T) {
WithCursorPos(0, 1). // At 'b'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "a",
@ -913,7 +797,7 @@ func TestTillCharBackward(t *testing.T) {
WithCursorPos(0, 10).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "h",
@ -940,7 +824,7 @@ func TestTillCharBackward(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "a",
@ -967,7 +851,7 @@ func TestTillCharBackward(t *testing.T) {
WithCursorPos(0, 10).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: " ",
@ -994,7 +878,7 @@ func TestTillCharBackward(t *testing.T) {
WithCursorPos(0, 6). // At 'w'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "o",
@ -1021,7 +905,7 @@ func TestTillCharBackward(t *testing.T) {
WithCursorPos(0, 9). // At last 'b'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "a",
@ -1054,7 +938,7 @@ func TestFindCharForwardWithCount(t *testing.T) {
WithCursorPos(0, 0). // At 'h'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "l",
@ -1085,7 +969,7 @@ func TestFindCharForwardWithCount(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "l",
@ -1116,7 +1000,7 @@ func TestFindCharForwardWithCount(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "l",
@ -1143,7 +1027,7 @@ func TestFindCharForwardWithCount(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "w",
@ -1170,7 +1054,7 @@ func TestFindCharForwardWithCount(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "a",
@ -1198,7 +1082,7 @@ func TestFindCharForwardWithCount(t *testing.T) {
WithCursorPos(0, 4). // At first 'b'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "a",
@ -1235,7 +1119,7 @@ func TestFindCharBackwardWithCount(t *testing.T) {
WithCursorPos(0, 10). // At 'd'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "l",
@ -1266,7 +1150,7 @@ func TestFindCharBackwardWithCount(t *testing.T) {
WithCursorPos(0, 10).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "l",
@ -1298,7 +1182,7 @@ func TestFindCharBackwardWithCount(t *testing.T) {
WithCursorPos(0, 10).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "l",
@ -1325,7 +1209,7 @@ func TestFindCharBackwardWithCount(t *testing.T) {
WithCursorPos(0, 10). // At last 'b'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "b",
@ -1353,7 +1237,7 @@ func TestFindCharBackwardWithCount(t *testing.T) {
WithCursorPos(0, 6). // At middle 'b'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "a",
@ -1390,7 +1274,7 @@ func TestTillCharForwardWithCount(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "l",
@ -1417,7 +1301,7 @@ func TestTillCharForwardWithCount(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "l",
@ -1444,7 +1328,7 @@ func TestTillCharForwardWithCount(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "l",
@ -1471,7 +1355,7 @@ func TestTillCharForwardWithCount(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "a",
@ -1505,7 +1389,7 @@ func TestTillCharBackwardWithCount(t *testing.T) {
WithCursorPos(0, 10).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "l",
@ -1532,7 +1416,7 @@ func TestTillCharBackwardWithCount(t *testing.T) {
WithCursorPos(0, 10).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "l",
@ -1559,7 +1443,7 @@ func TestTillCharBackwardWithCount(t *testing.T) {
WithCursorPos(0, 10).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "l",
@ -1586,7 +1470,7 @@ func TestTillCharBackwardWithCount(t *testing.T) {
WithCursorPos(0, 10).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "b",
@ -1725,7 +1609,7 @@ func TestRepeatFind_Semicolon_After_f(t *testing.T) {
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 := NewMockModelWithWindow(&win)
m.SetLastFind("o", true, true)
FindChar{Char: "o", Forward: true, Inclusive: true, Count: 1, Repeated: true}.Execute(m)
@ -1739,7 +1623,7 @@ func TestRepeatFind_Semicolon_After_f(t *testing.T) {
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 := NewMockModelWithWindow(&win)
m.SetLastFind("o", true, true)
FindChar{Char: "o", Forward: true, Inclusive: true, Count: 1, Repeated: true}.Execute(m)
@ -1753,7 +1637,7 @@ func TestRepeatFind_Semicolon_After_f(t *testing.T) {
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 := NewMockModelWithWindow(&win)
m.SetLastFind("o", true, true)
FindChar{Char: "o", Forward: true, Inclusive: true, Count: 1, Repeated: true}.Execute(m)
@ -1769,7 +1653,7 @@ func TestRepeatFind_Semicolon_After_f(t *testing.T) {
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 := NewMockModelWithWindow(&win)
m.SetLastFind("X", true, true)
FindChar{Char: "X", Forward: true, Inclusive: true, Count: 2, Repeated: true}.Execute(m)
@ -1783,7 +1667,7 @@ func TestRepeatFind_Semicolon_After_f(t *testing.T) {
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 := NewMockModelWithWindow(&win)
m.SetLastFind("o", true, true)
FindChar{Char: "o", Forward: true, Inclusive: true, Count: 1, Repeated: true}.Execute(m)
@ -1804,7 +1688,7 @@ func TestRepeatFind_Semicolon_After_F(t *testing.T) {
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 := NewMockModelWithWindow(&win)
m.SetLastFind("o", false, true)
FindChar{Char: "o", Forward: false, Inclusive: true, Count: 1, Repeated: true}.Execute(m)
@ -1818,7 +1702,7 @@ func TestRepeatFind_Semicolon_After_F(t *testing.T) {
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 := NewMockModelWithWindow(&win)
m.SetLastFind("o", false, true)
FindChar{Char: "o", Forward: false, Inclusive: true, Count: 1, Repeated: true}.Execute(m)
@ -1832,7 +1716,7 @@ func TestRepeatFind_Semicolon_After_F(t *testing.T) {
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 := NewMockModelWithWindow(&win)
m.SetLastFind("o", false, true)
FindChar{Char: "o", Forward: false, Inclusive: true, Count: 1, Repeated: true}.Execute(m)
@ -1847,7 +1731,7 @@ func TestRepeatFind_Semicolon_After_F(t *testing.T) {
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 := NewMockModelWithWindow(&win)
m.SetLastFind("X", false, true)
FindChar{Char: "X", Forward: false, Inclusive: true, Count: 2, Repeated: true}.Execute(m)
@ -1870,7 +1754,7 @@ func TestRepeatFind_Semicolon_After_t(t *testing.T) {
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 := NewMockModelWithWindow(&win)
m.SetLastFind("o", true, false)
FindChar{Char: "o", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m)
@ -1885,7 +1769,7 @@ func TestRepeatFind_Semicolon_After_t(t *testing.T) {
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 := NewMockModelWithWindow(&win)
m.SetLastFind("o", true, false)
FindChar{Char: "o", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m)
@ -1901,7 +1785,7 @@ func TestRepeatFind_Semicolon_After_t(t *testing.T) {
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 := NewMockModelWithWindow(&win)
m.SetLastFind("o", true, false)
FindChar{Char: "o", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m)
@ -1918,7 +1802,7 @@ func TestRepeatFind_Semicolon_After_t(t *testing.T) {
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 := NewMockModelWithWindow(&win)
m.SetLastFind("X", true, false)
// First repeat
@ -1946,7 +1830,7 @@ func TestRepeatFind_Semicolon_After_t(t *testing.T) {
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 := NewMockModelWithWindow(&win)
m.SetLastFind("X", true, false)
FindChar{Char: "X", Forward: true, Inclusive: false, Count: 2, Repeated: true}.Execute(m)
@ -1968,7 +1852,7 @@ func TestRepeatFind_Semicolon_After_T(t *testing.T) {
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 := NewMockModelWithWindow(&win)
m.SetLastFind("o", false, false)
FindChar{Char: "o", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m)
@ -1982,7 +1866,7 @@ func TestRepeatFind_Semicolon_After_T(t *testing.T) {
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 := NewMockModelWithWindow(&win)
m.SetLastFind("o", false, false)
FindChar{Char: "o", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m)
@ -1997,7 +1881,7 @@ func TestRepeatFind_Semicolon_After_T(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 := NewMockModelWithWindow(&win)
m.SetLastFind("X", false, false)
FindChar{Char: "X", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m)
@ -2015,7 +1899,7 @@ func TestRepeatFind_Semicolon_After_T(t *testing.T) {
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 := NewMockModelWithWindow(&win)
m.SetLastFind("X", false, false)
FindChar{Char: "X", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m)
@ -2041,7 +1925,7 @@ func TestRepeatFind_Semicolon_After_T(t *testing.T) {
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 := NewMockModelWithWindow(&win)
m.SetLastFind("X", false, false)
FindChar{Char: "X", Forward: false, Inclusive: false, Count: 2, Repeated: true}.Execute(m)
@ -2084,7 +1968,7 @@ func TestRepeatFind_CountedRepeats(t *testing.T) {
// Simulate with two sequential ; presses
win1 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 1).Build()
m1 := newMockModelWithWindow(&win1)
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)
@ -2092,7 +1976,7 @@ func TestRepeatFind_CountedRepeats(t *testing.T) {
// Simulate with a single 2; (Count=2)
win2 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 1).Build()
m2 := newMockModelWithWindow(&win2)
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
@ -2107,7 +1991,7 @@ func TestRepeatFind_CountedRepeats(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{line}).Build()
win1 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 1).Build()
m1 := newMockModelWithWindow(&win1)
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)
@ -2115,7 +1999,7 @@ func TestRepeatFind_CountedRepeats(t *testing.T) {
threeSemicolons := m1.ActiveWindow().Cursor.Col
win2 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 1).Build()
m2 := newMockModelWithWindow(&win2)
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
@ -2135,14 +2019,14 @@ func TestRepeatFind_CountedRepeats(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{line}).Build()
win1 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 7).Build()
m1 := newMockModelWithWindow(&win1)
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 := 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
@ -2163,14 +2047,14 @@ func TestRepeatFind_CountedRepeats(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{line}).Build()
win1 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 0).Build()
m1 := newMockModelWithWindow(&win1)
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 := 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
@ -2185,7 +2069,7 @@ func TestRepeatFind_CountedRepeats(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{line}).Build()
win1 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 0).Build()
m1 := newMockModelWithWindow(&win1)
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)
@ -2193,7 +2077,7 @@ func TestRepeatFind_CountedRepeats(t *testing.T) {
threeSemicolons := m1.ActiveWindow().Cursor.Col
win2 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 0).Build()
m2 := newMockModelWithWindow(&win2)
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
@ -2213,14 +2097,14 @@ func TestRepeatFind_CountedRepeats(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{line}).Build()
win1 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 8).Build()
m1 := newMockModelWithWindow(&win1)
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 := 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
@ -2235,7 +2119,7 @@ func TestRepeatFind_CountedRepeats(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{line}).Build()
win1 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 8).Build()
m1 := newMockModelWithWindow(&win1)
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)
@ -2243,7 +2127,7 @@ func TestRepeatFind_CountedRepeats(t *testing.T) {
threeSemicolons := m1.ActiveWindow().Cursor.Col
win2 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 8).Build()
m2 := newMockModelWithWindow(&win2)
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
@ -2270,7 +2154,7 @@ func TestRepeatFind_Comma_After_f(t *testing.T) {
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)
m := NewMockModelWithWindow(&win)
// Simulate: lastFind was set by `fo`
m.SetLastFind("o", true, true)
@ -2287,7 +2171,7 @@ func TestRepeatFind_Comma_After_f(t *testing.T) {
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 := NewMockModelWithWindow(&win)
m.SetLastFind("o", true, true)
// , reversed → backward inclusive from col 7, start at col 6: finds 'o' at 4
@ -2309,7 +2193,7 @@ func TestRepeatFind_Comma_After_F(t *testing.T) {
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 := NewMockModelWithWindow(&win)
m.SetLastFind("o", false, true)
// , reversed → forward inclusive
@ -2324,7 +2208,7 @@ func TestRepeatFind_Comma_After_F(t *testing.T) {
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 := NewMockModelWithWindow(&win)
m.SetLastFind("o", false, true)
// , reversed → forward inclusive from col 4, start at col 5: finds 'o' at 7
@ -2347,7 +2231,7 @@ func TestRepeatFind_Comma_After_t(t *testing.T) {
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 := NewMockModelWithWindow(&win)
m.SetLastFind("o", true, false)
// , reversed → backward exclusive, repeated
@ -2363,7 +2247,7 @@ func TestRepeatFind_Comma_After_t(t *testing.T) {
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 := NewMockModelWithWindow(&win)
m.SetLastFind("o", true, false)
FindChar{Char: "o", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m)
@ -2385,7 +2269,7 @@ func TestRepeatFind_Comma_After_T(t *testing.T) {
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 := NewMockModelWithWindow(&win)
m.SetLastFind("o", false, false)
// , reversed → forward exclusive, repeated
@ -2401,7 +2285,7 @@ func TestRepeatFind_Comma_After_T(t *testing.T) {
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 := NewMockModelWithWindow(&win)
m.SetLastFind("o", false, false)
FindChar{Char: "o", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m)
@ -2417,10 +2301,10 @@ func TestRepeatFind_Comma_After_T(t *testing.T) {
// --------------------------------------------------
func TestRepeatFind_Resolve(t *testing.T) {
makeMock := func(char string, forward, inclusive bool) *mockModel {
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 := NewMockModelWithWindow(&win)
m.SetLastFind(char, forward, inclusive)
return m
}
@ -2542,7 +2426,7 @@ func TestRepeatFind_Execute(t *testing.T) {
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 := NewMockModelWithWindow(&win)
m.SetLastFind("o", true, true)
// ; = RepeatFind{Reverse: false}
@ -2556,7 +2440,7 @@ func TestRepeatFind_Execute(t *testing.T) {
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 := NewMockModelWithWindow(&win)
m.SetLastFind("o", true, true)
// , = RepeatFind{Reverse: true}
@ -2570,7 +2454,7 @@ func TestRepeatFind_Execute(t *testing.T) {
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 := NewMockModelWithWindow(&win)
m.SetLastFind("o", true, false)
RepeatFind{Count: 1, Reverse: false}.Execute(m)
@ -2583,7 +2467,7 @@ func TestRepeatFind_Execute(t *testing.T) {
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 := NewMockModelWithWindow(&win)
m.SetLastFind("o", false, false)
RepeatFind{Count: 1, Reverse: false}.Execute(m)

View File

@ -41,6 +41,10 @@ type Model interface {
CommandOutput() *core.CommandOutput
// DO NOT FORGET TO CALL SetMode()
SetCommandOutput(out *core.CommandOutput)
CommandHistory() []string
SetCommandHistory(history []string)
CommandHistoryCursor() int
SetCommandHistoryCursor(cur int)
// ==================================================
// Editor-wide State

133
internal/action/mock.go Normal file
View File

@ -0,0 +1,133 @@
package action
import (
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/style"
)
// MockModel is a shared test implementation of the Model interface.
// Used by test files across multiple packages to avoid duplication.
// All fields are exported to allow direct manipulation in tests.
type MockModel struct {
WindowsList []*core.Window
ActiveWindowVal *core.Window
BuffersList []*core.Buffer
SettingsVal core.EditorSettings
ModeVal core.Mode
RegistersMap map[rune]core.Register
InsertKeysList []string
CommandVal string
CommandCursorVal int
CommandOutputVal *core.CommandOutput
CommandHistoryList []string
CommandHistoryCur int
LastFindVal core.LastFindCommand
StylesVal style.Styles
}
// NewMockModel creates a mock with an empty buffer and 24x80 window.
func NewMockModel() *MockModel {
buf := core.NewBufferBuilder().
WithLines([]string{""}).
Build()
win := core.NewWindowBuilder().
WithBuffer(&buf).
WithHeight(24).
WithWidth(80).
Build()
return &MockModel{
WindowsList: []*core.Window{&win},
ActiveWindowVal: &win,
BuffersList: []*core.Buffer{&buf},
SettingsVal: core.NewDefaultSettings(),
ModeVal: core.NormalMode,
RegistersMap: core.DefaultRegisters(),
}
}
// NewMockModelWithBuffer creates a mock with a custom buffer.
func NewMockModelWithBuffer(buf *core.Buffer) *MockModel {
win := core.NewWindowBuilder().
WithBuffer(buf).
WithHeight(24).
WithWidth(80).
Build()
return &MockModel{
WindowsList: []*core.Window{&win},
ActiveWindowVal: &win,
BuffersList: []*core.Buffer{buf},
SettingsVal: core.NewDefaultSettings(),
ModeVal: core.NormalMode,
RegistersMap: core.DefaultRegisters(),
}
}
// NewMockModelWithWindow creates a mock with a custom window.
func NewMockModelWithWindow(win *core.Window) *MockModel {
return &MockModel{
WindowsList: []*core.Window{win},
ActiveWindowVal: win,
BuffersList: []*core.Buffer{win.Buffer},
SettingsVal: core.NewDefaultSettings(),
ModeVal: core.NormalMode,
RegistersMap: core.DefaultRegisters(),
}
}
// ==================================================
// Model Interface Implementation
// ==================================================
// Core Data Access
func (m *MockModel) Windows() []*core.Window { return m.WindowsList }
func (m *MockModel) ActiveWindow() *core.Window { return m.ActiveWindowVal }
func (m *MockModel) Buffers() []*core.Buffer { return m.BuffersList }
func (m *MockModel) SetBuffers(bufs []*core.Buffer) { m.BuffersList = bufs }
func (m *MockModel) ActiveBuffer() *core.Buffer { return m.ActiveWindowVal.Buffer }
// Insert Mode State
func (m *MockModel) InsertKeys() []string { return m.InsertKeysList }
func (m *MockModel) SetInsertKeys(keys []string) { m.InsertKeysList = keys }
func (m *MockModel) SetInsertRecording(count int, a Action) {}
func (m *MockModel) ExitInsertMode() {}
func (m *MockModel) SetLastFind(char string, forward, inclusive bool) {
m.LastFindVal = core.LastFindCommand{Char: char, Forward: forward, Inclusive: inclusive}
}
func (m *MockModel) GetLastFind() *core.LastFindCommand { return &m.LastFindVal }
// Command Mode State
func (m *MockModel) Command() string { return m.CommandVal }
func (m *MockModel) SetCommand(cmd string) { m.CommandVal = cmd }
func (m *MockModel) CommandCursor() int { return m.CommandCursorVal }
func (m *MockModel) SetCommandCursor(cur int) { m.CommandCursorVal = cur }
func (m *MockModel) CommandOutput() *core.CommandOutput { return m.CommandOutputVal }
func (m *MockModel) SetCommandOutput(out *core.CommandOutput) { m.CommandOutputVal = out }
func (m *MockModel) CommandHistory() []string { return m.CommandHistoryList }
func (m *MockModel) SetCommandHistory(history []string) { m.CommandHistoryList = history }
func (m *MockModel) CommandHistoryCursor() int { return m.CommandHistoryCur }
func (m *MockModel) SetCommandHistoryCursor(cur int) { m.CommandHistoryCur = cur }
// Editor-wide State
func (m *MockModel) Mode() core.Mode { return m.ModeVal }
func (m *MockModel) SetMode(mode core.Mode) { m.ModeVal = mode }
func (m *MockModel) Settings() core.EditorSettings { return m.SettingsVal }
func (m *MockModel) SetSettings(s core.EditorSettings) { m.SettingsVal = s }
func (m *MockModel) Styles() style.Styles { return m.StylesVal }
func (m *MockModel) SetStyles(s style.Styles) { m.StylesVal = s }
// Registers
func (m *MockModel) Registers() map[rune]core.Register { return m.RegistersMap }
func (m *MockModel) GetRegister(name rune) (core.Register, bool) {
reg, ok := m.RegistersMap[name]
return reg, ok
}
func (m *MockModel) SetRegister(name rune, t core.RegisterType, cnt []string) error {
m.RegistersMap[name] = core.Register{Type: t, Content: cnt}
return nil
}
func (m *MockModel) UpdateDefaultRegister(t core.RegisterType, cnt []string) {
m.RegistersMap['"'] = core.Register{Type: t, Content: cnt}
}

View File

@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
@ -309,6 +310,27 @@ func cmdRegisters(m action.Model, args []string, force bool) tea.Cmd {
return nil
}
// --------------------------------------------------
// History Commands
// --------------------------------------------------
func cmdHistory(m action.Model, args []string, force bool) tea.Cmd {
_, _ = args, force
history := m.CommandHistory()
reversed := slices.Clone(history)
slices.Reverse(reversed)
m.SetMode(core.CommandOutputMode)
m.SetCommandOutput(&core.CommandOutput{
Title: ":history",
Lines: reversed,
Inline: false,
IsError: false,
})
return nil
}
// --------------------------------------------------
// Buffer Commands
// --------------------------------------------------

File diff suppressed because it is too large Load Diff

View File

@ -162,6 +162,13 @@ func (r *Registry) registerDefaults() {
Handler: cmdRegisters,
})
// History commands
r.Register(Command{
Name: "history",
ShortForm: "his",
Handler: cmdHistory,
})
// Buffer commands
r.Register(Command{
Name: "buffers",
@ -224,6 +231,7 @@ func (r *Registry) registerDefaults() {
ShortForm: "colo",
Handler: cmdColorscheme,
})
r.Register(Command{
Name: "colorschemes",
ShortForm: "colorschemes",

View File

@ -37,9 +37,11 @@ type Model struct {
lastFind core.LastFindCommand
// Command line state
command string
commandCursor int
commandOutput *core.CommandOutput
command string
commandCursor int
commandOutput *core.CommandOutput
commandHistory []string
commandHistoryCursor int
// Global settings
settings core.EditorSettings
@ -279,6 +281,22 @@ func (m *Model) SetCommandOutput(out *core.CommandOutput) {
m.commandOutput = out
}
func (m *Model) CommandHistory() []string {
return m.commandHistory
}
func (m *Model) SetCommandHistory(history []string) {
m.commandHistory = history
}
func (m *Model) CommandHistoryCursor() int {
return m.commandHistoryCursor
}
func (m *Model) SetCommandHistoryCursor(cur int) {
m.commandHistoryCursor = cur
}
// ==================================================
// Editor-wide State
// ==================================================

View File

@ -60,6 +60,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// TODO: Any vim action should exit also
// Simple override for command output mode for now
if m.Mode() == core.CommandOutputMode {
// TODO: Implement g/G/d/u
switch msg.String() {
case "enter":
m.SetMode(core.NormalMode)

View File

@ -142,6 +142,8 @@ func NewCommandKeymap() *Keymap {
motions: map[string]action.Motion{
"left": motion.MoveCommandLeft{},
"right": motion.MoveCommandRight{},
"up": motion.MoveCommandHistoryUp{},
"down": motion.MoveCommandHistoryDown{},
},
operators: map[string]action.Operator{}, // this will likely be empty
actions: map[string]action.Action{

View File

@ -31,3 +31,54 @@ func (a MoveCommandRight) Execute(m action.Model) tea.Cmd {
// MoveCommandRight.Type: Returns CharwiseExclusive for command line motion.
func (a MoveCommandRight) Type() core.MotionType { return core.CharwiseExclusive }
// ==================================================
// Command History Motions
// ==================================================
// MoveCommandHistoryUp implements Motion - moves cursor right in command line.
type MoveCommandHistoryUp struct{}
// MoveCommandHistoryUp.Execute: Moves the command history cursor up (if possible).
func (a MoveCommandHistoryUp) Execute(m action.Model) tea.Cmd {
cur := m.CommandHistoryCursor() // always +1 (bascially indexed at 1)
history := m.CommandHistory()
if cur < len(history) {
cmd := history[cur]
m.SetCommand(cmd)
m.SetCommandCursor(len(cmd))
// Only go up if we can
m.SetCommandHistoryCursor(cur + 1)
}
return nil
}
// MoveCommandHistoryUp.Type: Returns Linewise for command line motion.
func (a MoveCommandHistoryUp) Type() core.MotionType { return core.Linewise }
// MoveCommandHistoryDown implements Motion - moves cursor right in command line.
type MoveCommandHistoryDown struct{}
// MoveCommandHistoryDown.Execute: Moves the command history cursor down (if possible).
func (a MoveCommandHistoryDown) Execute(m action.Model) tea.Cmd {
cur := m.CommandHistoryCursor() // always +1 (bascially indexed at 1)
history := m.CommandHistory()
if cur > 1 {
cmd := history[cur-2]
m.SetCommand(cmd)
m.SetCommandCursor(len(cmd))
} else {
m.SetCommand("") // BUG: We should probably keep the original in state
m.SetCommandCursor(0)
}
m.SetCommandHistoryCursor(max(0, cur-1))
return nil
}
// MoveCommandHistoryDown.Type: Returns Linewise for command line motion.
func (a MoveCommandHistoryDown) Type() core.MotionType { return core.Linewise }

View File

@ -0,0 +1,376 @@
package motion
import (
"testing"
"git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/core"
)
// ==================================================
// MoveCommandHistoryUp Tests
// ==================================================
func TestMoveCommandHistoryUp(t *testing.T) {
t.Run("navigate up from start", func(t *testing.T) {
m := &action.MockModel{
ModeVal: core.CommandMode,
CommandVal: "",
CommandHistoryList: []string{"cmd3", "cmd2", "cmd1"},
CommandHistoryCur: 0,
}
action := MoveCommandHistoryUp{}
action.Execute(m)
// Should load first command in history
if m.Command() != "cmd3" {
t.Errorf("Command = %q, want %q", m.Command(), "cmd3")
}
// Cursor should move to end of command
if m.CommandCursor() != len("cmd3") {
t.Errorf("CommandCursor = %d, want %d", m.CommandCursor(), len("cmd3"))
}
// History cursor should advance
if m.CommandHistoryCursor() != 1 {
t.Errorf("CommandHistoryCursor = %d, want 1", m.CommandHistoryCursor())
}
})
t.Run("navigate up multiple times", func(t *testing.T) {
m := &action.MockModel{
ModeVal: core.CommandMode,
CommandVal: "",
CommandHistoryList: []string{"cmd3", "cmd2", "cmd1"},
CommandHistoryCur: 0,
}
action := MoveCommandHistoryUp{}
// First up
action.Execute(m)
if m.Command() != "cmd3" {
t.Errorf("After 1st up: Command = %q, want 'cmd3'", m.Command())
}
// Second up
action.Execute(m)
if m.Command() != "cmd2" {
t.Errorf("After 2nd up: Command = %q, want 'cmd2'", m.Command())
}
// Third up
action.Execute(m)
if m.Command() != "cmd1" {
t.Errorf("After 3rd up: Command = %q, want 'cmd1'", m.Command())
}
if m.CommandHistoryCursor() != 3 {
t.Errorf("CommandHistoryCursor = %d, want 3", m.CommandHistoryCursor())
}
})
t.Run("cannot navigate past end of history", func(t *testing.T) {
m := &action.MockModel{
ModeVal: core.CommandMode,
CommandVal: "",
CommandHistoryList: []string{"cmd3", "cmd2", "cmd1"},
CommandHistoryCur: 3, // Already at end
}
action := MoveCommandHistoryUp{}
action.Execute(m)
// Should not change command (still showing cmd1)
if m.Command() != "" {
t.Errorf("Command = %q, want empty (should not go past end)", m.Command())
}
// Cursor should not advance
if m.CommandHistoryCursor() != 3 {
t.Errorf("CommandHistoryCursor = %d, want 3 (should not advance)", m.CommandHistoryCursor())
}
})
t.Run("empty history does nothing", func(t *testing.T) {
m := &action.MockModel{
ModeVal: core.CommandMode,
CommandVal: "current",
CommandHistoryList: []string{},
CommandHistoryCur: 0,
}
action := MoveCommandHistoryUp{}
action.Execute(m)
// Command should not change
if m.Command() != "current" {
t.Errorf("Command = %q, want 'current' (no history to navigate)", m.Command())
}
if m.CommandHistoryCursor() != 0 {
t.Errorf("CommandHistoryCursor = %d, want 0", m.CommandHistoryCursor())
}
})
t.Run("command cursor moves to end", func(t *testing.T) {
m := &action.MockModel{
ModeVal: core.CommandMode,
CommandVal: "",
CommandCursorVal: 0,
CommandHistoryList: []string{"long command here"},
CommandHistoryCur: 0,
}
action := MoveCommandHistoryUp{}
action.Execute(m)
expectedLen := len("long command here")
if m.CommandCursor() != expectedLen {
t.Errorf("CommandCursor = %d, want %d (end of command)", m.CommandCursor(), expectedLen)
}
})
}
// ==================================================
// MoveCommandHistoryDown Tests
// ==================================================
func TestMoveCommandHistoryDown(t *testing.T) {
t.Run("navigate down from middle of history", func(t *testing.T) {
m := &action.MockModel{
ModeVal: core.CommandMode,
CommandVal: "cmd2",
CommandHistoryList: []string{"cmd3", "cmd2", "cmd1"},
CommandHistoryCur: 2, // At cmd2
}
action := MoveCommandHistoryDown{}
action.Execute(m)
// Should load cmd3 (more recent)
if m.Command() != "cmd3" {
t.Errorf("Command = %q, want 'cmd3'", m.Command())
}
// Cursor should be at 1
if m.CommandHistoryCursor() != 1 {
t.Errorf("CommandHistoryCursor = %d, want 1", m.CommandHistoryCursor())
}
})
t.Run("navigate down to empty command", func(t *testing.T) {
m := &action.MockModel{
ModeVal: core.CommandMode,
CommandVal: "cmd3",
CommandHistoryList: []string{"cmd3", "cmd2", "cmd1"},
CommandHistoryCur: 1, // At first command
}
action := MoveCommandHistoryDown{}
action.Execute(m)
// Should clear command (back to current input)
if m.Command() != "" {
t.Errorf("Command = %q, want empty (back to current)", m.Command())
}
if m.CommandCursor() != 0 {
t.Errorf("CommandCursor = %d, want 0", m.CommandCursor())
}
if m.CommandHistoryCursor() != 0 {
t.Errorf("CommandHistoryCursor = %d, want 0", m.CommandHistoryCursor())
}
})
t.Run("navigate down from cursor 0 does nothing", func(t *testing.T) {
m := &action.MockModel{
ModeVal: core.CommandMode,
CommandVal: "",
CommandHistoryList: []string{"cmd3", "cmd2", "cmd1"},
CommandHistoryCur: 0, // Already at bottom
}
action := MoveCommandHistoryDown{}
action.Execute(m)
// Should clear command
if m.Command() != "" {
t.Errorf("Command = %q, want empty", m.Command())
}
if m.CommandHistoryCursor() != 0 {
t.Errorf("CommandHistoryCursor = %d, want 0", m.CommandHistoryCursor())
}
})
t.Run("command cursor moves to end", func(t *testing.T) {
m := &action.MockModel{
ModeVal: core.CommandMode,
CommandVal: "",
CommandCursorVal: 0,
CommandHistoryList: []string{"cmd3", "long command here"},
CommandHistoryCur: 2,
}
action := MoveCommandHistoryDown{}
action.Execute(m)
expectedLen := len("cmd3")
if m.CommandCursor() != expectedLen {
t.Errorf("CommandCursor = %d, want %d (end of command)", m.CommandCursor(), expectedLen)
}
})
}
// ==================================================
// Integration Tests
// ==================================================
func TestCommandHistoryIntegration(t *testing.T) {
t.Run("up down up sequence", func(t *testing.T) {
m := &action.MockModel{
ModeVal: core.CommandMode,
CommandVal: "",
CommandHistoryList: []string{"cmd3", "cmd2", "cmd1"},
CommandHistoryCur: 0,
}
upAction := MoveCommandHistoryUp{}
downAction := MoveCommandHistoryDown{}
// Up twice
upAction.Execute(m) // cursor=1, cmd=cmd3
upAction.Execute(m) // cursor=2, cmd=cmd2
// Down once
downAction.Execute(m) // cursor=1, cmd=cmd3
// Up again
upAction.Execute(m) // cursor=2, cmd=cmd2
if m.Command() != "cmd2" {
t.Errorf("Command = %q, want 'cmd2'", m.Command())
}
if m.CommandHistoryCursor() != 2 {
t.Errorf("CommandHistoryCursor = %d, want 2", m.CommandHistoryCursor())
}
})
t.Run("navigate up past end does not crash", func(t *testing.T) {
m := &action.MockModel{
ModeVal: core.CommandMode,
CommandVal: "",
CommandHistoryList: []string{"cmd1", "cmd2"},
CommandHistoryCur: 0,
}
upAction := MoveCommandHistoryUp{}
// Navigate to end
upAction.Execute(m) // cursor = 1, cmd = cmd1
upAction.Execute(m) // cursor = 2, cmd = cmd2
// Try to go past end
upAction.Execute(m) // Should do nothing
if m.CommandHistoryCursor() != 2 {
t.Errorf("CommandHistoryCursor = %d, want 2 (should stay at end)", m.CommandHistoryCursor())
}
})
t.Run("navigate down past bottom does not crash", func(t *testing.T) {
m := &action.MockModel{
ModeVal: core.CommandMode,
CommandVal: "",
CommandHistoryList: []string{"cmd1"},
CommandHistoryCur: 0,
}
downAction := MoveCommandHistoryDown{}
// Try to go down from bottom
downAction.Execute(m)
if m.CommandHistoryCursor() != 0 {
t.Errorf("CommandHistoryCursor = %d, want 0 (should stay at bottom)", m.CommandHistoryCursor())
}
// Try again
downAction.Execute(m)
if m.CommandHistoryCursor() != 0 {
t.Errorf("CommandHistoryCursor = %d, want 0 (should stay at bottom)", m.CommandHistoryCursor())
}
})
}
// ==================================================
// Long History Tests
// ==================================================
func TestCommandHistoryWithLongHistory(t *testing.T) {
t.Run("navigate through 20 commands", func(t *testing.T) {
history := make([]string, 20)
for i := 0; i < 20; i++ {
history[i] = string(rune('A' + i))
}
m := &action.MockModel{
ModeVal: core.CommandMode,
CommandVal: "",
CommandHistoryList: history,
CommandHistoryCur: 0,
}
upAction := MoveCommandHistoryUp{}
// Navigate to 10th command
for i := 0; i < 10; i++ {
upAction.Execute(m)
}
want := string(rune('A' + 9))
if m.Command() != want {
t.Errorf("Command after 10 ups = %q, want %q", m.Command(), want)
}
if m.CommandHistoryCursor() != 10 {
t.Errorf("CommandHistoryCursor = %d, want 10", m.CommandHistoryCursor())
}
})
t.Run("navigate to very end of long history", func(t *testing.T) {
history := make([]string, 50)
for i := 0; i < 50; i++ {
history[i] = string(rune('0' + (i % 10)))
}
m := &action.MockModel{
ModeVal: core.CommandMode,
CommandVal: "",
CommandHistoryList: history,
CommandHistoryCur: 0,
}
upAction := MoveCommandHistoryUp{}
// Navigate all the way to the end
for i := 0; i < 100; i++ { // Try to go past end
upAction.Execute(m)
}
// Should be at position 50 (end of 50-item array)
if m.CommandHistoryCursor() != 50 {
t.Errorf("CommandHistoryCursor = %d, want 50 (at end)", m.CommandHistoryCursor())
}
// Should be showing last command
want := string(rune('0' + 49%10))
if m.Command() != want {
t.Errorf("Command = %q, want %q (last in history)", m.Command(), want)
}
})
}