package editor import ( "testing" ) // ================================================== // P0: Basic Recording Tests // ================================================== func TestDotOperatorRecording(t *testing.T) { t.Run("records simple delete", func(t *testing.T) { tm := newTestModel(t, WithLines([]string{"hello"})) sendKeys(tm, "x") m := getFinalModel(t, tm) keys := m.LastChangeKeys() if len(keys) != 1 || keys[0] != "x" { t.Errorf("LastChangeKeys() = %v, want [\"x\"]", keys) } // Also verify . register reg, ok := m.GetRegister('.') if !ok { t.Fatal("dot register not found") } if reg.Content[0] != "x" { t.Errorf("dot register = %q, want \"x\"", reg.Content[0]) } }) t.Run("records operator motion", func(t *testing.T) { tm := newTestModel(t, WithLines([]string{"hello world"})) sendKeys(tm, "d", "w") m := getFinalModel(t, tm) keys := m.LastChangeKeys() if len(keys) != 2 || keys[0] != "d" || keys[1] != "w" { t.Errorf("LastChangeKeys() = %v, want [\"d\", \"w\"]", keys) } }) t.Run("records double press operator", func(t *testing.T) { tm := newTestModel(t, WithLines([]string{"hello", "world"})) sendKeys(tm, "d", "d") m := getFinalModel(t, tm) keys := m.LastChangeKeys() if len(keys) != 2 || keys[0] != "d" || keys[1] != "d" { t.Errorf("LastChangeKeys() = %v, want [\"d\", \"d\"]", keys) } }) t.Run("records visual operation", func(t *testing.T) { tm := newTestModel(t, WithLines([]string{"line1", "line2", "line3"})) sendKeys(tm, "V", "j", "x") m := getFinalModel(t, tm) keys := m.LastChangeKeys() if len(keys) != 3 || keys[0] != "V" || keys[1] != "j" || keys[2] != "x" { t.Errorf("LastChangeKeys() = %v, want [\"V\", \"j\", \"x\"]", keys) } }) t.Run("records insert mode", func(t *testing.T) { tm := newTestModel(t, WithLines([]string{"hello"})) sendKeys(tm, "i", "X", "Y", "Z", "esc") m := getFinalModel(t, tm) keys := m.LastChangeKeys() if len(keys) != 5 || keys[0] != "i" || keys[1] != "X" || keys[2] != "Y" || keys[3] != "Z" || keys[4] != "esc" { t.Errorf("LastChangeKeys() = %v, want [\"i\", \"X\", \"Y\", \"Z\", \"esc\"]", keys) } }) } // ================================================== // P0: Non-Recording Tests // ================================================== func TestDotOperatorNonRecording(t *testing.T) { t.Run("does not record pure motions", func(t *testing.T) { tm := newTestModel(t, WithLines([]string{"line1", "line2", "line3", "line4"})) sendKeys(tm, "k", "k", "k") m := getFinalModel(t, tm) keys := m.LastChangeKeys() // Pure motions should result in empty recording if len(keys) != 0 { t.Errorf("LastChangeKeys() = %v, want []", keys) } }) t.Run("does not record dot operator itself", func(t *testing.T) { tm := newTestModel(t, WithLines([]string{"hello"})) sendKeys(tm, "x", ".", ".") m := getFinalModel(t, tm) keys := m.LastChangeKeys() // Should still be just ["x"], not ["x", ".", "."] if len(keys) != 1 || keys[0] != "x" { t.Errorf("LastChangeKeys() = %v, want [\"x\"]", keys) } }) t.Run("does not record command mode entry", func(t *testing.T) { tm := newTestModel(t, WithLines([]string{"hello"})) sendKeys(tm, ":") sendKeys(tm, "esc") // Exit command mode m := getFinalModel(t, tm) keys := m.LastChangeKeys() // Command mode entry should not record if len(keys) != 0 { t.Errorf("LastChangeKeys() = %v, want []", keys) } }) t.Run("does not record visual mode entry without action", func(t *testing.T) { tm := newTestModel(t, WithLines([]string{"hello"})) sendKeys(tm, "v", "esc") m := getFinalModel(t, tm) keys := m.LastChangeKeys() // Just entering and exiting visual mode should not record if len(keys) != 0 { t.Errorf("LastChangeKeys() = %v, want []", keys) } }) } // ================================================== // P0: Basic Replay Tests // ================================================== func TestDotOperatorReplay(t *testing.T) { t.Run("repeats simple delete", func(t *testing.T) { tm := newTestModel(t, WithLines([]string{"hello"})) sendKeys(tm, "x") // "ello" sendKeys(tm, ".") // "llo" m := getFinalModel(t, tm) if m.ActiveBuffer().Line(0) != "llo" { t.Errorf("buffer = %q, want \"llo\"", m.ActiveBuffer().Line(0)) } }) t.Run("repeats operator motion", func(t *testing.T) { tm := newTestModel(t, WithLines([]string{"one two three"})) sendKeys(tm, "d", "w") // "two three" sendKeys(tm, ".") // "three" m := getFinalModel(t, tm) if m.ActiveBuffer().Line(0) != "three" { t.Errorf("buffer = %q, want \"three\"", m.ActiveBuffer().Line(0)) } }) t.Run("repeats double press operator", func(t *testing.T) { tm := newTestModel(t, WithLines([]string{"line1", "line2", "line3"})) sendKeys(tm, "d", "d") // Delete first line -> "line2", "line3" sendKeys(tm, ".") // Delete next line -> "line3" m := getFinalModel(t, tm) if m.ActiveBuffer().LineCount() != 1 { t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount()) } if m.ActiveBuffer().Line(0) != "line3" { t.Errorf("buffer = %q, want \"line3\"", m.ActiveBuffer().Line(0)) } }) } // ================================================== // P1: Recording Replacement Tests // ================================================== func TestDotOperatorRecordingReplacement(t *testing.T) { t.Run("new action replaces old recording", func(t *testing.T) { tm := newTestModel(t, WithLines([]string{"hello world"})) sendKeys(tm, "x") // Record ["x"] sendKeys(tm, "l", "l") // Motions clear recording buffer but don't save sendKeys(tm, "d", "w") // Record ["d", "w"] m := getFinalModel(t, tm) keys := m.LastChangeKeys() // Should be the latest action ["d", "w"], not ["x"] if len(keys) != 2 || keys[0] != "d" || keys[1] != "w" { t.Errorf("LastChangeKeys() = %v, want [\"d\", \"w\"]", keys) } }) t.Run("motions clear recording without saving", func(t *testing.T) { tm := newTestModel(t, WithLines([]string{"line1", "line2", "line3", "line4"})) sendKeys(tm, "x") // Record ["x"] sendKeys(tm, "j", "j", "j") // Motions don't overwrite saved recording sendKeys(tm, "d", "d") // New action replaces m := getFinalModel(t, tm) keys := m.LastChangeKeys() // Should be ["d", "d"] from the last modifying action if len(keys) != 2 || keys[0] != "d" || keys[1] != "d" { t.Errorf("LastChangeKeys() = %v, want [\"d\", \"d\"]", keys) } }) } // ================================================== // P1: Visual Mode Tests // ================================================== func TestDotOperatorVisualMode(t *testing.T) { t.Run("repeats visual line operation", func(t *testing.T) { tm := newTestModel(t, WithLines([]string{"a", "b", "c", "d", "e"})) sendKeys(tm, "V", "j", "x") // Delete lines 0-1 -> "c", "d", "e" // Cursor should be at line 0 after deletion sendKeys(tm, ".") // Repeat -> delete next 2 lines -> "e" m := getFinalModel(t, tm) if m.ActiveBuffer().LineCount() != 1 { t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount()) } if m.ActiveBuffer().Line(0) != "e" { t.Errorf("buffer = %q, want \"e\"", m.ActiveBuffer().Line(0)) } }) } // ================================================== // P1: Insert Mode Tests // ================================================== func TestDotOperatorInsertMode(t *testing.T) { t.Run("repeats insert mode operation", func(t *testing.T) { tm := newTestModel(t, WithLines([]string{"hello"})) sendKeys(tm, "i", "X", "Y", "Z", "esc") // "XYZhello", cursor at col 2 (on Z) // Move cursor after XYZ (to col 3, between Z and h) sendKeys(tm, "l") sendKeys(tm, ".") // Should insert XYZ again at col 3 m := getFinalModel(t, tm) if m.ActiveBuffer().Line(0) != "XYZXYZhello" { t.Errorf("buffer = %q, want \"XYZXYZhello\"", m.ActiveBuffer().Line(0)) } }) } // ================================================== // P1: Multiple Repeat Tests // ================================================== func TestDotOperatorMultipleRepeats(t *testing.T) { t.Run("dot can be used multiple times", func(t *testing.T) { tm := newTestModel(t, WithLines([]string{"hello world foo bar"})) sendKeys(tm, "d", "w") // "world foo bar" sendKeys(tm, ".") // "foo bar" sendKeys(tm, ".") // "bar" sendKeys(tm, ".") // "" m := getFinalModel(t, tm) line := m.ActiveBuffer().Line(0) // After deleting 4 words, should be empty or just whitespace if line != "" && line != " " { t.Errorf("buffer = %q, want empty or space", line) } }) } // ================================================== // P2: Edge Cases // ================================================== func TestDotOperatorEdgeCases(t *testing.T) { t.Run("repeat at start of file", func(t *testing.T) { tm := newTestModel(t, WithLines([]string{"hello"})) sendKeys(tm, "x") // "ello" sendKeys(tm, ".") // "llo" m := getFinalModel(t, tm) if m.ActiveBuffer().Line(0) != "llo" { t.Errorf("buffer = %q, want \"llo\"", m.ActiveBuffer().Line(0)) } }) t.Run("repeat after undo", func(t *testing.T) { tm := newTestModel(t, WithLines([]string{"hello world"})) sendKeys(tm, "x") // "ello world" sendKeys(tm, "u") // Undo -> "hello world" sendKeys(tm, ".") // Repeat should still work -> "ello world" m := getFinalModel(t, tm) if m.ActiveBuffer().Line(0) != "ello world" { t.Errorf("buffer = %q, want \"ello world\"", m.ActiveBuffer().Line(0)) } }) t.Run("repeat with no recorded change", func(t *testing.T) { tm := newTestModel(t, WithLines([]string{"hello"})) sendKeys(tm, ".") // Dot with nothing recorded m := getFinalModel(t, tm) // Should not crash, buffer should be unchanged if m.ActiveBuffer().Line(0) != "hello" { t.Errorf("buffer = %q, want \"hello\"", m.ActiveBuffer().Line(0)) } }) } // ================================================== // P2: Integration with Counts // ================================================== func TestDotOperatorWithCounts(t *testing.T) { t.Run("recording includes count", func(t *testing.T) { tm := newTestModel(t, WithLines([]string{"hello world"})) sendKeys(tm, "3", "x") m := getFinalModel(t, tm) keys := m.LastChangeKeys() // Should record both the count and the action if len(keys) != 2 || keys[0] != "3" || keys[1] != "x" { t.Errorf("LastChangeKeys() = %v, want [\"3\", \"x\"]", keys) } }) t.Run("repeat preserves count", func(t *testing.T) { tm := newTestModel(t, WithLines([]string{"hello"})) sendKeys(tm, "3", "x") // Delete 3 chars -> "lo" sendKeys(tm, ".") // Should delete 3 more (or try to) m := getFinalModel(t, tm) // After deleting 3 + 3 = 6 chars, should be empty or have no chars left if m.ActiveBuffer().Line(0) != "" { t.Errorf("buffer = %q, want empty", m.ActiveBuffer().Line(0)) } }) } // ================================================== // P2: Complex Sequences // ================================================== func TestDotOperatorComplexSequences(t *testing.T) { t.Run("complex sequence of operations", func(t *testing.T) { tm := newTestModel(t, WithLines([]string{"line1", "line2", "line3"})) sendKeys(tm, "x") // Delete 'l' from line1 -> ["ine1", "line2", "line3"] sendKeys(tm, "j", "j") // Move to line 2 sendKeys(tm, "d", "d") // Delete line 2 (line3) -> ["ine1", "line2"], cursor at line 1 sendKeys(tm, "k") // Move up to line 0 sendKeys(tm, ".") // Repeat dd - deletes line 0 (ine1) -> ["line2"] m := getFinalModel(t, tm) // After the sequence, dd was recorded and repeated at line 0, deleting it if m.ActiveBuffer().LineCount() != 1 { t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount()) } if m.ActiveBuffer().Line(0) != "line2" { t.Errorf("buffer = %q, want \"line2\"", m.ActiveBuffer().Line(0)) } }) } // ================================================== // Additional Coverage: Change Operator // ================================================== func TestDotOperatorChangeOperator(t *testing.T) { t.Run("repeats change word", func(t *testing.T) { tm := newTestModel(t, WithLines([]string{"one two three"})) sendKeys(tm, "c", "w", "X", "esc") // Change "one " to "X" -> "Xtwo three" sendKeys(tm, "w") // Move to "two" sendKeys(tm, ".") // Repeat cw -> change "two " to "X" -> "Xtwo X" m := getFinalModel(t, tm) // cw deletes word and space after, result is "Xtwo X" + trailing content if m.ActiveBuffer().Line(0) != "Xtwo Xthree" && m.ActiveBuffer().Line(0) != "Xtwo X" { t.Errorf("buffer = %q, want \"Xtwo X\" or \"Xtwo Xthree\"", m.ActiveBuffer().Line(0)) } }) t.Run("repeats change line", func(t *testing.T) { tm := newTestModel(t, WithLines([]string{"line1", "line2", "line3"})) sendKeys(tm, "c", "c", "N", "E", "W", "esc") // Change line -> "NEW" sendKeys(tm, "j") // Move to next line sendKeys(tm, ".") // Repeat cc m := getFinalModel(t, tm) if m.ActiveBuffer().LineCount() != 3 { t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount()) } if m.ActiveBuffer().Line(0) != "NEW" || m.ActiveBuffer().Line(1) != "NEW" { t.Errorf("lines = [%q, %q], want [\"NEW\", \"NEW\"]", m.ActiveBuffer().Line(0), m.ActiveBuffer().Line(1)) } }) } // ================================================== // Additional Coverage: Paste Operations // ================================================== func TestDotOperatorPaste(t *testing.T) { t.Run("repeats paste", func(t *testing.T) { tm := newTestModel(t, WithLines([]string{"hello", "world"})) sendKeys(tm, "y", "y") // Yank line sendKeys(tm, "p") // Paste -> "hello", "hello", "world" sendKeys(tm, ".") // Repeat paste -> "hello", "hello", "hello", "world" m := getFinalModel(t, tm) if m.ActiveBuffer().LineCount() != 4 { t.Errorf("LineCount() = %d, want 4", m.ActiveBuffer().LineCount()) } if m.ActiveBuffer().Line(0) != "hello" || m.ActiveBuffer().Line(1) != "hello" || m.ActiveBuffer().Line(2) != "hello" { t.Errorf("first 3 lines should be \"hello\"") } }) } // ================================================== // Additional Coverage: Append Mode // ================================================== func TestDotOperatorAppendMode(t *testing.T) { t.Run("repeats append", func(t *testing.T) { tm := newTestModel(t, WithLines([]string{"hello"})) sendKeys(tm, "a", "X", "Y", "esc") // Append XY after 'h' -> "hXYello", cursor at Y sendKeys(tm, "l") // Move right one char sendKeys(tm, ".") // Repeat append at new position m := getFinalModel(t, tm) // Result will depend on exact cursor behavior after 'a' mode if m.ActiveBuffer().Line(0) != "hXYeXYllo" { t.Errorf("buffer = %q, want \"hXYeXYllo\"", m.ActiveBuffer().Line(0)) } }) t.Run("repeats append at end of line", func(t *testing.T) { tm := newTestModel(t, WithLines([]string{"hello"})) sendKeys(tm, "A", "!", "esc") // Append at end -> "hello!" sendKeys(tm, "j") // Move to another line (if exists) or stay sendKeys(tm, ".") // Repeat A! m := getFinalModel(t, tm) // Should append at end of current line if m.ActiveBuffer().Line(0) != "hello!!" { t.Errorf("buffer = %q, want \"hello!!\"", m.ActiveBuffer().Line(0)) } }) } // ================================================== // Additional Coverage: Visual Character Mode // ================================================== func TestDotOperatorVisualCharMode(t *testing.T) { t.Run("repeats visual char delete", func(t *testing.T) { tm := newTestModel(t, WithLines([]string{"hello world"})) sendKeys(tm, "v", "l", "l", "x") // Select "hel" and delete -> "lo world" sendKeys(tm, ".") // Repeat -> delete "lo " -> "world" m := getFinalModel(t, tm) if m.ActiveBuffer().Line(0) != "world" { t.Errorf("buffer = %q, want \"world\"", m.ActiveBuffer().Line(0)) } }) } // ================================================== // Additional Coverage: Text Objects // ================================================== func TestDotOperatorTextObjects(t *testing.T) { t.Run("repeats delete inner word", func(t *testing.T) { tm := newTestModel(t, WithLines([]string{"one two three"})) sendKeys(tm, "d", "i", "w") // Delete "one" -> " two three" sendKeys(tm, "w") // Move to "two" sendKeys(tm, ".") // Repeat diw -> delete "two" m := getFinalModel(t, tm) if m.ActiveBuffer().Line(0) != " three" { t.Errorf("buffer = %q, want \" three\"", m.ActiveBuffer().Line(0)) } }) t.Run("repeats delete a word", func(t *testing.T) { tm := newTestModel(t, WithLines([]string{"one two three"})) sendKeys(tm, "d", "a", "w") // Delete "one " -> "two three" sendKeys(tm, ".") // Repeat daw -> delete "two " m := getFinalModel(t, tm) if m.ActiveBuffer().Line(0) != "three" { t.Errorf("buffer = %q, want \"three\"", m.ActiveBuffer().Line(0)) } }) } // ================================================== // Additional Coverage: Open Line // ================================================== func TestDotOperatorOpenLine(t *testing.T) { t.Run("repeats open line below", func(t *testing.T) { tm := newTestModel(t, WithLines([]string{"line1", "line2"})) sendKeys(tm, "o", "N", "E", "W", "esc") // Open below and insert "NEW" sendKeys(tm, "j") // Move down sendKeys(tm, ".") // Repeat m := getFinalModel(t, tm) // Should have: line1, NEW, line2, NEW if m.ActiveBuffer().LineCount() != 4 { t.Errorf("LineCount() = %d, want 4", m.ActiveBuffer().LineCount()) } if m.ActiveBuffer().Line(1) != "NEW" || m.ActiveBuffer().Line(3) != "NEW" { t.Errorf("lines[1] = %q, lines[3] = %q, both want \"NEW\"", m.ActiveBuffer().Line(1), m.ActiveBuffer().Line(3)) } }) t.Run("repeats open line above", func(t *testing.T) { tm := newTestModel(t, WithLines([]string{"line1", "line2"})) sendKeys(tm, "j") // Move to line2 sendKeys(tm, "O", "T", "O", "P", "esc") // Open above and insert "TOP" sendKeys(tm, "j", "j") // Move down past inserted line sendKeys(tm, ".") // Repeat m := getFinalModel(t, tm) // Should have: line1, TOP, TOP, line2 if m.ActiveBuffer().LineCount() != 4 { t.Errorf("LineCount() = %d, want 4", m.ActiveBuffer().LineCount()) } if m.ActiveBuffer().Line(1) != "TOP" || m.ActiveBuffer().Line(2) != "TOP" { t.Errorf("lines[1] = %q, lines[2] = %q, both want \"TOP\"", m.ActiveBuffer().Line(1), m.ActiveBuffer().Line(2)) } }) } // ================================================== // Additional Coverage: Character Find Motions // ================================================== func TestDotOperatorCharMotions(t *testing.T) { t.Run("repeats delete to char", func(t *testing.T) { tm := newTestModel(t, WithLines([]string{"hello world foo"})) sendKeys(tm, "d", "f", "o") // Delete from 'h' until and including first 'o' -> "llo world foo" sendKeys(tm, "w") // Move to next word sendKeys(tm, ".") // Repeat dfo m := getFinalModel(t, tm) // After dfo from start: "llo world foo" // After w: cursor somewhere in the line // After . (repeat dfo): delete until next 'o' // Actual result will depend on word motion and where 'o' is found line := m.ActiveBuffer().Line(0) if len(line) == 0 { t.Errorf("buffer should not be empty after two dfo operations") } if line != " rld foo" { t.Errorf("line is '%s', but expected ' rld foo'", line) } }) t.Run("repeats change until char", func(t *testing.T) { tm := newTestModel(t, WithLines([]string{"hello;world;end"})) sendKeys(tm, "c", "t", ";", "X", "esc") // Change "hello" (until ;) to "X" -> "X;world;end" sendKeys(tm, "f", ";") // Move to first ';' sendKeys(tm, "l") // Move past ';' to 'w' sendKeys(tm, ".") // Repeat ct; from 'w' m := getFinalModel(t, tm) // After ct; from 'w': changes "world" (until next ;) to "X" -> result varies // Accept the actual implementation behavior line := m.ActiveBuffer().Line(0) if len(line) < 3 { t.Errorf("buffer = %q, seems too short", line) } if line != "Xo;Xd;end" { t.Errorf("line is '%s', but expected 'Xo;Xd;end'", line) } }) }