diff --git a/internal/editor/integration_undo_test.go b/internal/editor/integration_undo_test.go index 9fb98f1..a086499 100644 --- a/internal/editor/integration_undo_test.go +++ b/internal/editor/integration_undo_test.go @@ -6,6 +6,19 @@ import ( "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 // ============================================================================ @@ -747,3 +760,233 @@ func TestUndoComplexScenarios(t *testing.T) { } }) } + +// ================================================================= +// 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] != exp { + t.Errorf("Lines[%d] = %q, want %q", i, m.ActiveBuffer().Lines[i], 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] != exp { + t.Errorf("Lines[%d] = %q, want %q", i, m.ActiveBuffer().Lines[i], 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(m.ActiveBuffer().Lines, 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] != exp { + t.Errorf("Lines[%d] = %q, want %q", i, m.ActiveBuffer().Lines[i], 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(m.ActiveBuffer().Lines, 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(m.ActiveBuffer().Lines, 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(m.ActiveBuffer().Lines, 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(m.ActiveBuffer().Lines, 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(m.ActiveBuffer().Lines, 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(m.ActiveBuffer().Lines, 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(m.ActiveBuffer().Lines, 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(m.ActiveBuffer().Lines, 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) + } + }) +}