package editor import ( "testing" "git.gophernest.net/azpect/TextEditor/internal/core" ) func TestReplaceChar(t *testing.T) { t.Run("test 'rx' replaces character under cursor", func(t *testing.T) { lines := []string{"hello"} tm := newTestModelWithLines(t, lines) sendKeys(tm, "r", "x") m := getFinalModel(t, tm) if m.ActiveBuffer().Lines[0].String() != "xello" { t.Errorf("lines[0] = %q, want 'xello'", m.ActiveBuffer().Lines[0].String()) } }) t.Run("test 'r' in middle of line", func(t *testing.T) { lines := []string{"hello"} tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) sendKeys(tm, "r", "x") m := getFinalModel(t, tm) if m.ActiveBuffer().Lines[0].String() != "hexlo" { t.Errorf("lines[0] = %q, want 'hexlo'", m.ActiveBuffer().Lines[0].String()) } }) t.Run("test 'r' at end of line", func(t *testing.T) { lines := []string{"hello"} tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 0}) sendKeys(tm, "r", "x") m := getFinalModel(t, tm) if m.ActiveBuffer().Lines[0].String() != "hellx" { t.Errorf("lines[0] = %q, want 'hellx'", m.ActiveBuffer().Lines[0].String()) } }) t.Run("test 'r' cursor position remains at replaced char", func(t *testing.T) { lines := []string{"hello"} tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) sendKeys(tm, "r", "x") m := getFinalModel(t, tm) if m.ActiveWindow().Cursor.Col != 2 { t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col) } }) t.Run("test 'r' stays in normal mode", func(t *testing.T) { lines := []string{"hello"} tm := newTestModelWithLines(t, lines) sendKeys(tm, "r", "x") m := getFinalModel(t, tm) if m.Mode() != core.NormalMode { t.Errorf("Mode() = %v, want NormalMode", m.Mode()) } }) t.Run("test 'r' with space replaces with space", func(t *testing.T) { lines := []string{"hello"} tm := newTestModelWithLines(t, lines) sendKeys(tm, "r", " ") m := getFinalModel(t, tm) if m.ActiveBuffer().Lines[0].String() != " ello" { t.Errorf("lines[0] = %q, want ' ello'", m.ActiveBuffer().Lines[0].String()) } }) t.Run("test 'r' with digit replaces with digit", func(t *testing.T) { lines := []string{"hello"} tm := newTestModelWithLines(t, lines) sendKeys(tm, "r", "5") m := getFinalModel(t, tm) if m.ActiveBuffer().Lines[0].String() != "5ello" { t.Errorf("lines[0] = %q, want '5ello'", m.ActiveBuffer().Lines[0].String()) } }) t.Run("test 'r' with special char replaces correctly", func(t *testing.T) { lines := []string{"hello"} tm := newTestModelWithLines(t, lines) sendKeys(tm, "r", "@") m := getFinalModel(t, tm) if m.ActiveBuffer().Lines[0].String() != "@ello" { t.Errorf("lines[0] = %q, want '@ello'", m.ActiveBuffer().Lines[0].String()) } }) t.Run("test multiple 'r' operations in sequence", func(t *testing.T) { lines := []string{"hello"} tm := newTestModelWithLines(t, lines) sendKeys(tm, "r", "x", "l", "r", "y") m := getFinalModel(t, tm) if m.ActiveBuffer().Lines[0].String() != "xyllo" { t.Errorf("lines[0] = %q, want 'xyllo'", m.ActiveBuffer().Lines[0].String()) } }) } func TestReplaceCharWithCount(t *testing.T) { t.Run("test '3rx' replaces three characters", func(t *testing.T) { lines := []string{"hello"} tm := newTestModelWithLines(t, lines) sendKeys(tm, "3", "r", "x") m := getFinalModel(t, tm) if m.ActiveBuffer().Lines[0].String() != "xxxlo" { t.Errorf("lines[0] = %q, want 'xxxlo'", m.ActiveBuffer().Lines[0].String()) } }) t.Run("test '3rx' cursor position after replace", func(t *testing.T) { lines := []string{"hello"} tm := newTestModelWithLines(t, lines) sendKeys(tm, "3", "r", "x") m := getFinalModel(t, tm) // Cursor should be at last replaced character if m.ActiveWindow().Cursor.Col != 2 { t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col) } }) t.Run("test '5rx' replaces all five characters", func(t *testing.T) { lines := []string{"hello"} tm := newTestModelWithLines(t, lines) sendKeys(tm, "5", "r", "x") m := getFinalModel(t, tm) if m.ActiveBuffer().Lines[0].String() != "xxxxx" { t.Errorf("lines[0] = %q, want 'xxxxx'", m.ActiveBuffer().Lines[0].String()) } }) t.Run("test '10rx' with overflow stops at line end", func(t *testing.T) { lines := []string{"hello"} tm := newTestModelWithLines(t, lines) sendKeys(tm, "1", "0", "r", "x") m := getFinalModel(t, tm) // Should only replace 5 chars (all available) if m.ActiveBuffer().Lines[0].String() != "xxxxx" { t.Errorf("lines[0] = %q, want 'xxxxx'", m.ActiveBuffer().Lines[0].String()) } }) t.Run("test '2rx' from middle", func(t *testing.T) { lines := []string{"hello"} tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0}) sendKeys(tm, "2", "r", "x") m := getFinalModel(t, tm) if m.ActiveBuffer().Lines[0].String() != "hxxlo" { t.Errorf("lines[0] = %q, want 'hxxlo'", m.ActiveBuffer().Lines[0].String()) } }) t.Run("test '2rx' cursor position from middle", func(t *testing.T) { lines := []string{"hello"} tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0}) sendKeys(tm, "2", "r", "x") m := getFinalModel(t, tm) if m.ActiveWindow().Cursor.Col != 2 { t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col) } }) t.Run("test '4rx' from near end stops at line end", func(t *testing.T) { lines := []string{"hello"} tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) sendKeys(tm, "4", "r", "x") m := getFinalModel(t, tm) // Should only replace 2 chars (3 and 4) if m.ActiveBuffer().Lines[0].String() != "helxx" { t.Errorf("lines[0] = %q, want 'helxx'", m.ActiveBuffer().Lines[0].String()) } }) t.Run("test large count '100rx' doesn't crash", func(t *testing.T) { lines := []string{"short"} tm := newTestModelWithLines(t, lines) sendKeys(tm, "1", "0", "0", "r", "x") m := getFinalModel(t, tm) if m.ActiveBuffer().Lines[0].String() != "xxxxx" { t.Errorf("lines[0] = %q, want 'xxxxx'", m.ActiveBuffer().Lines[0].String()) } }) } func TestReplaceCharEdgeCases(t *testing.T) { t.Run("test 'r' on empty line does nothing", func(t *testing.T) { lines := []string{""} tm := newTestModelWithLines(t, lines) sendKeys(tm, "r", "x") m := getFinalModel(t, tm) if m.ActiveBuffer().Lines[0].String() != "" { t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0].String()) } if m.ActiveWindow().Cursor.Col != 0 { t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) } }) t.Run("test 'r' on single character line", func(t *testing.T) { lines := []string{"a"} tm := newTestModelWithLines(t, lines) sendKeys(tm, "r", "x") m := getFinalModel(t, tm) if m.ActiveBuffer().Lines[0].String() != "x" { t.Errorf("Line(0) = %q, want 'x'", m.ActiveBuffer().Lines[0].String()) } }) t.Run("test 'r' at last char replaces it", func(t *testing.T) { lines := []string{"ab"} tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0}) sendKeys(tm, "r", "x") m := getFinalModel(t, tm) if m.ActiveBuffer().Lines[0].String() != "ax" { t.Errorf("Line(0) = %q, want 'ax'", m.ActiveBuffer().Lines[0].String()) } }) t.Run("test 'r' with whitespace", func(t *testing.T) { lines := []string{"a b c"} tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0}) sendKeys(tm, "r", "x") m := getFinalModel(t, tm) if m.ActiveBuffer().Lines[0].String() != "axb c" { t.Errorf("Line(0) = %q, want 'axb c'", m.ActiveBuffer().Lines[0].String()) } }) t.Run("test 'r' preserves other lines", func(t *testing.T) { lines := []string{"hello", "world"} tm := newTestModelWithLines(t, lines) sendKeys(tm, "r", "x") m := getFinalModel(t, tm) if m.ActiveBuffer().LineCount() != 2 { t.Errorf("LineCount() = %d, want 2", m.ActiveBuffer().LineCount()) } if m.ActiveBuffer().Lines[1].String() != "world" { t.Errorf("Line(1) = %q, want 'world'", m.ActiveBuffer().Lines[1].String()) } }) t.Run("test 'r' on line with tabs", func(t *testing.T) { lines := []string{"a\tb"} tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0}) sendKeys(tm, "r", "x") m := getFinalModel(t, tm) if m.ActiveBuffer().Lines[0].String() != "axb" { t.Errorf("Line(0) = %q, want 'axb'", m.ActiveBuffer().Lines[0].String()) } }) t.Run("test '5rx' with only 3 chars available", func(t *testing.T) { lines := []string{"abc"} tm := newTestModelWithLines(t, lines) sendKeys(tm, "5", "r", "x") m := getFinalModel(t, tm) if m.ActiveBuffer().Lines[0].String() != "xxx" { t.Errorf("Line(0) = %q, want 'xxx'", m.ActiveBuffer().Lines[0].String()) } }) t.Run("test 'r' in middle preserves surrounding chars", func(t *testing.T) { lines := []string{"abcde"} tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) sendKeys(tm, "r", "x") m := getFinalModel(t, tm) if m.ActiveBuffer().Lines[0].String() != "abxde" { t.Errorf("Line(0) = %q, want 'abxde'", m.ActiveBuffer().Lines[0].String()) } }) t.Run("test 'r' on whitespace-only line", func(t *testing.T) { lines := []string{" "} tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) sendKeys(tm, "r", "x") m := getFinalModel(t, tm) if m.ActiveBuffer().Lines[0].String() != " x " { t.Errorf("Line(0) = %q, want ' x '", m.ActiveBuffer().Lines[0].String()) } }) t.Run("test 'r' on different lines independently", func(t *testing.T) { lines := []string{"hello", "world"} tm := newTestModelWithLines(t, lines) sendKeys(tm, "r", "x", "j", "r", "y") m := getFinalModel(t, tm) if m.ActiveBuffer().Lines[0].String() != "xello" { t.Errorf("Line(0) = %q, want 'xello'", m.ActiveBuffer().Lines[0].String()) } if m.ActiveBuffer().Lines[1].String() != "yorld" { t.Errorf("Line(1) = %q, want 'yorld'", m.ActiveBuffer().Lines[1].String()) } }) t.Run("test 'r' does not affect line count", func(t *testing.T) { lines := []string{"line 1", "line 2", "line 3"} tm := newTestModelWithLines(t, lines) sendKeys(tm, "r", "x") m := getFinalModel(t, tm) if m.ActiveBuffer().LineCount() != 3 { t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount()) } }) t.Run("test 'r' with newline character is treated as single char", func(t *testing.T) { lines := []string{"hello"} tm := newTestModelWithLines(t, lines) // Note: In vim, 'r' with enter doesn't work the same way // This test documents the current behavior sendKeys(tm, "r", "\n") m := getFinalModel(t, tm) // Replacing with "\n" string (not actual newline) if m.ActiveBuffer().Lines[0].String() != "\nello" { t.Errorf("Line(0) = %q, want '\\nello'", m.ActiveBuffer().Lines[0].String()) } }) } func TestReplaceCharWithMotion(t *testing.T) { t.Run("test 'lrx' moves then replaces", func(t *testing.T) { lines := []string{"hello"} tm := newTestModelWithLines(t, lines) sendKeys(tm, "l", "r", "x") m := getFinalModel(t, tm) if m.ActiveBuffer().Lines[0].String() != "hxllo" { t.Errorf("lines[0] = %q, want 'hxllo'", m.ActiveBuffer().Lines[0].String()) } }) t.Run("test 'wrx' moves to next word start then replaces", func(t *testing.T) { lines := []string{"hello world"} tm := newTestModelWithLines(t, lines) sendKeys(tm, "w", "r", "x") m := getFinalModel(t, tm) if m.ActiveBuffer().Lines[0].String() != "hello xorld" { t.Errorf("lines[0] = %q, want 'hello xorld'", m.ActiveBuffer().Lines[0].String()) } }) t.Run("test '$rx' moves to end then replaces", func(t *testing.T) { lines := []string{"hello"} tm := newTestModelWithLines(t, lines) sendKeys(tm, "$", "r", "x") m := getFinalModel(t, tm) if m.ActiveBuffer().Lines[0].String() != "hellx" { t.Errorf("lines[0] = %q, want 'hellx'", m.ActiveBuffer().Lines[0].String()) } }) t.Run("test '2lrx' moves 2 right then replaces", func(t *testing.T) { lines := []string{"hello"} tm := newTestModelWithLines(t, lines) sendKeys(tm, "2", "l", "r", "x") m := getFinalModel(t, tm) if m.ActiveBuffer().Lines[0].String() != "hexlo" { t.Errorf("lines[0] = %q, want 'hexlo'", m.ActiveBuffer().Lines[0].String()) } }) } func TestReplaceCharUndo(t *testing.T) { t.Run("test 'rx' can be undone with 'u'", func(t *testing.T) { lines := []string{"hello"} tm := newTestModelWithLines(t, lines) sendKeys(tm, "r", "x", "u") m := getFinalModel(t, tm) if m.ActiveBuffer().Lines[0].String() != "hello" { t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0].String()) } }) t.Run("test '3rx' can be undone with 'u'", func(t *testing.T) { lines := []string{"hello"} tm := newTestModelWithLines(t, lines) sendKeys(tm, "3", "r", "x", "u") m := getFinalModel(t, tm) if m.ActiveBuffer().Lines[0].String() != "hello" { t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0].String()) } }) t.Run("test multiple 'r' operations can be undone separately", func(t *testing.T) { lines := []string{"hello"} tm := newTestModelWithLines(t, lines) sendKeys(tm, "r", "x", "l", "r", "y", "u") m := getFinalModel(t, tm) // Only the last 'ry' should be undone if m.ActiveBuffer().Lines[0].String() != "xello" { t.Errorf("lines[0] = %q, want 'xello'", m.ActiveBuffer().Lines[0].String()) } }) t.Run("test undo then redo 'rx'", func(t *testing.T) { lines := []string{"hello"} tm := newTestModelWithLines(t, lines) sendKeys(tm, "r", "x", "u", "ctrl+r") m := getFinalModel(t, tm) if m.ActiveBuffer().Lines[0].String() != "xello" { t.Errorf("lines[0] = %q, want 'xello'", m.ActiveBuffer().Lines[0].String()) } }) } func TestReplaceCharRepeat(t *testing.T) { t.Run("test 'rx' can be repeated with '.'", func(t *testing.T) { lines := []string{"hello"} tm := newTestModelWithLines(t, lines) sendKeys(tm, "r", "x", "l", ".") m := getFinalModel(t, tm) if m.ActiveBuffer().Lines[0].String() != "xxllo" { t.Errorf("lines[0] = %q, want 'xxllo'", m.ActiveBuffer().Lines[0].String()) } }) t.Run("test '3rx' can be repeated with '.'", func(t *testing.T) { lines := []string{"hello world"} tm := newTestModelWithLines(t, lines) sendKeys(tm, "3", "r", "x", "w", ".") m := getFinalModel(t, tm) // First 3rx at position 0, second at position 6 if m.ActiveBuffer().Lines[0].String() != "xxxlo xxxld" { t.Errorf("lines[0] = %q, want 'xxxlo xxxld'", m.ActiveBuffer().Lines[0].String()) } }) t.Run("test '.' repeats last replace char", func(t *testing.T) { lines := []string{"hello"} tm := newTestModelWithLines(t, lines) sendKeys(tm, "r", "a", "l", ".", "l", ".") m := getFinalModel(t, tm) if m.ActiveBuffer().Lines[0].String() != "aaalo" { t.Errorf("lines[0] = %q, want 'aaalo'", m.ActiveBuffer().Lines[0].String()) } }) // NOTE: This is the same as Vim's handling, but that is okay, I like this more, feels more honest t.Run("test '.' with count multiplies", func(t *testing.T) { lines := []string{"hello world"} tm := newTestModelWithLines(t, lines) sendKeys(tm, "2", "r", "x", "w", "3", ".") m := getFinalModel(t, tm) if m.ActiveBuffer().Lines[0].String() != "xxllo xxxxx" { t.Errorf("lines[0] = %q, want 'xxllo xxxxx'", m.ActiveBuffer().Lines[0].String()) } }) } func TestReplaceCharMultiLine(t *testing.T) { t.Run("test 'r' on second line", func(t *testing.T) { lines := []string{"first", "second"} tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1}) sendKeys(tm, "r", "x") m := getFinalModel(t, tm) if m.ActiveBuffer().Lines[0].String() != "first" { t.Errorf("Line(0) = %q, want 'first'", m.ActiveBuffer().Lines[0].String()) } if m.ActiveBuffer().Lines[1].String() != "xecond" { t.Errorf("Line(1) = %q, want 'xecond'", m.ActiveBuffer().Lines[1].String()) } }) t.Run("test 'r' does not cross line boundaries", func(t *testing.T) { lines := []string{"ab", "cd"} tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0}) sendKeys(tm, "5", "r", "x") m := getFinalModel(t, tm) // Should only replace 'b', not cross to next line if m.ActiveBuffer().Lines[0].String() != "ax" { t.Errorf("Line(0) = %q, want 'ax'", m.ActiveBuffer().Lines[0].String()) } if m.ActiveBuffer().Lines[1].String() != "cd" { t.Errorf("Line(1) = %q, want 'cd'", m.ActiveBuffer().Lines[1].String()) } }) t.Run("test 'r' on last line", func(t *testing.T) { lines := []string{"first", "second", "third"} tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 2}) sendKeys(tm, "r", "x") m := getFinalModel(t, tm) if m.ActiveBuffer().Lines[2].String() != "xhird" { t.Errorf("Line(2) = %q, want 'xhird'", m.ActiveBuffer().Lines[2].String()) } }) } func TestReplaceCharCombinations(t *testing.T) { t.Run("test 'frx' finds then replaces", func(t *testing.T) { lines := []string{"hello world"} tm := newTestModelWithLines(t, lines) sendKeys(tm, "f", "w", "r", "x") m := getFinalModel(t, tm) if m.ActiveBuffer().Lines[0].String() != "hello xorld" { t.Errorf("lines[0] = %q, want 'hello xorld'", m.ActiveBuffer().Lines[0].String()) } }) t.Run("test '2frx' finds second occurrence then replaces", func(t *testing.T) { lines := []string{"hello hello"} tm := newTestModelWithLines(t, lines) sendKeys(tm, "2", "f", "l", "r", "x") m := getFinalModel(t, tm) // Find second 'l', which is at position 3 if m.ActiveBuffer().Lines[0].String() != "helxo hello" { t.Errorf("lines[0] = %q, want 'helxo hello'", m.ActiveBuffer().Lines[0].String()) } }) t.Run("test '^rx' moves to first non-blank then replaces", func(t *testing.T) { lines := []string{" hello"} tm := newTestModelWithLines(t, lines) sendKeys(tm, "^", "r", "x") m := getFinalModel(t, tm) if m.ActiveBuffer().Lines[0].String() != " xello" { t.Errorf("lines[0] = %q, want ' xello'", m.ActiveBuffer().Lines[0].String()) } }) }