package editor import ( "strings" "testing" tea "github.com/charmbracelet/bubbletea" ) // typeRunes feeds each rune as a KeyRunes message (the typing path). func typeRunes(e *Editor, s string) { for _, r := range s { e.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) } } func TestUndoCoalescesTyping(t *testing.T) { e := newEditorWith("") typeRunes(e, "abc") if e.Lines[0] != "abc" { t.Fatalf("setup: %q", e.Lines[0]) } e.Undo() if e.Lines[0] != "" { t.Errorf("one undo should revert the whole typing run, got %q", e.Lines[0]) } } func TestUndoStructuralOpsAreSeparateGroups(t *testing.T) { e := newEditorWith("") typeRunes(e, "ab") e.HandleKey(tea.KeyMsg{Type: tea.KeyEnter}) // structural: own group typeRunes(e, "cd") if len(e.Lines) != 2 || e.Lines[1] != "cd" { t.Fatalf("setup: %v", e.Lines) } e.Undo() // removes "cd" if len(e.Lines) != 2 || e.Lines[1] != "" { t.Fatalf("after undo cd: %v", e.Lines) } e.Undo() // removes the newline if len(e.Lines) != 1 || e.Lines[0] != "ab" { t.Fatalf("after undo newline: %v", e.Lines) } e.Undo() // removes "ab" if e.Lines[0] != "" { t.Errorf("after undo ab: %q", e.Lines[0]) } } func TestUndoRestoresCursorAndSelection(t *testing.T) { e := newEditorWith("hello world") // Select "world" then type over it. e.Cursor = Position{Row: 0, Col: 6} e.HandleKey(tea.KeyMsg{Type: tea.KeyShiftEnd}) // selects "world" if e.SelectedText() != "world" { t.Fatalf("setup selection: %q", e.SelectedText()) } e.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'X'}}) if e.Lines[0] != "hello X" { t.Fatalf("after type-over: %q", e.Lines[0]) } e.Undo() if e.Lines[0] != "hello world" { t.Errorf("undo content: %q", e.Lines[0]) } if !e.HasSelection() || e.SelectedText() != "world" { t.Errorf("undo should restore the selection, got %q", e.SelectedText()) } } func TestRedoReappliesUndoneEdit(t *testing.T) { e := newEditorWith("") typeRunes(e, "abc") e.Undo() e.Redo() if e.Lines[0] != "abc" { t.Errorf("redo should reapply, got %q", e.Lines[0]) } } func TestNewEditClearsRedoStack(t *testing.T) { e := newEditorWith("") typeRunes(e, "abc") e.Undo() // now "" with redo available typeRunes(e, "z") e.Redo() // redo stack should be empty → no-op if e.Lines[0] != "z" { t.Errorf("redo after a new edit must be a no-op, got %q", e.Lines[0]) } } func TestUndoOnEmptyHistoryIsNoOp(t *testing.T) { e := newEditorWith("seed") e.Undo() e.Redo() if e.Lines[0] != "seed" { t.Errorf("undo/redo with no history changed buffer: %q", e.Lines[0]) } } func TestSetContentResetsHistory(t *testing.T) { e := newEditorWith("") typeRunes(e, "abc") e.SetContent([]byte("fresh")) e.Undo() if e.Lines[0] != "fresh" { t.Errorf("SetContent must clear undo history, got %q", e.Lines[0]) } } func TestHistoryIsBounded(t *testing.T) { e := newEditorWith("") // Each Enter is its own structural group; exceed the cap. for i := 0; i < maxUndo+50; i++ { e.HandleKey(tea.KeyMsg{Type: tea.KeyEnter}) } lines := len(e.Lines) for i := 0; i < maxUndo+50; i++ { e.Undo() } // Capped history can't undo every newline; the oldest are dropped. if len(e.Lines) == 1 { t.Errorf("history should be bounded; expected residual newlines, got fully unwound from %d", lines) } } func TestNoOpKeyDoesNotRecord(t *testing.T) { e := newEditorWith("x") typeRunes(e, "y") // "xy"... actually inserts at col0 unless cursor moved // Move to very start and backspace (no-op at row0,col0). e.Cursor = Position{Row: 0, Col: 0} e.HandleKey(tea.KeyMsg{Type: tea.KeyBackspace}) // no-op before := strings.Join(e.Lines, "\n") e.Undo() // should undo the typing, not the no-op backspace if strings.Join(e.Lines, "\n") == before { t.Errorf("a no-op key must not consume an undo slot") } } func TestPushUndoSupportsAppDrivenEdits(t *testing.T) { e := newEditorWith("hello") e.Cursor = Position{Row: 0, Col: 5} e.PushUndo() // app records before a paste/cut e.InsertText(" world") if e.Lines[0] != "hello world" { t.Fatalf("setup: %q", e.Lines[0]) } e.Undo() if e.Lines[0] != "hello" { t.Errorf("PushUndo+Undo should revert app edit, got %q", e.Lines[0]) } }