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.
This commit is contained in:
Hayden Hargreaves 2026-03-19 15:23:44 -07:00
parent 5ff473d0d9
commit 3c98dca777
12 changed files with 771 additions and 27 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,171 @@
package action
import (
"testing"
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea"
)
// mockModelForHistory is a minimal mock for testing command history
type mockModelForHistory struct {
mockModel
commandHistory []string
commandHistoryCursor int
}
func (m *mockModelForHistory) CommandHistory() []string {
return m.commandHistory
}
func (m *mockModelForHistory) SetCommandHistory(history []string) {
m.commandHistory = history
}
func (m *mockModelForHistory) CommandHistoryCursor() int {
return m.commandHistoryCursor
}
func (m *mockModelForHistory) SetCommandHistoryCursor(cur int) {
m.commandHistoryCursor = cur
}
// 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 := &mockModelForHistory{
mockModel: mockModel{
mode: core.CommandMode,
command: "w test.txt",
},
commandHistory: []string{},
commandHistoryCursor: 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 := &mockModelForHistory{
mockModel: mockModel{
mode: core.CommandMode,
command: "w file1.txt",
},
commandHistory: []string{},
commandHistoryCursor: 0,
}
registry := &mockRegistry{}
action := CommandExecute{Registry: registry}
// Execute first command
action.Execute(m)
// Execute second command
m.command = "q"
action.Execute(m)
// Execute third command
m.command = "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 := &mockModelForHistory{
mockModel: mockModel{
mode: core.CommandMode,
command: "",
},
commandHistory: []string{"previous command"},
commandHistoryCursor: 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 := &mockModelForHistory{
mockModel: mockModel{
mode: core.CommandMode,
command: "w",
},
commandHistory: []string{},
commandHistoryCursor: 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 := &mockModelForHistory{
mockModel: mockModel{
mode: core.CommandMode,
command: "w",
},
commandHistory: []string{"w"},
commandHistoryCursor: 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

@ -12,18 +12,20 @@ import (
// ==================================================
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
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
commandHistory []string
commandHistoryCursor int
lastFind core.LastFindCommand
styles style.Styles
}
func newMockModel() *mockModel {
@ -99,6 +101,10 @@ func (m *mockModel) CommandCursor() int { return m.command
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 }
func (m *mockModel) CommandHistory() []string { return m.commandHistory }
func (m *mockModel) SetCommandHistory(history []string) { m.commandHistory = history }
func (m *mockModel) CommandHistoryCursor() int { return m.commandHistoryCursor }
func (m *mockModel) SetCommandHistoryCursor(cur int) { m.commandHistoryCursor = cur }
// Editor-wide State
func (m *mockModel) Mode() core.Mode { return m.mode }

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

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
// --------------------------------------------------

View File

@ -19,18 +19,20 @@ import (
// ==================================================
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
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
commandHistory []string
commandHistoryCursor int
lastFind core.LastFindCommand
styles style.Styles
}
func newMockModel() *mockModel {
@ -95,6 +97,10 @@ func (m *mockModel) CommandCursor() int { return m.command
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 }
func (m *mockModel) CommandHistory() []string { return m.commandHistory }
func (m *mockModel) SetCommandHistory(history []string) { m.commandHistory = history }
func (m *mockModel) CommandHistoryCursor() int { return m.commandHistoryCursor }
func (m *mockModel) SetCommandHistoryCursor(cur int) { m.commandHistoryCursor = cur }
// Editor-wide State
func (m *mockModel) Mode() core.Mode { return m.mode }

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,449 @@
package motion
import (
"testing"
"git.gophernest.net/azpect/TextEditor/internal/action"
"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
commandHistory []string
commandHistoryCursor int
lastFind core.LastFindCommand
styles style.Styles
}
// 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.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 }
func (m *mockModel) CommandHistory() []string { return m.commandHistory }
func (m *mockModel) SetCommandHistory(history []string) { m.commandHistory = history }
func (m *mockModel) CommandHistoryCursor() int { return m.commandHistoryCursor }
func (m *mockModel) SetCommandHistoryCursor(cur int) { m.commandHistoryCursor = cur }
// 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}
}
// ==================================================
// MoveCommandHistoryUp Tests
// ==================================================
func TestMoveCommandHistoryUp(t *testing.T) {
t.Run("navigate up from start", func(t *testing.T) {
m := &mockModel{
mode: core.CommandMode,
command: "",
commandHistory: []string{"cmd3", "cmd2", "cmd1"},
commandHistoryCursor: 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 := &mockModel{
mode: core.CommandMode,
command: "",
commandHistory: []string{"cmd3", "cmd2", "cmd1"},
commandHistoryCursor: 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 := &mockModel{
mode: core.CommandMode,
command: "",
commandHistory: []string{"cmd3", "cmd2", "cmd1"},
commandHistoryCursor: 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 := &mockModel{
mode: core.CommandMode,
command: "current",
commandHistory: []string{},
commandHistoryCursor: 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 := &mockModel{
mode: core.CommandMode,
command: "",
commandCursor: 0,
commandHistory: []string{"long command here"},
commandHistoryCursor: 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 := &mockModel{
mode: core.CommandMode,
command: "cmd2",
commandHistory: []string{"cmd3", "cmd2", "cmd1"},
commandHistoryCursor: 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 := &mockModel{
mode: core.CommandMode,
command: "cmd3",
commandHistory: []string{"cmd3", "cmd2", "cmd1"},
commandHistoryCursor: 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 := &mockModel{
mode: core.CommandMode,
command: "",
commandHistory: []string{"cmd3", "cmd2", "cmd1"},
commandHistoryCursor: 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 := &mockModel{
mode: core.CommandMode,
command: "",
commandCursor: 0,
commandHistory: []string{"cmd3", "long command here"},
commandHistoryCursor: 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 := &mockModel{
mode: core.CommandMode,
command: "",
commandHistory: []string{"cmd3", "cmd2", "cmd1"},
commandHistoryCursor: 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 := &mockModel{
mode: core.CommandMode,
command: "",
commandHistory: []string{"cmd1", "cmd2"},
commandHistoryCursor: 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 := &mockModel{
mode: core.CommandMode,
command: "",
commandHistory: []string{"cmd1"},
commandHistoryCursor: 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 := &mockModel{
mode: core.CommandMode,
command: "",
commandHistory: history,
commandHistoryCursor: 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 := &mockModel{
mode: core.CommandMode,
command: "",
commandHistory: history,
commandHistoryCursor: 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)
}
})
}