All checks were successful
Run Test Suite / test (push) Successful in 56s
Not sure if this is perfect, but it seems to be working
993 lines
33 KiB
Go
993 lines
33 KiB
Go
package editor
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"git.gophernest.net/azpect/TextEditor/internal/core"
|
|
)
|
|
|
|
// equalStringSlices compares two string slices for equality
|
|
func equalStringSlices(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ============================================================================
|
|
// BASIC UNDO/REDO TESTS
|
|
// ============================================================================
|
|
|
|
func TestUndoBasicOperations(t *testing.T) {
|
|
t.Run("undo single character delete with x", func(t *testing.T) {
|
|
lines := []string{"hello"}
|
|
tm := newTestModelWithLines(t, lines)
|
|
sendKeys(tm, "x") // Delete 'h'
|
|
sendKeys(tm, "u") // Undo
|
|
|
|
m := getFinalModel(t, tm)
|
|
if m.ActiveBuffer().Lines[0].String() != "hello" {
|
|
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0].String())
|
|
}
|
|
|
|
// Verify undo stack is empty
|
|
if m.ActiveBuffer().UndoStack.CanUndo() {
|
|
t.Error("Expected undo stack to be empty after undoing all changes")
|
|
}
|
|
})
|
|
|
|
t.Run("undo and redo single character delete", func(t *testing.T) {
|
|
lines := []string{"hello"}
|
|
tm := newTestModelWithLines(t, lines)
|
|
sendKeys(tm, "x") // Delete 'h'
|
|
sendKeys(tm, "u") // Undo
|
|
sendKeys(tm, "ctrl+r") // Redo
|
|
|
|
m := getFinalModel(t, tm)
|
|
if m.ActiveBuffer().Lines[0].String() != "ello" {
|
|
t.Errorf("lines[0] = %q, want 'ello'", m.ActiveBuffer().Lines[0].String())
|
|
}
|
|
|
|
// Verify redo stack is empty
|
|
if m.ActiveBuffer().UndoStack.CanRedo() {
|
|
t.Error("Expected redo stack to be empty after redoing all changes")
|
|
}
|
|
})
|
|
|
|
t.Run("undo multiple x operations creates separate undo blocks", func(t *testing.T) {
|
|
lines := []string{"hello"}
|
|
tm := newTestModelWithLines(t, lines)
|
|
sendKeys(tm, "x") // Delete 'h' -> "ello"
|
|
sendKeys(tm, "x") // Delete 'e' -> "llo"
|
|
sendKeys(tm, "x") // Delete first 'l' -> "lo"
|
|
sendKeys(tm, "u") // Undo last x -> "llo"
|
|
sendKeys(tm, "u") // Undo second x -> "ello"
|
|
sendKeys(tm, "u") // Undo first x -> "hello"
|
|
|
|
m := getFinalModel(t, tm)
|
|
if m.ActiveBuffer().Lines[0].String() != "hello" {
|
|
t.Errorf("After 3 undos: lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0].String())
|
|
}
|
|
|
|
// Verify undo stack is empty
|
|
if m.ActiveBuffer().UndoStack.CanUndo() {
|
|
t.Error("Expected undo stack to be empty after undoing all changes")
|
|
}
|
|
})
|
|
|
|
t.Run("undo single X (delete backward)", func(t *testing.T) {
|
|
lines := []string{"hello"}
|
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
|
|
sendKeys(tm, "X") // Delete 'e'
|
|
sendKeys(tm, "u") // Undo
|
|
|
|
m := getFinalModel(t, tm)
|
|
if m.ActiveBuffer().Lines[0].String() != "hello" {
|
|
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0].String())
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestUndoCursorRestoration(t *testing.T) {
|
|
t.Run("undo restores cursor position after x", func(t *testing.T) {
|
|
lines := []string{"hello world"}
|
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 6, Line: 0})
|
|
sendKeys(tm, "x") // Delete 'w' at position 6
|
|
sendKeys(tm, "u") // Undo
|
|
|
|
m := getFinalModel(t, tm)
|
|
if m.ActiveWindow().Cursor.Col != 6 {
|
|
t.Errorf("cursor col = %d, want 6", m.ActiveWindow().Cursor.Col)
|
|
}
|
|
if m.ActiveBuffer().Lines[0].String() != "hello world" {
|
|
t.Errorf("lines[0] = %q, want 'hello world'", m.ActiveBuffer().Lines[0].String())
|
|
}
|
|
})
|
|
|
|
t.Run("redo restores cursor position after operation", func(t *testing.T) {
|
|
lines := []string{"hello"}
|
|
tm := newTestModelWithLines(t, lines)
|
|
sendKeys(tm, "x") // Delete at position 0
|
|
sendKeys(tm, "u") // Undo
|
|
sendKeys(tm, "ctrl+r") // Redo
|
|
|
|
m := getFinalModel(t, tm)
|
|
if m.ActiveWindow().Cursor.Col != 0 {
|
|
t.Errorf("cursor col = %d, want 0", m.ActiveWindow().Cursor.Col)
|
|
}
|
|
})
|
|
}
|
|
|
|
// ============================================================================
|
|
// INSERT MODE UNDO TESTS
|
|
// ============================================================================
|
|
|
|
func TestUndoInsertMode(t *testing.T) {
|
|
t.Run("insert mode groups all characters into one undo", func(t *testing.T) {
|
|
lines := []string{""}
|
|
tm := newTestModelWithLines(t, lines)
|
|
sendKeys(tm, "i") // Enter insert mode
|
|
sendKeyString(tm, "hello") // Type 5 characters
|
|
sendKeys(tm, "esc") // Exit insert mode
|
|
sendKeys(tm, "u") // Undo once
|
|
|
|
m := getFinalModel(t, tm)
|
|
if m.ActiveBuffer().Lines[0].String() != "" {
|
|
t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0].String())
|
|
}
|
|
|
|
// Verify only one undo was needed
|
|
if m.ActiveBuffer().UndoStack.CanUndo() {
|
|
t.Error("Expected undo stack to be empty after single undo of insert session")
|
|
}
|
|
})
|
|
|
|
t.Run("multiple insert sessions create separate undo blocks", func(t *testing.T) {
|
|
lines := []string{""}
|
|
tm := newTestModelWithLines(t, lines)
|
|
|
|
// First insert session
|
|
sendKeys(tm, "i")
|
|
sendKeyString(tm, "hello")
|
|
sendKeys(tm, "esc")
|
|
|
|
// Second insert session
|
|
sendKeys(tm, "a") // Append
|
|
sendKeyString(tm, " world")
|
|
sendKeys(tm, "esc")
|
|
|
|
// Undo second session
|
|
sendKeys(tm, "u")
|
|
// Undo first session
|
|
sendKeys(tm, "u")
|
|
|
|
m := getFinalModel(t, tm)
|
|
if m.ActiveBuffer().Lines[0].String() != "" {
|
|
t.Errorf("After 2 undos: lines[0] = %q, want ''", m.ActiveBuffer().Lines[0].String())
|
|
}
|
|
|
|
// Verify undo stack is empty
|
|
if m.ActiveBuffer().UndoStack.CanUndo() {
|
|
t.Error("Expected undo stack to be empty after undoing all changes")
|
|
}
|
|
})
|
|
|
|
t.Run("insert with newlines groups everything into one undo", func(t *testing.T) {
|
|
lines := []string{""}
|
|
tm := newTestModelWithLines(t, lines)
|
|
sendKeys(tm, "i")
|
|
sendKeyString(tm, "line1")
|
|
sendKeys(tm, "enter")
|
|
sendKeyString(tm, "line2")
|
|
sendKeys(tm, "enter")
|
|
sendKeyString(tm, "line3")
|
|
sendKeys(tm, "esc")
|
|
// Single undo should remove everything
|
|
sendKeys(tm, "u")
|
|
|
|
m := getFinalModel(t, tm)
|
|
if len(m.ActiveBuffer().Lines) != 1 || m.ActiveBuffer().Lines[0].String() != "" {
|
|
t.Errorf("After undo: got %d lines with content %q, want 1 empty line",
|
|
len(m.ActiveBuffer().Lines), m.ActiveBuffer().Lines)
|
|
}
|
|
})
|
|
|
|
t.Run("insert mode with backspace is grouped into one undo", func(t *testing.T) {
|
|
lines := []string{""}
|
|
tm := newTestModelWithLines(t, lines)
|
|
sendKeys(tm, "i")
|
|
sendKeyString(tm, "hello")
|
|
sendKeys(tm, "backspace", "backspace") // Delete "lo"
|
|
sendKeyString(tm, "y") // Type "y"
|
|
sendKeys(tm, "esc")
|
|
// Single undo should remove entire insert session
|
|
sendKeys(tm, "u")
|
|
|
|
m := getFinalModel(t, tm)
|
|
if m.ActiveBuffer().Lines[0].String() != "" {
|
|
t.Errorf("After undo: lines[0] = %q, want ''", m.ActiveBuffer().Lines[0].String())
|
|
}
|
|
})
|
|
}
|
|
|
|
// ============================================================================
|
|
// OPERATOR UNDO TESTS (dd, cc, etc.)
|
|
// ============================================================================
|
|
|
|
func TestUndoDeleteOperator(t *testing.T) {
|
|
t.Run("dd creates one undo block", func(t *testing.T) {
|
|
lines := []string{"line1", "line2", "line3"}
|
|
tm := newTestModelWithLines(t, lines)
|
|
sendKeys(tm, "d", "d") // Delete line1
|
|
sendKeys(tm, "u") // Undo
|
|
|
|
m := getFinalModel(t, tm)
|
|
if len(m.ActiveBuffer().Lines) != 3 {
|
|
t.Errorf("line count = %d, want 3", len(m.ActiveBuffer().Lines))
|
|
}
|
|
if m.ActiveBuffer().Lines[0].String() != "line1" {
|
|
t.Errorf("lines[0] = %q, want 'line1'", m.ActiveBuffer().Lines[0].String())
|
|
}
|
|
})
|
|
|
|
t.Run("3dd creates one undo block for all 3 lines", func(t *testing.T) {
|
|
lines := []string{"line1", "line2", "line3", "line4"}
|
|
tm := newTestModelWithLines(t, lines)
|
|
sendKeys(tm, "3", "d", "d") // Delete 3 lines
|
|
sendKeys(tm, "u") // Undo once
|
|
|
|
m := getFinalModel(t, tm)
|
|
if len(m.ActiveBuffer().Lines) != 4 {
|
|
t.Errorf("line count = %d, want 4", len(m.ActiveBuffer().Lines))
|
|
}
|
|
if m.ActiveBuffer().Lines[0].String() != "line1" {
|
|
t.Errorf("lines[0] = %q, want 'line1'", m.ActiveBuffer().Lines[0].String())
|
|
}
|
|
if m.ActiveBuffer().Lines[2].String() != "line3" {
|
|
t.Errorf("lines[2] = %q, want 'line3'", m.ActiveBuffer().Lines[2].String())
|
|
}
|
|
})
|
|
|
|
t.Run("dw (delete word) creates one undo block", func(t *testing.T) {
|
|
lines := []string{"hello world foo"}
|
|
tm := newTestModelWithLines(t, lines)
|
|
sendKeys(tm, "d", "w") // Delete "hello "
|
|
sendKeys(tm, "u") // Undo
|
|
|
|
m := getFinalModel(t, tm)
|
|
if m.ActiveBuffer().Lines[0].String() != "hello world foo" {
|
|
t.Errorf("lines[0] = %q, want 'hello world foo'", m.ActiveBuffer().Lines[0].String())
|
|
}
|
|
})
|
|
|
|
t.Run("D (delete to end of line) undoes correctly", func(t *testing.T) {
|
|
lines := []string{"hello world"}
|
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 6, Line: 0})
|
|
sendKeys(tm, "D") // Delete "world"
|
|
sendKeys(tm, "u") // Undo
|
|
|
|
m := getFinalModel(t, tm)
|
|
if m.ActiveBuffer().Lines[0].String() != "hello world" {
|
|
t.Errorf("lines[0] = %q, want 'hello world'", m.ActiveBuffer().Lines[0].String())
|
|
}
|
|
if m.ActiveWindow().Cursor.Col != 6 {
|
|
t.Errorf("cursor col = %d, want 6", m.ActiveWindow().Cursor.Col)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestUndoChangeOperator(t *testing.T) {
|
|
t.Run("cc (change line) undoes correctly", func(t *testing.T) {
|
|
lines := []string{"original line", "line2"}
|
|
tm := newTestModelWithLines(t, lines)
|
|
sendKeys(tm, "c", "c") // Change line (deletes and enters insert)
|
|
sendKeyString(tm, "new line") // Type new content
|
|
sendKeys(tm, "esc") // Exit insert mode
|
|
sendKeys(tm, "u") // Undo
|
|
|
|
m := getFinalModel(t, tm)
|
|
if m.ActiveBuffer().Lines[0].String() != "original line" {
|
|
t.Errorf("lines[0] = %q, want 'original line'", m.ActiveBuffer().Lines[0].String())
|
|
}
|
|
})
|
|
|
|
t.Run("cw (change word) undoes correctly", func(t *testing.T) {
|
|
lines := []string{"hello world"}
|
|
tm := newTestModelWithLines(t, lines)
|
|
sendKeys(tm, "c", "w") // Change word
|
|
sendKeyString(tm, "hi") // Type replacement
|
|
sendKeys(tm, "esc")
|
|
sendKeys(tm, "u")
|
|
|
|
m := getFinalModel(t, tm)
|
|
if m.ActiveBuffer().Lines[0].String() != "hello world" {
|
|
t.Errorf("lines[0] = %q, want 'hello world'", m.ActiveBuffer().Lines[0].String())
|
|
}
|
|
})
|
|
|
|
t.Run("s (substitute char) undoes correctly", func(t *testing.T) {
|
|
lines := []string{"hello"}
|
|
tm := newTestModelWithLines(t, lines)
|
|
sendKeys(tm, "s") // Substitute character
|
|
sendKeyString(tm, "H") // Type replacement
|
|
sendKeys(tm, "esc")
|
|
sendKeys(tm, "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("S (substitute line) undoes correctly", func(t *testing.T) {
|
|
lines := []string{"original", "line2"}
|
|
tm := newTestModelWithLines(t, lines)
|
|
sendKeys(tm, "S") // Substitute line
|
|
sendKeyString(tm, "replaced") // Type replacement
|
|
sendKeys(tm, "esc")
|
|
sendKeys(tm, "u")
|
|
|
|
m := getFinalModel(t, tm)
|
|
if m.ActiveBuffer().Lines[0].String() != "original" {
|
|
t.Errorf("lines[0] = %q, want 'original'", m.ActiveBuffer().Lines[0].String())
|
|
}
|
|
})
|
|
}
|
|
|
|
// ============================================================================
|
|
// VISUAL MODE UNDO TESTS
|
|
// ============================================================================
|
|
|
|
func TestUndoVisualMode(t *testing.T) {
|
|
t.Run("visual char mode delete undoes correctly", func(t *testing.T) {
|
|
lines := []string{"hello world"}
|
|
tm := newTestModelWithLines(t, lines)
|
|
sendKeys(tm, "v") // Enter visual char mode
|
|
sendKeys(tm, "l", "l", "l", "l") // Select "hello"
|
|
sendKeys(tm, "d") // Delete selection
|
|
sendKeys(tm, "u") // Undo
|
|
|
|
m := getFinalModel(t, tm)
|
|
if m.ActiveBuffer().Lines[0].String() != "hello world" {
|
|
t.Errorf("lines[0] = %q, want 'hello world'", m.ActiveBuffer().Lines[0].String())
|
|
}
|
|
})
|
|
|
|
t.Run("visual line mode delete undoes correctly", func(t *testing.T) {
|
|
lines := []string{"line1", "line2", "line3"}
|
|
tm := newTestModelWithLines(t, lines)
|
|
sendKeys(tm, "V") // Enter visual line mode
|
|
sendKeys(tm, "j") // Select 2 lines
|
|
sendKeys(tm, "d") // Delete
|
|
sendKeys(tm, "u") // Undo
|
|
|
|
m := getFinalModel(t, tm)
|
|
if len(m.ActiveBuffer().Lines) != 3 {
|
|
t.Errorf("line count = %d, want 3", len(m.ActiveBuffer().Lines))
|
|
}
|
|
if m.ActiveBuffer().Lines[0].String() != "line1" {
|
|
t.Errorf("lines[0] = %q, want 'line1'", m.ActiveBuffer().Lines[0].String())
|
|
}
|
|
})
|
|
|
|
t.Run("visual block mode delete undoes correctly", func(t *testing.T) {
|
|
lines := []string{"hello", "world", "test"}
|
|
tm := newTestModelWithLines(t, lines)
|
|
sendKeys(tm, "ctrl+v") // Enter visual block mode
|
|
sendKeys(tm, "j", "j") // Select 3 lines
|
|
sendKeys(tm, "l", "l") // Select 3 columns
|
|
sendKeys(tm, "d") // Delete block
|
|
sendKeys(tm, "u") // Undo
|
|
|
|
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("visual char mode change undoes correctly", func(t *testing.T) {
|
|
lines := []string{"hello world"}
|
|
tm := newTestModelWithLines(t, lines)
|
|
sendKeys(tm, "v") // Visual char mode
|
|
sendKeys(tm, "l", "l", "l", "l") // Select "hello"
|
|
sendKeys(tm, "c") // Change
|
|
sendKeyString(tm, "hi") // Type replacement
|
|
sendKeys(tm, "esc")
|
|
sendKeys(tm, "u") // Undo
|
|
|
|
m := getFinalModel(t, tm)
|
|
if m.ActiveBuffer().Lines[0].String() != "hello world" {
|
|
t.Errorf("lines[0] = %q, want 'hello world'", m.ActiveBuffer().Lines[0].String())
|
|
}
|
|
})
|
|
}
|
|
|
|
// ============================================================================
|
|
// TEXT OBJECT UNDO TESTS
|
|
// ============================================================================
|
|
|
|
func TestUndoTextObjects(t *testing.T) {
|
|
t.Run("diw (delete inner word) undoes correctly", func(t *testing.T) {
|
|
lines := []string{"hello world foo"}
|
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
|
|
sendKeys(tm, "d", "i", "w") // Delete inner word
|
|
sendKeys(tm, "u") // Undo
|
|
|
|
m := getFinalModel(t, tm)
|
|
if m.ActiveBuffer().Lines[0].String() != "hello world foo" {
|
|
t.Errorf("lines[0] = %q, want 'hello world foo'", m.ActiveBuffer().Lines[0].String())
|
|
}
|
|
})
|
|
|
|
t.Run("daw (delete a word) undoes correctly", func(t *testing.T) {
|
|
lines := []string{"hello world foo"}
|
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 6, Line: 0})
|
|
sendKeys(tm, "d", "a", "w") // Delete a word
|
|
sendKeys(tm, "u") // Undo
|
|
|
|
m := getFinalModel(t, tm)
|
|
if m.ActiveBuffer().Lines[0].String() != "hello world foo" {
|
|
t.Errorf("lines[0] = %q, want 'hello world foo'", m.ActiveBuffer().Lines[0].String())
|
|
}
|
|
})
|
|
|
|
t.Run("ci( changes inside parens undoes correctly", func(t *testing.T) {
|
|
lines := []string{"before (hello) after"}
|
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 9, Line: 0})
|
|
sendKeys(tm, "c", "i", "(") // Change inside parens
|
|
sendKeyString(tm, "world") // Type replacement
|
|
sendKeys(tm, "esc")
|
|
sendKeys(tm, "u") // Undo
|
|
|
|
m := getFinalModel(t, tm)
|
|
if m.ActiveBuffer().Lines[0].String() != "before (hello) after" {
|
|
t.Errorf("lines[0] = %q, want 'before (hello) after'", m.ActiveBuffer().Lines[0].String())
|
|
}
|
|
})
|
|
}
|
|
|
|
// ============================================================================
|
|
// UNDO/REDO SEQUENCE TESTS
|
|
// ============================================================================
|
|
|
|
func TestUndoRedoSequences(t *testing.T) {
|
|
t.Run("undo then redo multiple times", func(t *testing.T) {
|
|
lines := []string{"hello"}
|
|
tm := newTestModelWithLines(t, lines)
|
|
sendKeys(tm, "x") // Delete 'h' -> "ello"
|
|
sendKeys(tm, "x") // Delete 'e' -> "llo"
|
|
// Undo twice
|
|
sendKeys(tm, "u", "u")
|
|
// Redo twice
|
|
sendKeys(tm, "ctrl+r", "ctrl+r")
|
|
|
|
m := getFinalModel(t, tm)
|
|
if m.ActiveBuffer().Lines[0].String() != "llo" {
|
|
t.Errorf("After 2 redos: lines[0] = %q, want 'llo'", m.ActiveBuffer().Lines[0].String())
|
|
}
|
|
})
|
|
|
|
t.Run("new change after undo clears redo stack", func(t *testing.T) {
|
|
lines := []string{"hello"}
|
|
tm := newTestModelWithLines(t, lines)
|
|
sendKeys(tm, "x") // Delete 'h' -> "ello"
|
|
sendKeys(tm, "x") // Delete 'e' -> "llo"
|
|
sendKeys(tm, "u") // Undo -> "ello"
|
|
sendKeys(tm, "x") // New change -> "llo"
|
|
|
|
m := getFinalModel(t, tm)
|
|
// Verify redo is not possible
|
|
if m.ActiveBuffer().UndoStack.CanRedo() {
|
|
t.Error("Expected redo stack to be cleared after new change")
|
|
}
|
|
|
|
// Verify content
|
|
if m.ActiveBuffer().Lines[0].String() != "llo" {
|
|
t.Errorf("lines[0] = %q, want 'llo'", m.ActiveBuffer().Lines[0].String())
|
|
}
|
|
})
|
|
|
|
t.Run("complex sequence: operations, undo, redo, more operations", func(t *testing.T) {
|
|
lines := []string{"line1", "line2", "line3"}
|
|
tm := newTestModelWithLines(t, lines)
|
|
|
|
// Do operations
|
|
sendKeys(tm, "d", "d") // Delete line1
|
|
sendKeys(tm, "d", "d") // Delete line2
|
|
// Undo once
|
|
sendKeys(tm, "u")
|
|
// Redo
|
|
sendKeys(tm, "ctrl+r")
|
|
// New operation
|
|
sendKeys(tm, "i")
|
|
sendKeyString(tm, "new")
|
|
sendKeys(tm, "esc")
|
|
|
|
m := getFinalModel(t, tm)
|
|
if m.ActiveBuffer().Lines[0].String() != "newline3" {
|
|
t.Errorf("After insert: lines[0] = %q, want 'newline3'", m.ActiveBuffer().Lines[0].String())
|
|
}
|
|
})
|
|
}
|
|
|
|
// ============================================================================
|
|
// EDGE CASE TESTS
|
|
// ============================================================================
|
|
|
|
func TestUndoEdgeCases(t *testing.T) {
|
|
t.Run("undo on empty undo stack does nothing", func(t *testing.T) {
|
|
lines := []string{"hello"}
|
|
tm := newTestModelWithLines(t, lines)
|
|
sendKeys(tm, "u") // Undo when nothing to undo
|
|
|
|
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("redo on empty redo stack does nothing", func(t *testing.T) {
|
|
lines := []string{"hello"}
|
|
tm := newTestModelWithLines(t, lines)
|
|
sendKeys(tm, "ctrl+r") // Redo when nothing to redo
|
|
|
|
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("undo after exhausting redo stack", func(t *testing.T) {
|
|
lines := []string{"hello"}
|
|
tm := newTestModelWithLines(t, lines)
|
|
sendKeys(tm, "x") // Delete
|
|
sendKeys(tm, "u") // Undo
|
|
sendKeys(tm, "ctrl+r") // Redo
|
|
sendKeys(tm, "ctrl+r") // Try redo again (should do nothing)
|
|
|
|
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("undo operation that left buffer empty", func(t *testing.T) {
|
|
lines := []string{"only line"}
|
|
tm := newTestModelWithLines(t, lines)
|
|
sendKeys(tm, "d", "d") // Delete only line (buffer should have empty line)
|
|
sendKeys(tm, "u") // Undo
|
|
|
|
m := getFinalModel(t, tm)
|
|
if len(m.ActiveBuffer().Lines) != 1 {
|
|
t.Errorf("line count = %d, want 1", len(m.ActiveBuffer().Lines))
|
|
}
|
|
if m.ActiveBuffer().Lines[0].String() != "only line" {
|
|
t.Errorf("lines[0] = %q, want 'only line'", m.ActiveBuffer().Lines[0].String())
|
|
}
|
|
})
|
|
}
|
|
|
|
// ============================================================================
|
|
// MULTI-LINE OPERATION TESTS
|
|
// ============================================================================
|
|
|
|
func TestUndoMultiLineOperations(t *testing.T) {
|
|
t.Run("undo multi-line delete from visual mode", func(t *testing.T) {
|
|
lines := []string{"line1", "line2", "line3", "line4", "line5"}
|
|
tm := newTestModelWithLines(t, lines)
|
|
sendKeys(tm, "V") // Visual line mode
|
|
sendKeys(tm, "j", "j") // Select 3 lines
|
|
sendKeys(tm, "d") // Delete
|
|
sendKeys(tm, "u") // Undo
|
|
|
|
m := getFinalModel(t, tm)
|
|
if len(m.ActiveBuffer().Lines) != 5 {
|
|
t.Errorf("line count = %d, want 5", len(m.ActiveBuffer().Lines))
|
|
}
|
|
for i := 0; i < 5; i++ {
|
|
expected := "line" + string(rune('1'+i))
|
|
if m.ActiveBuffer().Lines[i].String() != expected {
|
|
t.Errorf("lines[%d] = %q, want %q", i, m.ActiveBuffer().Lines[i].String(), expected)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("undo delete spanning multiple lines with motion", func(t *testing.T) {
|
|
lines := []string{"line1", "line2", "line3"}
|
|
tm := newTestModelWithLines(t, lines)
|
|
sendKeys(tm, "d", "j") // Delete current line and line below
|
|
sendKeys(tm, "u") // Undo
|
|
|
|
m := getFinalModel(t, tm)
|
|
if len(m.ActiveBuffer().Lines) != 3 {
|
|
t.Errorf("line count = %d, want 3", len(m.ActiveBuffer().Lines))
|
|
}
|
|
if m.ActiveBuffer().Lines[0].String() != "line1" {
|
|
t.Errorf("lines[0] = %q, want 'line1'", m.ActiveBuffer().Lines[0].String())
|
|
}
|
|
})
|
|
|
|
t.Run("undo o (open line below) operation", func(t *testing.T) {
|
|
lines := []string{"line1", "line2"}
|
|
tm := newTestModelWithLines(t, lines)
|
|
sendKeys(tm, "o") // Open line below
|
|
sendKeyString(tm, "new line") // Type content
|
|
sendKeys(tm, "esc") // Exit insert
|
|
sendKeys(tm, "u") // Undo
|
|
|
|
m := getFinalModel(t, tm)
|
|
if len(m.ActiveBuffer().Lines) != 2 {
|
|
t.Errorf("line count = %d, want 2", len(m.ActiveBuffer().Lines))
|
|
}
|
|
if m.ActiveBuffer().Lines[0].String() != "line1" {
|
|
t.Errorf("lines[0] = %q, want 'line1'", m.ActiveBuffer().Lines[0].String())
|
|
}
|
|
})
|
|
|
|
t.Run("undo O (open line above) operation", func(t *testing.T) {
|
|
lines := []string{"line1", "line2"}
|
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 1, Col: 0})
|
|
sendKeys(tm, "O") // Open line above
|
|
sendKeyString(tm, "new line") // Type content
|
|
sendKeys(tm, "esc") // Exit insert
|
|
sendKeys(tm, "u") // Undo
|
|
|
|
m := getFinalModel(t, tm)
|
|
if len(m.ActiveBuffer().Lines) != 2 {
|
|
t.Errorf("line count = %d, want 2", len(m.ActiveBuffer().Lines))
|
|
}
|
|
if m.ActiveBuffer().Lines[1].String() != "line2" {
|
|
t.Errorf("lines[1] = %q, want 'line2'", m.ActiveBuffer().Lines[1].String())
|
|
}
|
|
})
|
|
}
|
|
|
|
// ============================================================================
|
|
// UNDO STACK INSPECTION TESTS
|
|
// ============================================================================
|
|
|
|
func TestUndoStackStructure(t *testing.T) {
|
|
t.Run("verify undo stack has correct number of blocks", func(t *testing.T) {
|
|
lines := []string{"hello"}
|
|
tm := newTestModelWithLines(t, lines)
|
|
|
|
// Perform 3 separate operations
|
|
sendKeys(tm, "x") // Op 1
|
|
sendKeys(tm, "x") // Op 2
|
|
sendKeys(tm, "x") // Op 3
|
|
|
|
m := getFinalModel(t, tm)
|
|
|
|
// Should have 3 undo blocks
|
|
undoCount := 0
|
|
for m.ActiveBuffer().UndoStack.CanUndo() {
|
|
m.ActiveBuffer().UndoStack.Undo()
|
|
undoCount++
|
|
}
|
|
|
|
if undoCount != 3 {
|
|
t.Errorf("undo block count = %d, want 3", undoCount)
|
|
}
|
|
})
|
|
|
|
t.Run("verify insert mode creates single block with multiple changes", func(t *testing.T) {
|
|
lines := []string{""}
|
|
tm := newTestModelWithLines(t, lines)
|
|
sendKeys(tm, "i")
|
|
sendKeyString(tm, "hello")
|
|
sendKeys(tm, "esc")
|
|
// Verify single undo removes everything
|
|
sendKeys(tm, "u")
|
|
|
|
m := getFinalModel(t, tm)
|
|
if m.ActiveBuffer().Lines[0].String() != "" {
|
|
t.Errorf("After undo: lines[0] = %q, want ''", m.ActiveBuffer().Lines[0].String())
|
|
}
|
|
if m.ActiveBuffer().UndoStack.CanUndo() {
|
|
t.Error("Expected empty undo stack after single undo")
|
|
}
|
|
})
|
|
|
|
t.Run("verify dd creates single block with correct change types", func(t *testing.T) {
|
|
lines := []string{"line1", "line2"}
|
|
tm := newTestModelWithLines(t, lines)
|
|
sendKeys(tm, "d", "d")
|
|
// Verify undo restores correctly
|
|
sendKeys(tm, "u")
|
|
|
|
m := getFinalModel(t, tm)
|
|
if len(m.ActiveBuffer().Lines) != 2 {
|
|
t.Errorf("line count after undo = %d, want 2", len(m.ActiveBuffer().Lines))
|
|
}
|
|
// Verify undo stack is empty after undo
|
|
if m.ActiveBuffer().UndoStack.CanUndo() {
|
|
t.Error("Expected empty undo stack after undoing all changes")
|
|
}
|
|
})
|
|
}
|
|
|
|
// ============================================================================
|
|
// COMPLEX SCENARIO TESTS
|
|
// ============================================================================
|
|
|
|
func TestUndoComplexScenarios(t *testing.T) {
|
|
t.Run("realistic editing session with multiple undo/redo", func(t *testing.T) {
|
|
lines := []string{"func main() {", "}", ""}
|
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 1, Col: 0})
|
|
|
|
// Insert a line
|
|
sendKeys(tm, "O")
|
|
sendKeyString(tm, "\tfmt.Println(\"hello\")")
|
|
sendKeys(tm, "esc")
|
|
// Delete a word
|
|
sendKeys(tm, "d", "i", "w")
|
|
// Undo delete
|
|
sendKeys(tm, "u")
|
|
// Undo insert
|
|
sendKeys(tm, "u")
|
|
// Redo both
|
|
sendKeys(tm, "ctrl+r", "ctrl+r")
|
|
|
|
m := getFinalModel(t, tm)
|
|
if len(m.ActiveBuffer().Lines) != 4 {
|
|
t.Errorf("After 2 redos: line count = %d, want 4", len(m.ActiveBuffer().Lines))
|
|
}
|
|
})
|
|
|
|
t.Run("alternating operations and undos", func(t *testing.T) {
|
|
lines := []string{"abc"}
|
|
tm := newTestModelWithLines(t, lines)
|
|
|
|
sendKeys(tm, "x") // Delete 'a' -> "bc"
|
|
sendKeys(tm, "u") // Undo -> "abc"
|
|
sendKeys(tm, "$") // Move to end
|
|
sendKeys(tm, "x") // Delete 'c' -> "ab"
|
|
sendKeys(tm, "u") // Undo -> "abc"
|
|
sendKeys(tm, "0") // Move to start
|
|
sendKeys(tm, "x") // Delete 'a' -> "bc"
|
|
|
|
m := getFinalModel(t, tm)
|
|
if m.ActiveBuffer().Lines[0].String() != "bc" {
|
|
t.Errorf("lines[0] = %q, want 'bc'", m.ActiveBuffer().Lines[0].String())
|
|
}
|
|
})
|
|
}
|
|
|
|
// =================================================================
|
|
// PASTE OPERATIONS TESTS
|
|
// =================================================================
|
|
|
|
func TestUndoPasteOperations(t *testing.T) {
|
|
t.Run("basic p (paste after) undo", func(t *testing.T) {
|
|
tm := newTestModelWithLines(t, []string{"line1", "line2"})
|
|
|
|
// Yank first line and paste after second line
|
|
sendKeys(tm, "y", "y") // yank current line (line1)
|
|
sendKeys(tm, "j") // move to line2
|
|
sendKeys(tm, "p") // paste after line2
|
|
sendKeys(tm, "u") // undo paste
|
|
|
|
m := getFinalModel(t, tm)
|
|
expected := []string{"line1", "line2"}
|
|
if len(m.ActiveBuffer().Lines) != len(expected) {
|
|
t.Errorf("len(Lines) = %d, want %d", len(m.ActiveBuffer().Lines), len(expected))
|
|
}
|
|
for i, exp := range expected {
|
|
if m.ActiveBuffer().Lines[i].String() != exp {
|
|
t.Errorf("Lines[%d] = %q, want %q", i, m.ActiveBuffer().Lines[i].String(), exp)
|
|
}
|
|
}
|
|
// Cursor should be back at line2
|
|
if m.ActiveWindow().Cursor.Line != 1 {
|
|
t.Errorf("Cursor.Line = %d, want 1", m.ActiveWindow().Cursor.Line)
|
|
}
|
|
})
|
|
|
|
t.Run("basic P (paste before) undo", func(t *testing.T) {
|
|
tm := newTestModelWithLines(t, []string{"line1", "line2"})
|
|
|
|
// Yank first line and paste before second line
|
|
sendKeys(tm, "y", "y") // yank current line (line1)
|
|
sendKeys(tm, "j") // move to line2
|
|
sendKeys(tm, "P") // paste before line2
|
|
sendKeys(tm, "u") // undo paste
|
|
|
|
m := getFinalModel(t, tm)
|
|
expected := []string{"line1", "line2"}
|
|
if len(m.ActiveBuffer().Lines) != len(expected) {
|
|
t.Errorf("len(Lines) = %d, want %d", len(m.ActiveBuffer().Lines), len(expected))
|
|
}
|
|
for i, exp := range expected {
|
|
if m.ActiveBuffer().Lines[i].String() != exp {
|
|
t.Errorf("Lines[%d] = %q, want %q", i, m.ActiveBuffer().Lines[i].String(), exp)
|
|
}
|
|
}
|
|
// Cursor should be back at line2
|
|
if m.ActiveWindow().Cursor.Line != 1 {
|
|
t.Errorf("Cursor.Line = %d, want 1", m.ActiveWindow().Cursor.Line)
|
|
}
|
|
})
|
|
|
|
t.Run("charwise paste undo", func(t *testing.T) {
|
|
tm := newTestModelWithLines(t, []string{"hello world"})
|
|
|
|
// Yank "hello" and paste after "world"
|
|
sendKeys(tm, "y", "w") // yank word "hello"
|
|
sendKeys(tm, "$") // move to end
|
|
sendKeys(tm, "p") // paste after cursor
|
|
sendKeys(tm, "u") // undo paste
|
|
|
|
m := getFinalModel(t, tm)
|
|
expected := []string{"hello world"}
|
|
if !equalStringSlices(bufferLinesToStrings(m.ActiveBuffer()), expected) {
|
|
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
|
|
}
|
|
})
|
|
|
|
t.Run("visual mode paste undo", func(t *testing.T) {
|
|
tm := newTestModelWithLines(t, []string{"hello world", "foo bar"})
|
|
|
|
// Yank "hello" then select "world" and paste over it
|
|
sendKeys(tm, "y", "w") // yank "hello"
|
|
sendKeys(tm, "w") // move to "world"
|
|
sendKeys(tm, "v", "e") // select "world"
|
|
sendKeys(tm, "p") // paste over selection
|
|
sendKeys(tm, "u") // undo paste
|
|
|
|
m := getFinalModel(t, tm)
|
|
expected := []string{"hello world", "foo bar"}
|
|
if len(m.ActiveBuffer().Lines) != len(expected) {
|
|
t.Errorf("len(Lines) = %d, want %d", len(m.ActiveBuffer().Lines), len(expected))
|
|
}
|
|
for i, exp := range expected {
|
|
if m.ActiveBuffer().Lines[i].String() != exp {
|
|
t.Errorf("Lines[%d] = %q, want %q", i, m.ActiveBuffer().Lines[i].String(), exp)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("multiple paste operations undo separately", func(t *testing.T) {
|
|
tm := newTestModelWithLines(t, []string{"base"})
|
|
|
|
sendKeys(tm, "y", "y") // yank "base"
|
|
sendKeys(tm, "p") // paste: "base\nbase"
|
|
sendKeys(tm, "p") // paste: "base\nbase\nbase"
|
|
sendKeys(tm, "u") // undo last paste: "base\nbase"
|
|
sendKeys(tm, "u") // undo first paste: "base"
|
|
|
|
m := getFinalModel(t, tm)
|
|
expected := []string{"base"}
|
|
if !equalStringSlices(bufferLinesToStrings(m.ActiveBuffer()), expected) {
|
|
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
|
|
}
|
|
})
|
|
|
|
t.Run("paste with count undo", func(t *testing.T) {
|
|
tm := newTestModelWithLines(t, []string{"test"})
|
|
|
|
sendKeys(tm, "y", "y") // yank "test"
|
|
sendKeyString(tm, "3p") // paste 3 times
|
|
sendKeys(tm, "u") // undo (should undo all 3 pastes as one block)
|
|
|
|
m := getFinalModel(t, tm)
|
|
expected := []string{"test"}
|
|
if !equalStringSlices(bufferLinesToStrings(m.ActiveBuffer()), expected) {
|
|
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
|
|
}
|
|
})
|
|
}
|
|
|
|
// =================================================================
|
|
// COMPLEX COUNT OPERATIONS TESTS
|
|
// =================================================================
|
|
|
|
func TestUndoComplexCountOperations(t *testing.T) {
|
|
t.Run("5dd undo", func(t *testing.T) {
|
|
tm := newTestModelWithLines(t, []string{"1", "2", "3", "4", "5", "6", "7"})
|
|
|
|
sendKeys(tm, "j", "j") // move to line 3
|
|
sendKeyString(tm, "5dd") // delete 5 lines (3,4,5,6,7)
|
|
sendKeys(tm, "u") // undo
|
|
|
|
m := getFinalModel(t, tm)
|
|
expected := []string{"1", "2", "3", "4", "5", "6", "7"}
|
|
if !equalStringSlices(bufferLinesToStrings(m.ActiveBuffer()), expected) {
|
|
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
|
|
}
|
|
// Cursor should be back at line 3 (index 2)
|
|
if m.ActiveWindow().Cursor.Line != 2 {
|
|
t.Errorf("Cursor.Line = %d, want 2", m.ActiveWindow().Cursor.Line)
|
|
}
|
|
})
|
|
|
|
t.Run("3cw (change 3 words) undo", func(t *testing.T) {
|
|
tm := newTestModelWithLines(t, []string{"one two three four five"})
|
|
|
|
sendKeys(tm, "3", "c", "w") // change 3 words
|
|
sendKeys(tm, "CHANGED") // type replacement
|
|
sendKeys(tm, "esc") // exit insert mode
|
|
sendKeys(tm, "u") // undo
|
|
|
|
m := getFinalModel(t, tm)
|
|
expected := []string{"one two three four five"}
|
|
if !equalStringSlices(bufferLinesToStrings(m.ActiveBuffer()), expected) {
|
|
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
|
|
}
|
|
})
|
|
|
|
t.Run("10x (delete 10 chars) undo", func(t *testing.T) {
|
|
tm := newTestModelWithLines(t, []string{"abcdefghijklmnopqrstuvwxyz"})
|
|
|
|
sendKeys(tm, "5", "|") // move to column 5 (f)
|
|
sendKeyString(tm, "10x") // delete 10 chars (fghijklmno)
|
|
sendKeys(tm, "u") // undo
|
|
|
|
m := getFinalModel(t, tm)
|
|
expected := []string{"abcdefghijklmnopqrstuvwxyz"}
|
|
if !equalStringSlices(bufferLinesToStrings(m.ActiveBuffer()), expected) {
|
|
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
|
|
}
|
|
// Cursor should be back at column 4 (index of 'e', 0-based)
|
|
if m.ActiveWindow().Cursor.Col != 4 {
|
|
t.Errorf("Cursor.Col = %d, want 4", m.ActiveWindow().Cursor.Col)
|
|
}
|
|
})
|
|
|
|
t.Run("2cc (change 2 lines) undo", func(t *testing.T) {
|
|
tm := newTestModelWithLines(t, []string{"line1", "line2", "line3", "line4"})
|
|
|
|
sendKeys(tm, "j") // move to line2
|
|
sendKeys(tm, "2", "c", "c") // change 2 lines (line2, line3)
|
|
sendKeys(tm, "NEW", "LINE") // type replacement
|
|
sendKeys(tm, "esc") // exit insert mode
|
|
sendKeys(tm, "u") // undo
|
|
|
|
m := getFinalModel(t, tm)
|
|
expected := []string{"line1", "line2", "line3", "line4"}
|
|
if !equalStringSlices(bufferLinesToStrings(m.ActiveBuffer()), expected) {
|
|
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
|
|
}
|
|
})
|
|
|
|
t.Run("4diw (delete 4 words) undo", func(t *testing.T) {
|
|
tm := newTestModelWithLines(t, []string{"word1 word2 word3 word4 word5"})
|
|
|
|
sendKeys(tm, "w") // move to word2
|
|
sendKeyString(tm, "4diw") // delete 4 words (word2, word3, word4, word5)
|
|
sendKeys(tm, "u") // undo
|
|
|
|
m := getFinalModel(t, tm)
|
|
expected := []string{"word1 word2 word3 word4 word5"}
|
|
if !equalStringSlices(bufferLinesToStrings(m.ActiveBuffer()), expected) {
|
|
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
|
|
}
|
|
})
|
|
|
|
t.Run("complex count with paste: 3p after 2yy", func(t *testing.T) {
|
|
tm := newTestModelWithLines(t, []string{"A", "B", "C", "D"})
|
|
|
|
sendKeyString(tm, "2yy") // yank 2 lines (A, B)
|
|
sendKeys(tm, "j", "j") // move to line C
|
|
sendKeyString(tm, "3p") // paste 3 times
|
|
sendKeys(tm, "u") // undo paste
|
|
|
|
m := getFinalModel(t, tm)
|
|
expected := []string{"A", "B", "C", "D"}
|
|
if !equalStringSlices(bufferLinesToStrings(m.ActiveBuffer()), expected) {
|
|
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
|
|
}
|
|
// Cursor should be back at line C (index 2)
|
|
if m.ActiveWindow().Cursor.Line != 2 {
|
|
t.Errorf("Cursor.Line = %d, want 2", m.ActiveWindow().Cursor.Line)
|
|
}
|
|
})
|
|
}
|