Gim/internal/action/command_test.go
Hayden Hargreaves ea3ebcdc83
All checks were successful
Run Test Suite / test (push) Successful in 17s
feat: implemented :<n> command, tested
Also added tons of tests for the actual command mode, since that was all
untested...
2026-04-02 14:03:36 -07:00

726 lines
19 KiB
Go

package action
import (
"testing"
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea"
)
// mockCommandRegistry for testing - returns nil for all commands (used for numeric goto)
type mockCommandRegistry struct{}
func (r *mockCommandRegistry) Execute(m Model, cmdLine string) (tea.Cmd, error) {
return nil, nil
}
// TestCommandExecute_GoToLine tests the :<num> command functionality
func TestCommandExecute_GoToLine(t *testing.T) {
t.Run("goto positive line number within bounds", func(t *testing.T) {
// Create buffer with 10 lines
buf := core.NewBufferBuilder().
WithLines([]string{"line1", "line2", "line3", "line4", "line5", "line6", "line7", "line8", "line9", "line10"}).
Build()
m := NewMockModelWithBuffer(&buf)
win := m.ActiveWindow()
win.SetCursorLine(0)
// Set command to "5"
m.SetCommand("5")
m.SetMode(core.CommandMode)
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
// Should jump to line 5 (0-indexed: line 4)
if win.Cursor.Line != 4 {
t.Fatalf("expected cursor at line 4, got %d", win.Cursor.Line)
}
// Should exit command mode
if m.Mode() != core.NormalMode {
t.Fatalf("expected normal mode, got %v", m.Mode())
}
})
t.Run("goto line 1", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithLines([]string{"line1", "line2", "line3", "line4", "line5"}).
Build()
m := NewMockModelWithBuffer(&buf)
win := m.ActiveWindow()
win.SetCursorLine(3) // Start at line 4
m.SetCommand("1")
m.SetMode(core.CommandMode)
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
// Should jump to line 1 (0-indexed: line 0)
if win.Cursor.Line != 0 {
t.Fatalf("expected cursor at line 0, got %d", win.Cursor.Line)
}
})
t.Run("goto last line", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithLines([]string{"line1", "line2", "line3", "line4", "line5"}).
Build()
m := NewMockModelWithBuffer(&buf)
win := m.ActiveWindow()
win.SetCursorLine(0)
m.SetCommand("5")
m.SetMode(core.CommandMode)
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
// Should jump to line 5 (0-indexed: line 4)
if win.Cursor.Line != 4 {
t.Fatalf("expected cursor at line 4, got %d", win.Cursor.Line)
}
})
t.Run("goto line beyond buffer length clamps to last line", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithLines([]string{"line1", "line2", "line3"}).
Build()
m := NewMockModelWithBuffer(&buf)
win := m.ActiveWindow()
win.SetCursorLine(0)
m.SetCommand("999")
m.SetMode(core.CommandMode)
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
// Should clamp to last line (0-indexed: line 2)
// Note: cmdGoToLine uses min(line-1, buf.LineCount())
// LineCount() returns 3, so min(998, 3) = 3
// But SetCursorLine calls ClampCursor() which clamps to valid range [0, LineCount-1]
lastLine := buf.LineCount() - 1
if win.Cursor.Line != lastLine {
t.Fatalf("expected cursor at line %d (last line), got %d", lastLine, win.Cursor.Line)
}
})
t.Run("goto line zero goes to beginning", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithLines([]string{"line1", "line2", "line3", "line4", "line5"}).
Build()
m := NewMockModelWithBuffer(&buf)
win := m.ActiveWindow()
win.SetCursorLine(3)
m.SetCommand("0")
m.SetMode(core.CommandMode)
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
// Line 0 or below goes relative: max(0, currentLine + 0) = currentLine
// But with the logic: if line <= 0, newLine = max(0, win.Cursor.Line + line)
// So for line=0: max(0, 3+0) = 3 (stays at same line)
if win.Cursor.Line != 3 {
t.Fatalf("expected cursor at line 3, got %d", win.Cursor.Line)
}
})
t.Run("negative number moves relative backwards", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithLines([]string{"line1", "line2", "line3", "line4", "line5", "line6", "line7", "line8", "line9", "line10"}).
Build()
m := NewMockModelWithBuffer(&buf)
win := m.ActiveWindow()
win.SetCursorLine(5) // Start at line 6 (0-indexed)
m.SetCommand("-3")
m.SetMode(core.CommandMode)
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
// Should move back 3 lines: 5 + (-3) = 2
if win.Cursor.Line != 2 {
t.Fatalf("expected cursor at line 2, got %d", win.Cursor.Line)
}
})
t.Run("negative number beyond start clamps to line 0", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithLines([]string{"line1", "line2", "line3", "line4", "line5"}).
Build()
m := NewMockModelWithBuffer(&buf)
win := m.ActiveWindow()
win.SetCursorLine(2)
m.SetCommand("-10")
m.SetMode(core.CommandMode)
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
// Should clamp to line 0: max(0, 2 + (-10)) = max(0, -8) = 0
if win.Cursor.Line != 0 {
t.Fatalf("expected cursor at line 0, got %d", win.Cursor.Line)
}
})
t.Run("goto same line", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithLines([]string{"line1", "line2", "line3", "line4", "line5"}).
Build()
m := NewMockModelWithBuffer(&buf)
win := m.ActiveWindow()
win.SetCursorLine(2) // At line 3
m.SetCommand("3")
m.SetMode(core.CommandMode)
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
// Should stay at line 3 (0-indexed: line 2)
if win.Cursor.Line != 2 {
t.Fatalf("expected cursor at line 2, got %d", win.Cursor.Line)
}
})
t.Run("empty buffer", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithLines([]string{""}).
Build()
m := NewMockModelWithBuffer(&buf)
win := m.ActiveWindow()
m.SetCommand("1")
m.SetMode(core.CommandMode)
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
// Should stay at line 0
if win.Cursor.Line != 0 {
t.Fatalf("expected cursor at line 0, got %d", win.Cursor.Line)
}
})
t.Run("single line buffer goto line 1", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithLines([]string{"only line"}).
Build()
m := NewMockModelWithBuffer(&buf)
win := m.ActiveWindow()
m.SetCommand("1")
m.SetMode(core.CommandMode)
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
if win.Cursor.Line != 0 {
t.Fatalf("expected cursor at line 0, got %d", win.Cursor.Line)
}
})
t.Run("single line buffer goto beyond", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithLines([]string{"only line"}).
Build()
m := NewMockModelWithBuffer(&buf)
win := m.ActiveWindow()
m.SetCommand("10")
m.SetMode(core.CommandMode)
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
// Should clamp to the available lines
if win.Cursor.Line > 0 {
t.Fatalf("expected cursor at line 0 or 1, got %d", win.Cursor.Line)
}
})
t.Run("large line number", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithLines([]string{"line1", "line2", "line3"}).
Build()
m := NewMockModelWithBuffer(&buf)
win := m.ActiveWindow()
m.SetCommand("1000000")
m.SetMode(core.CommandMode)
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
// Should clamp to last available line
lineCount := buf.LineCount()
if win.Cursor.Line > lineCount {
t.Fatalf("expected cursor at or before line %d, got %d", lineCount, win.Cursor.Line)
}
})
t.Run("command with leading/trailing spaces", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithLines([]string{"line1", "line2", "line3", "line4", "line5"}).
Build()
m := NewMockModelWithBuffer(&buf)
win := m.ActiveWindow()
win.SetCursorLine(0)
// strconv.Atoi should handle leading spaces
m.SetCommand(" 3 ")
m.SetMode(core.CommandMode)
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
// strconv.Atoi(" 3 ") will fail, so this won't be treated as a line number
// It should stay at the same position and potentially show an error
// depending on the CommandRegistry behavior
})
t.Run("mixed alphanumeric command not treated as line number", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithLines([]string{"line1", "line2", "line3"}).
Build()
m := NewMockModelWithBuffer(&buf)
win := m.ActiveWindow()
initialLine := 1
win.SetCursorLine(initialLine)
m.SetCommand("3abc")
m.SetMode(core.CommandMode)
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
// "3abc" is not a valid number, so won't trigger goto line
// Cursor should stay at original position
if win.Cursor.Line != initialLine {
t.Fatalf("expected cursor at line %d, got %d", initialLine, win.Cursor.Line)
}
})
}
// TestCommandExecute_EmptyCommand tests empty command behavior
func TestCommandExecute_EmptyCommand(t *testing.T) {
t.Run("empty command does nothing", func(t *testing.T) {
m := NewMockModel()
win := m.ActiveWindow()
initialLine := 0
win.SetCursorLine(initialLine)
m.SetCommand("")
m.SetMode(core.CommandMode)
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
// Should exit command mode
if m.Mode() != core.NormalMode {
t.Fatalf("expected normal mode, got %v", m.Mode())
}
// Cursor should not move
if win.Cursor.Line != initialLine {
t.Fatalf("expected cursor at line %d, got %d", initialLine, win.Cursor.Line)
}
})
}
// TestCommandExecute_CommandHistory tests that commands are added to history
func TestCommandExecute_CommandHistory(t *testing.T) {
t.Run("numeric command added to history", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithLines([]string{"line1", "line2", "line3", "line4", "line5"}).
Build()
m := NewMockModelWithBuffer(&buf)
m.SetCommand("3")
m.SetMode(core.CommandMode)
m.SetCommandHistory([]string{})
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
history := m.CommandHistory()
if len(history) != 1 {
t.Fatalf("expected 1 item in history, got %d", len(history))
}
if history[0] != "3" {
t.Fatalf("expected '3' in history, got '%s'", history[0])
}
})
t.Run("commands prepended to existing history", func(t *testing.T) {
m := NewMockModel()
m.SetCommandHistory([]string{"oldcommand1", "oldcommand2"})
m.SetCommand("5")
m.SetMode(core.CommandMode)
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
history := m.CommandHistory()
if len(history) != 3 {
t.Fatalf("expected 3 items in history, got %d", len(history))
}
if history[0] != "5" {
t.Fatalf("expected '5' as first item, got '%s'", history[0])
}
if history[1] != "oldcommand1" {
t.Fatalf("expected 'oldcommand1' as second item, got '%s'", history[1])
}
})
}
// TestCommandExecute_ModeTransition tests command mode exit behavior
func TestCommandExecute_ModeTransition(t *testing.T) {
t.Run("exits command mode after execution", func(t *testing.T) {
m := NewMockModel()
m.SetMode(core.CommandMode)
m.SetCommand("5")
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
if m.Mode() != core.NormalMode {
t.Fatalf("expected normal mode, got %v", m.Mode())
}
})
t.Run("resets command cursor to 0", func(t *testing.T) {
m := NewMockModel()
m.SetCommandCursor(10)
m.SetCommand("5")
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
if m.CommandCursor() != 0 {
t.Fatalf("expected command cursor at 0, got %d", m.CommandCursor())
}
})
t.Run("resets command history cursor to 0", func(t *testing.T) {
m := NewMockModel()
m.SetCommandHistoryCursor(5)
m.SetCommand("5")
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
if m.CommandHistoryCursor() != 0 {
t.Fatalf("expected command history cursor at 0, got %d", m.CommandHistoryCursor())
}
})
}
// TestExitCommandMode tests the Esc key behavior
func TestExitCommandMode(t *testing.T) {
t.Run("exit command mode clears state", func(t *testing.T) {
m := NewMockModel()
m.SetMode(core.CommandMode)
m.SetCommand("test command")
m.SetCommandCursor(5)
m.SetCommandHistoryCursor(3)
action := ExitCommandMode{}
action.Execute(m)
if m.Mode() != core.NormalMode {
t.Fatalf("expected normal mode, got %v", m.Mode())
}
if m.Command() != "" {
t.Fatalf("expected empty command, got '%s'", m.Command())
}
if m.CommandCursor() != 0 {
t.Fatalf("expected command cursor at 0, got %d", m.CommandCursor())
}
if m.CommandHistoryCursor() != 0 {
t.Fatalf("expected command history cursor at 0, got %d", m.CommandHistoryCursor())
}
})
}
// TestInsertCommandChar tests character insertion in command mode
func TestInsertCommandChar(t *testing.T) {
t.Run("insert character at empty command", func(t *testing.T) {
m := NewMockModel()
m.SetCommand("")
m.SetCommandCursor(0)
action := InsertCommandChar{Char: "5"}
action.Execute(m)
if m.Command() != "5" {
t.Fatalf("expected '5', got '%s'", m.Command())
}
if m.CommandCursor() != 1 {
t.Fatalf("expected cursor at 1, got %d", m.CommandCursor())
}
})
t.Run("insert character at end", func(t *testing.T) {
m := NewMockModel()
m.SetCommand("12")
m.SetCommandCursor(2)
action := InsertCommandChar{Char: "3"}
action.Execute(m)
if m.Command() != "123" {
t.Fatalf("expected '123', got '%s'", m.Command())
}
if m.CommandCursor() != 3 {
t.Fatalf("expected cursor at 3, got %d", m.CommandCursor())
}
})
t.Run("insert character in middle", func(t *testing.T) {
m := NewMockModel()
m.SetCommand("13")
m.SetCommandCursor(1)
action := InsertCommandChar{Char: "2"}
action.Execute(m)
if m.Command() != "123" {
t.Fatalf("expected '123', got '%s'", m.Command())
}
if m.CommandCursor() != 2 {
t.Fatalf("expected cursor at 2, got %d", m.CommandCursor())
}
})
}
// TestCommandBackspace tests backspace in command mode
func TestCommandBackspace(t *testing.T) {
t.Run("backspace at beginning does nothing", func(t *testing.T) {
m := NewMockModel()
m.SetCommand("123")
m.SetCommandCursor(0)
action := CommandBackspace{}
action.Execute(m)
if m.Command() != "123" {
t.Fatalf("expected '123', got '%s'", m.Command())
}
if m.CommandCursor() != 0 {
t.Fatalf("expected cursor at 0, got %d", m.CommandCursor())
}
})
t.Run("backspace in middle", func(t *testing.T) {
m := NewMockModel()
m.SetCommand("123")
m.SetCommandCursor(2)
action := CommandBackspace{}
action.Execute(m)
if m.Command() != "13" {
t.Fatalf("expected '13', got '%s'", m.Command())
}
if m.CommandCursor() != 1 {
t.Fatalf("expected cursor at 1, got %d", m.CommandCursor())
}
})
t.Run("backspace at end", func(t *testing.T) {
m := NewMockModel()
m.SetCommand("123")
m.SetCommandCursor(3)
action := CommandBackspace{}
action.Execute(m)
if m.Command() != "12" {
t.Fatalf("expected '12', got '%s'", m.Command())
}
if m.CommandCursor() != 2 {
t.Fatalf("expected cursor at 2, got %d", m.CommandCursor())
}
})
}
// TestCommandDelete tests delete key in command mode
func TestCommandDelete(t *testing.T) {
t.Run("delete at cursor position 0 deletes character after cursor", func(t *testing.T) {
m := NewMockModel()
m.SetCommand("123")
m.SetCommandCursor(0)
action := CommandDelete{}
action.Execute(m)
// At position 0, delete removes char at position 1 (the next char)
// Result: "1" + "3" = "13"
if m.Command() != "13" {
t.Fatalf("expected '13', got '%s'", m.Command())
}
})
t.Run("delete at cursor position 1", func(t *testing.T) {
m := NewMockModel()
m.SetCommand("123")
m.SetCommandCursor(1)
action := CommandDelete{}
action.Execute(m)
// At position 1, delete removes char at position 2 (the next char)
// Result: "12" + "" = "12"
if m.Command() != "12" {
t.Fatalf("expected '12', got '%s'", m.Command())
}
})
t.Run("delete at last character", func(t *testing.T) {
m := NewMockModel()
m.SetCommand("123")
m.SetCommandCursor(2)
action := CommandDelete{}
action.Execute(m)
if m.Command() != "12" {
t.Fatalf("expected '12', got '%s'", m.Command())
}
})
t.Run("delete at end acts as backspace", func(t *testing.T) {
m := NewMockModel()
m.SetCommand("123")
m.SetCommandCursor(3)
action := CommandDelete{}
action.Execute(m)
if m.Command() != "12" {
t.Fatalf("expected '12', got '%s'", m.Command())
}
if m.CommandCursor() != 2 {
t.Fatalf("expected cursor at 2, got %d", m.CommandCursor())
}
})
}
// TestCommandDeletePreviousWord tests Ctrl+W behavior in command mode
func TestCommandDeletePreviousWord(t *testing.T) {
t.Run("delete word from middle of text", func(t *testing.T) {
m := NewMockModel()
m.SetCommand("hello world")
m.SetCommandCursor(11) // At end
action := CommandDeletePreviousWord{}
action.Execute(m)
if m.Command() != "hello " {
t.Fatalf("expected 'hello ', got '%s'", m.Command())
}
if m.CommandCursor() != 6 {
t.Fatalf("expected cursor at 6, got %d", m.CommandCursor())
}
})
t.Run("delete word with leading spaces", func(t *testing.T) {
m := NewMockModel()
m.SetCommand("hello world")
m.SetCommandCursor(13) // At end
action := CommandDeletePreviousWord{}
action.Execute(m)
if m.Command() != "hello " {
t.Fatalf("expected 'hello ', got '%s'", m.Command())
}
})
t.Run("delete at beginning does nothing", func(t *testing.T) {
m := NewMockModel()
m.SetCommand("hello")
m.SetCommandCursor(0)
action := CommandDeletePreviousWord{}
action.Execute(m)
if m.Command() != "hello" {
t.Fatalf("expected 'hello', got '%s'", m.Command())
}
if m.CommandCursor() != 0 {
t.Fatalf("expected cursor at 0, got %d", m.CommandCursor())
}
})
t.Run("delete punctuation", func(t *testing.T) {
m := NewMockModel()
m.SetCommand("hello...")
m.SetCommandCursor(8) // After the dots
action := CommandDeletePreviousWord{}
action.Execute(m)
if m.Command() != "hello" {
t.Fatalf("expected 'hello', got '%s'", m.Command())
}
if m.CommandCursor() != 5 {
t.Fatalf("expected cursor at 5, got %d", m.CommandCursor())
}
})
t.Run("delete word in middle of command", func(t *testing.T) {
m := NewMockModel()
m.SetCommand("one two three")
m.SetCommandCursor(7) // After "two"
action := CommandDeletePreviousWord{}
action.Execute(m)
if m.Command() != "one three" {
t.Fatalf("expected 'one three', got '%s'", m.Command())
}
if m.CommandCursor() != 4 {
t.Fatalf("expected cursor at 4, got %d", m.CommandCursor())
}
})
t.Run("delete single character word", func(t *testing.T) {
m := NewMockModel()
m.SetCommand("a b c")
m.SetCommandCursor(3) // After "b"
action := CommandDeletePreviousWord{}
action.Execute(m)
if m.Command() != "a c" {
t.Fatalf("expected 'a c', got '%s'", m.Command())
}
})
t.Run("delete from whitespace position", func(t *testing.T) {
m := NewMockModel()
m.SetCommand("hello world")
m.SetCommandCursor(6) // At the space after "hello"
action := CommandDeletePreviousWord{}
action.Execute(m)
// Should skip the space and delete "hello" and the space
if m.Command() != "world" {
t.Fatalf("expected 'world', got '%s'", m.Command())
}
if m.CommandCursor() != 0 {
t.Fatalf("expected cursor at 0, got %d", m.CommandCursor())
}
})
}