test: initial tests are complete!

Claude says they are "production ready"
This commit is contained in:
Hayden Hargreaves 2026-03-30 23:01:46 -07:00
parent 98e02553b1
commit 4dedb15a36
3 changed files with 780 additions and 2 deletions

View File

@ -67,7 +67,9 @@ type Undo struct{}
func (a Undo) Execute(m Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
buf.Undo(win)
if buf.UndoStack.CanUndo() {
buf.Undo(win)
}
return nil
}
@ -77,6 +79,8 @@ type Redo struct{}
func (a Redo) Execute(m Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
buf.Redo(win)
if buf.UndoStack.CanRedo() {
buf.Redo(win)
}
return nil
}

View File

@ -0,0 +1,749 @@
package editor
import (
"testing"
"git.gophernest.net/azpect/TextEditor/internal/core"
)
// ============================================================================
// 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] != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0])
}
// 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] != "ello" {
t.Errorf("lines[0] = %q, want 'ello'", m.ActiveBuffer().Lines[0])
}
// 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] != "hello" {
t.Errorf("After 3 undos: lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0])
}
// 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] != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0])
}
})
}
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] != "hello world" {
t.Errorf("lines[0] = %q, want 'hello world'", m.ActiveBuffer().Lines[0])
}
})
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] != "" {
t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0])
}
// 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] != "" {
t.Errorf("After 2 undos: lines[0] = %q, want ''", m.ActiveBuffer().Lines[0])
}
// 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] != "" {
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] != "" {
t.Errorf("After undo: lines[0] = %q, want ''", m.ActiveBuffer().Lines[0])
}
})
}
// ============================================================================
// 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] != "line1" {
t.Errorf("lines[0] = %q, want 'line1'", m.ActiveBuffer().Lines[0])
}
})
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] != "line1" {
t.Errorf("lines[0] = %q, want 'line1'", m.ActiveBuffer().Lines[0])
}
if m.ActiveBuffer().Lines[2] != "line3" {
t.Errorf("lines[2] = %q, want 'line3'", m.ActiveBuffer().Lines[2])
}
})
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] != "hello world foo" {
t.Errorf("lines[0] = %q, want 'hello world foo'", m.ActiveBuffer().Lines[0])
}
})
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] != "hello world" {
t.Errorf("lines[0] = %q, want 'hello world'", m.ActiveBuffer().Lines[0])
}
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] != "original line" {
t.Errorf("lines[0] = %q, want 'original line'", m.ActiveBuffer().Lines[0])
}
})
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] != "hello world" {
t.Errorf("lines[0] = %q, want 'hello world'", m.ActiveBuffer().Lines[0])
}
})
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] != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0])
}
})
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] != "original" {
t.Errorf("lines[0] = %q, want 'original'", m.ActiveBuffer().Lines[0])
}
})
}
// ============================================================================
// 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] != "hello world" {
t.Errorf("lines[0] = %q, want 'hello world'", m.ActiveBuffer().Lines[0])
}
})
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] != "line1" {
t.Errorf("lines[0] = %q, want 'line1'", m.ActiveBuffer().Lines[0])
}
})
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] != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0])
}
if m.ActiveBuffer().Lines[1] != "world" {
t.Errorf("lines[1] = %q, want 'world'", m.ActiveBuffer().Lines[1])
}
})
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] != "hello world" {
t.Errorf("lines[0] = %q, want 'hello world'", m.ActiveBuffer().Lines[0])
}
})
}
// ============================================================================
// 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] != "hello world foo" {
t.Errorf("lines[0] = %q, want 'hello world foo'", m.ActiveBuffer().Lines[0])
}
})
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] != "hello world foo" {
t.Errorf("lines[0] = %q, want 'hello world foo'", m.ActiveBuffer().Lines[0])
}
})
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] != "before (hello) after" {
t.Errorf("lines[0] = %q, want 'before (hello) after'", m.ActiveBuffer().Lines[0])
}
})
}
// ============================================================================
// 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] != "llo" {
t.Errorf("After 2 redos: lines[0] = %q, want 'llo'", m.ActiveBuffer().Lines[0])
}
})
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] != "llo" {
t.Errorf("lines[0] = %q, want 'llo'", m.ActiveBuffer().Lines[0])
}
})
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] != "newline3" {
t.Errorf("After insert: lines[0] = %q, want 'newline3'", m.ActiveBuffer().Lines[0])
}
})
}
// ============================================================================
// 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] != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0])
}
})
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] != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0])
}
})
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] != "ello" {
t.Errorf("lines[0] = %q, want 'ello'", m.ActiveBuffer().Lines[0])
}
})
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] != "only line" {
t.Errorf("lines[0] = %q, want 'only line'", m.ActiveBuffer().Lines[0])
}
})
}
// ============================================================================
// 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] != expected {
t.Errorf("lines[%d] = %q, want %q", i, m.ActiveBuffer().Lines[i], 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] != "line1" {
t.Errorf("lines[0] = %q, want 'line1'", m.ActiveBuffer().Lines[0])
}
})
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] != "line1" {
t.Errorf("lines[0] = %q, want 'line1'", m.ActiveBuffer().Lines[0])
}
})
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] != "line2" {
t.Errorf("lines[1] = %q, want 'line2'", m.ActiveBuffer().Lines[1])
}
})
}
// ============================================================================
// 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] != "" {
t.Errorf("After undo: lines[0] = %q, want ''", m.ActiveBuffer().Lines[0])
}
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] != "bc" {
t.Errorf("lines[0] = %q, want 'bc'", m.ActiveBuffer().Lines[0])
}
})
}

View File

@ -3,6 +3,7 @@ package input
import (
"git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/operator"
tea "github.com/charmbracelet/bubbletea"
)
@ -540,6 +541,17 @@ func (h *Handler) executeAction(m action.Model, act action.Action) tea.Cmd {
cmd := act.Execute(m)
// If the action one that includes insert mode, we should not end the block, we want to
// include the text from the insert mode in the block.
_, O := act.(action.OpenLineAbove)
_, o := act.(action.OpenLineBelow)
_, s := act.(action.SubstituteChar)
_, S := act.(action.SubstituteLine)
_, C := act.(action.ChangeToEndOfLine)
if o || O || s || S || C {
return nil
}
if buf.UndoStack != nil {
buf.UndoStack.EndBlock(win.Cursor)
}
@ -562,6 +574,12 @@ func (h *Handler) executeOperator(m action.Model, op action.Operator, start, end
cmd := op.Operate(m, start, end, mtype)
// If operator is one that enters insert mode, we do not want to end the block.
_, c := op.(operator.ChangeOperator)
if c {
return cmd
}
if buf.UndoStack != nil {
buf.UndoStack.EndBlock(win.Cursor)
}
@ -579,6 +597,13 @@ func (h *Handler) executeDoublePress(m action.Model, dp action.DoublePresser, co
cmd := dp.DoublePress(m, count)
// If operator being double pressed is one that enters insert mode, we do not
// want to end the block.
_, c := dp.(operator.ChangeOperator)
if c {
return cmd
}
if buf.UndoStack != nil {
buf.UndoStack.EndBlock(win.Cursor)
}