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:
parent
5ff473d0d9
commit
3c98dca777
@ -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{
|
||||
|
||||
171
internal/action/command_history_test.go
Normal file
171
internal/action/command_history_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -22,6 +22,8 @@ type mockModel struct {
|
||||
command string
|
||||
commandCursor int
|
||||
commandOutput *core.CommandOutput
|
||||
commandHistory []string
|
||||
commandHistoryCursor int
|
||||
lastFind core.LastFindCommand
|
||||
styles style.Styles
|
||||
}
|
||||
@ -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 }
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
// --------------------------------------------------
|
||||
|
||||
@ -29,6 +29,8 @@ type mockModel struct {
|
||||
command string
|
||||
commandCursor int
|
||||
commandOutput *core.CommandOutput
|
||||
commandHistory []string
|
||||
commandHistoryCursor int
|
||||
lastFind core.LastFindCommand
|
||||
styles style.Styles
|
||||
}
|
||||
@ -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 }
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -40,6 +40,8 @@ type Model struct {
|
||||
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
|
||||
// ==================================================
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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 }
|
||||
|
||||
449
internal/motion/command_test.go
Normal file
449
internal/motion/command_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user