Gim/internal/editor/integration_replace_test.go
Hayden Hargreaves 32fe3f1edd
All checks were successful
Run Test Suite / test (push) Successful in 18s
fix: fixed replace action (r) in visual mode, tested
2026-04-06 21:14:56 -07:00

1159 lines
36 KiB
Go

package editor
import (
"testing"
"git.gophernest.net/azpect/TextEditor/internal/core"
)
// ==================================================
// Replace Char (r) Tests
// ==================================================
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())
}
})
}
// ==================================================
// Replace Mode (R) Tests
// ==================================================
func TestReplaceModeEntry(t *testing.T) {
t.Run("test 'R' enters replace mode", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R")
m := getFinalModel(t, tm)
if m.Mode() != core.ReplaceMode {
t.Errorf("Mode() = %v, want ReplaceMode", m.Mode())
}
})
t.Run("test 'esc' exits replace mode to normal", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R", "x", "esc")
m := getFinalModel(t, tm)
if m.Mode() != core.NormalMode {
t.Errorf("Mode() = %v, want NormalMode", m.Mode())
}
})
}
func TestReplaceModeBasic(t *testing.T) {
t.Run("test single character overwrites", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R", "X", "esc")
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 multiple characters overwrite", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R")
sendKeyString(tm, "abc")
sendKeys(tm, "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "abclo" {
t.Errorf("lines[0] = %q, want 'abclo'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test typing past end of line appends", func(t *testing.T) {
lines := []string{"hi"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "R")
sendKeyString(tm, "there")
sendKeys(tm, "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hithere" {
t.Errorf("lines[0] = %q, want 'hithere'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test replace mode starting at end of line", func(t *testing.T) {
lines := []string{"hi"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "R")
sendKeyString(tm, "!!")
sendKeys(tm, "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hi!!" {
t.Errorf("lines[0] = %q, want 'hi!!'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test replace mode from middle of line", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "R")
sendKeyString(tm, "XX")
sendKeys(tm, "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "heXXo" {
t.Errorf("lines[0] = %q, want 'heXXo'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test cursor position after exiting replace mode", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R")
sendKeyString(tm, "abc")
sendKeys(tm, "esc")
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)
}
})
}
func TestReplaceModeBackspace(t *testing.T) {
t.Run("test backspace deletes character", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R")
sendKeyString(tm, "abc")
sendKeys(tm, "backspace", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "ablo" {
t.Errorf("lines[0] = %q, want 'ablo'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test backspace at start does nothing", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R", "backspace", "esc")
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 backspaces", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R")
sendKeyString(tm, "abc")
sendKeys(tm, "backspace", "backspace", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "alo" {
t.Errorf("lines[0] = %q, want 'alo'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test backspace then type more", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R")
sendKeyString(tm, "abc")
sendKeys(tm, "backspace")
sendKeyString(tm, "XY")
sendKeys(tm, "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "abXY" {
t.Errorf("lines[0] = %q, want 'abXY'", m.ActiveBuffer().Lines[0].String())
}
})
}
func TestReplaceModeNavigation(t *testing.T) {
t.Run("test right arrow moves cursor", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R", "right", "right", "X", "esc")
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 left arrow moves cursor", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "R", "left", "X", "esc")
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 up/down arrows navigate between lines", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R", "X", "down", "Y", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "Xello" {
t.Errorf("lines[0] = %q, want 'Xello'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1].String() != "wYrld" {
t.Errorf("lines[1] = %q, want 'wYrld'", m.ActiveBuffer().Lines[1].String())
}
})
t.Run("test up arrow from first line stays on first line", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R", "up", "X", "esc")
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 down arrow from last line stays on last line", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1})
sendKeys(tm, "R", "down", "X", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[1].String() != "Xorld" {
t.Errorf("lines[1] = %q, want 'Xorld'", m.ActiveBuffer().Lines[1].String())
}
})
}
func TestReplaceModeSpecialKeys(t *testing.T) {
t.Run("test enter splits line", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 0})
sendKeys(tm, "R", "enter", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1].String() != "world" {
t.Errorf("lines[1] = %q, want 'world'", m.ActiveBuffer().Lines[1].String())
}
})
t.Run("test tab inserts tab character", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R", "tab", "esc")
m := getFinalModel(t, tm)
// Tab is expanded to spaces based on tabstop setting (default 2)
if m.ActiveBuffer().Lines[0].String() != " ello" {
t.Errorf("lines[0] = %q, want ' ello'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test ctrl+w deletes previous word", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R")
sendKeyString(tm, "foo bar")
sendKeys(tm, "ctrl+w", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "foo orld" {
t.Errorf("lines[0] = %q, want 'foo orld'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test ctrl+w at beginning does nothing", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R", "ctrl+w", "esc")
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 delete key removes character ahead", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R", "delete", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "ello" {
t.Errorf("lines[0] = %q, want 'ello'", m.ActiveBuffer().Lines[0].String())
}
})
}
func TestReplaceModeUndo(t *testing.T) {
t.Run("test replace mode changes can be undone", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R")
sendKeyString(tm, "abc")
sendKeys(tm, "esc", "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 replace mode changes can be redone", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R")
sendKeyString(tm, "abc")
sendKeys(tm, "esc", "u", "ctrl+r")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "abclo" {
t.Errorf("lines[0] = %q, want 'abclo'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test multiple undo operations", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R")
sendKeyString(tm, "abc")
sendKeys(tm, "esc")
sendKeys(tm, "l", "R")
sendKeyString(tm, "XY")
sendKeys(tm, "esc", "u")
m := getFinalModel(t, tm)
// Only the second replace should be undone
if m.ActiveBuffer().Lines[0].String() != "abclo" {
t.Errorf("lines[0] = %q, want 'abclo'", m.ActiveBuffer().Lines[0].String())
}
})
}
func TestReplaceModeEdgeCases(t *testing.T) {
t.Run("test replace mode on empty line", func(t *testing.T) {
lines := []string{""}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R")
sendKeyString(tm, "test")
sendKeys(tm, "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "test" {
t.Errorf("lines[0] = %q, want 'test'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test replace mode on different lines", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1})
sendKeys(tm, "R", "X", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1].String() != "Xorld" {
t.Errorf("lines[1] = %q, want 'Xorld'", m.ActiveBuffer().Lines[1].String())
}
})
t.Run("test overwriting tab character", func(t *testing.T) {
lines := []string{"a\tb"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0})
sendKeys(tm, "R", "X", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "aXb" {
t.Errorf("lines[0] = %q, want 'aXb'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test replace mode with whitespace characters", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R")
sendKeyString(tm, "a b")
sendKeys(tm, "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "a blo" {
t.Errorf("lines[0] = %q, want 'a blo'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test replace mode on single character line", func(t *testing.T) {
lines := []string{"a"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R", "X", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "X" {
t.Errorf("lines[0] = %q, want 'X'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test replace mode preserves other lines", func(t *testing.T) {
lines := []string{"first", "second", "third"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1})
sendKeys(tm, "R")
sendKeyString(tm, "XXX")
sendKeys(tm, "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "first" {
t.Errorf("lines[0] = %q, want 'first'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1].String() != "XXXond" {
t.Errorf("lines[1] = %q, want 'XXXond'", m.ActiveBuffer().Lines[1].String())
}
if m.ActiveBuffer().Lines[2].String() != "third" {
t.Errorf("lines[2] = %q, want 'third'", m.ActiveBuffer().Lines[2].String())
}
})
t.Run("test replace mode with special characters", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R")
sendKeyString(tm, "@#$")
sendKeys(tm, "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "@#$lo" {
t.Errorf("lines[0] = %q, want '@#$lo'", m.ActiveBuffer().Lines[0].String())
}
})
}
func TestReplaceModeRepeat(t *testing.T) {
t.Run("test replace mode can be repeated with '.'", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R")
sendKeyString(tm, "XX")
sendKeys(tm, "esc", "j", ".")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "XXllo" {
t.Errorf("lines[0] = %q, want 'XXllo'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1].String() != "wXXld" {
t.Errorf("lines[1] = %q, want 'wXXld'", m.ActiveBuffer().Lines[1].String())
}
})
t.Run("test dot repeats last replace operation", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R")
sendKeyString(tm, "abc")
sendKeys(tm, "esc", "w", ".")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "abclo abcld" {
t.Errorf("lines[0] = %q, want 'abclo abcld'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test dot after navigation", func(t *testing.T) {
lines := []string{"aaa", "bbb", "ccc"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R", "X", "esc", "j", "j", ".")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "Xaa" {
t.Errorf("lines[0] = %q, want 'Xaa'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[2].String() != "Xcc" {
t.Errorf("lines[2] = %q, want 'Xcc'", m.ActiveBuffer().Lines[2].String())
}
})
}
// ==================================================
// Visual Replace Char (v/V/ctrl+v + r{char}) Tests
// ==================================================
func TestVisualReplaceChar(t *testing.T) {
t.Run("test 'vlllrx' replaces each selected char in characterwise visual mode", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "v", "l", "l", "l", "r", "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Line(0) != "xxxxo world" {
t.Errorf("Line(0) = %q, want %q", m.ActiveBuffer().Line(0), "xxxxo world")
}
if m.Mode() != core.NormalMode {
t.Errorf("Mode() = %v, want %v", m.Mode(), core.NormalMode)
}
})
t.Run("test backward characterwise selection with 'r'", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 0})
sendKeys(tm, "v", "h", "h", "r", "z")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Line(0) != "hezzz" {
t.Errorf("Line(0) = %q, want %q", m.ActiveBuffer().Line(0), "hezzz")
}
})
t.Run("test 'VjrX' replaces all characters in selected lines", func(t *testing.T) {
lines := []string{"abc", "de", "fghi"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "V", "j", "r", "X")
m := getFinalModel(t, tm)
assertReplaceVisualLines(t, m, []string{"XXX", "XX", "fghi"})
if m.Mode() != core.NormalMode {
t.Errorf("Mode() = %v, want %v", m.Mode(), core.NormalMode)
}
})
t.Run("test visual line backward selection with 'r'", func(t *testing.T) {
lines := []string{"one", "two", "three"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 2})
sendKeys(tm, "V", "k", "r", "_")
m := getFinalModel(t, tm)
assertReplaceVisualLines(t, m, []string{"one", "___", "_____"})
})
t.Run("test 'ctrl+vljrx' replaces each char in block selection", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "ctrl+v", "l", "j", "r", "x")
m := getFinalModel(t, tm)
assertReplaceVisualLines(t, m, []string{"xxllo", "xxrld"})
})
t.Run("test block replace with ragged line lengths replaces available chars", func(t *testing.T) {
lines := []string{"abcd", "xy", "1234"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0})
sendKeys(tm, "ctrl+v", "l", "j", "j", "r", "q")
m := getFinalModel(t, tm)
assertReplaceVisualLines(t, m, []string{"aqqd", "xq", "1qq4"})
})
}
// ==================================================
// Visual Replace Char Repeat (.) Tests
// ==================================================
func TestVisualReplaceCharRepeat(t *testing.T) {
t.Run("test dot repeats characterwise visual replace", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "v", "l", "l", "r", "x", "w", ".")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Line(0) != "xxxlo xxxld" {
t.Errorf("Line(0) = %q, want %q", m.ActiveBuffer().Line(0), "xxxlo xxxld")
}
})
t.Run("test dot repeats visual line replace on next line", func(t *testing.T) {
lines := []string{"abcde", "vwxyz"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "V", "r", "_", "j", ".")
m := getFinalModel(t, tm)
assertReplaceVisualLines(t, m, []string{"_____", "_____"})
})
t.Run("test dot repeats visual block replace at new location", func(t *testing.T) {
lines := []string{"abcdef", "ghijkl", "mnopqr", "stuvwx"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "ctrl+v", "l", "j", "r", "*", "j", "j", ".")
m := getFinalModel(t, tm)
assertReplaceVisualLines(t, m, []string{"**cdef", "**ijkl", "**opqr", "**uvwx"})
})
}
func assertReplaceVisualLines(t *testing.T, m *Model, want []string) {
t.Helper()
if m.ActiveBuffer().LineCount() != len(want) {
t.Fatalf("LineCount() = %d, want %d", m.ActiveBuffer().LineCount(), len(want))
}
for i := range want {
if m.ActiveBuffer().Line(i) != want[i] {
t.Errorf("Line(%d) = %q, want %q", i, m.ActiveBuffer().Line(i), want[i])
}
}
}