From ea3ebcdc83b4f30ff2450086f9f91138a9296de4 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Thu, 2 Apr 2026 14:03:36 -0700 Subject: [PATCH] feat: implemented : command, tested Also added tons of tests for the actual command mode, since that was all untested... --- FEATURES.md | 62 +-- internal/action/command.go | 28 ++ internal/action/command_test.go | 725 ++++++++++++++++++++++++++++++++ 3 files changed, 754 insertions(+), 61 deletions(-) create mode 100644 internal/action/command_test.go diff --git a/FEATURES.md b/FEATURES.md index bb63ea1..39598fa 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -219,7 +219,7 @@ - [x] `:q!` - Force quit - [x] `:e {file}` - Edit file - [x] `:bn` / `:bp` - Next/previous buffer -- [ ] `:{range}` - Go to line +- [x] `:{range}` - Go to line - [ ] `:%s/old/new/g` - Search and replace - [ ] `:!{cmd}` - Run shell command - [ ] `:help` - Show help @@ -408,63 +408,3 @@ Buffers are in-memory representations of files. A buffer exists for each open fi - [ ] Spell check --- - -### Well Tested - Editor Core - -#### Command Execution (179 tests) -- [x] Command parsing and validation -- [x] Command lookup and prefix matching -- [x] Force flag handling (!) -- [x] Write commands (`:w`, `:w {file}`, `:w!`) -- [x] Write all commands (`:wa`, `:wall`, `:wa!`) -- [x] Quit commands (`:q`, `:q!`, `:qa`, `:qa!`) -- [x] Write-quit commands (`:wq`, `:wq!`, `:wqa`, `:wqa!`) -- [x] Edit command (`:e {file}`) -- [x] Register display (`:register`, `:reg {name}`) -- [x] Set commands (`:set number`, `:set tabstop=N`, etc.) -- [x] Setting lookup and validation -- [x] Buffer-level readonly protection -- [x] Scratch buffer write protection -- [x] Force write bypassing readonly/scratch checks -- [x] Multiple buffer write operations -- [x] File write error handling (permissions, paths) -- [x] Modified buffer tracking -- [x] Unicode filename and content handling -- [x] Edge cases (empty args, long filenames, special chars) - -#### Program Initialization (70 tests) -- [x] Empty program creation -- [x] File program with nonexistent files (new file buffers) -- [x] File program with existing files (content loading) -- [x] Line ending handling (Unix `\n`, Windows `\r\n`, mixed) -- [x] Tab to space conversion based on TabStop -- [x] Unicode content preservation (CJK, emoji) -- [x] File extension and type detection -- [x] Buffer state initialization (flags, metadata) -- [x] Large file handling (10,000+ lines) -- [x] Long line handling (10,000+ chars) -- [x] Empty file handling -- [x] Builder pattern method chaining -- [x] Program option accumulation -- [x] Model state defaults (settings, registers, mode) -- [x] Error handling (permissions, invalid paths) -- [x] Integration workflows (end-to-end) -- [x] Edge cases (empty filenames, relative paths, dot files) - -### Moderately Tested -- [x] Basic motions (h, j, k, l) -- [x] Word motions (w, e, b) -- [x] Jump motions (G, gg, 0, $, _, ^, |) -- [x] Scroll actions (ctrl+u, ctrl+d) -- [x] Delete operator (d, dd) -- [x] Yank operator (y, yy) -- [x] Paste actions (p, P) -- [x] Change operator (c, cc, C) -- [x] Substitute action (s, S) -- [x] Insert mode entry (i, a, I, A, o, O) -- [x] Insert mode editing (enter, backspace, delete, tab, ctrl+w) -- [x] Visual modes (v, V, ctrl+v) -- [x] Visual mode with motions -- [x] Delete actions (x, D) -- [x] Register behavior - diff --git a/internal/action/command.go b/internal/action/command.go index ef9c16d..9b5d7f9 100644 --- a/internal/action/command.go +++ b/internal/action/command.go @@ -1,6 +1,8 @@ package action import ( + "strconv" + "git.gophernest.net/azpect/TextEditor/internal/core" tea "github.com/charmbracelet/bubbletea" ) @@ -139,6 +141,12 @@ func (a CommandExecute) Execute(m Model) tea.Cmd { // history = append([]string{cmdLine}, history...) m.SetCommandHistory(history) + // try to parse entire thing as a number + num, err := strconv.Atoi(cmdLine) + if err == nil { + return cmdGoToLine(m, num) + } + cmd, err := a.Registry.Execute(m, cmdLine) if err != nil { out := core.CommandOutput{ @@ -152,3 +160,23 @@ func (a CommandExecute) Execute(m Model) tea.Cmd { return cmd } + +// cmdGoToLine: DOES NOT implement command.Command. Instead, is called directly +// by CommandExecture.Execute(). Jumps the cursor to the line provided +func cmdGoToLine(m Model, line int) tea.Cmd { + // number below 0 just goes back that many + + win := m.ActiveWindow() + buf := m.ActiveBuffer() + + if line <= 0 { + newLine := max(0, win.Cursor.Line+line) + win.SetCursorLine(newLine) + return nil + } + + newLine := min(line-1, buf.LineCount()) + win.SetCursorLine(newLine) + + return nil +} diff --git a/internal/action/command_test.go b/internal/action/command_test.go new file mode 100644 index 0000000..5f3275f --- /dev/null +++ b/internal/action/command_test.go @@ -0,0 +1,725 @@ +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 : 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()) + } + }) +}