package editor import ( "testing" "glint/internal/theme" tea "github.com/charmbracelet/bubbletea" ) func newEditorWith(lines ...string) *Editor { e := New() e.Lines = append([]string{}, lines...) e.SetSize(80, 10) return e } func TestSetContentSplitsLines(t *testing.T) { e := New() e.SetContent([]byte("a\nb\nc")) if len(e.Lines) != 3 || e.Lines[1] != "b" { t.Fatalf("Lines = %v", e.Lines) } if e.Dirty { t.Error("SetContent should clear Dirty") } } func TestBytesRoundTrip(t *testing.T) { e := New() e.SetContent([]byte("x\ny")) if string(e.Bytes()) != "x\ny" { t.Errorf("Bytes = %q", string(e.Bytes())) } } func TestInsertRune(t *testing.T) { e := newEditorWith("ac") e.Cursor = Position{Row: 0, Col: 1} e.InsertRune('b') if e.Lines[0] != "abc" { t.Errorf("Lines[0] = %q, want abc", e.Lines[0]) } if e.Cursor.Col != 2 { t.Errorf("Cursor.Col = %d, want 2", e.Cursor.Col) } if !e.Dirty { t.Error("insert should set Dirty") } } func TestInsertNewlineSplitsLine(t *testing.T) { e := newEditorWith("abcd") e.Cursor = Position{Row: 0, Col: 2} e.InsertNewline() if len(e.Lines) != 2 || e.Lines[0] != "ab" || e.Lines[1] != "cd" { t.Fatalf("Lines = %v", e.Lines) } if e.Cursor.Row != 1 || e.Cursor.Col != 0 { t.Errorf("Cursor = %+v, want {1 0}", e.Cursor) } } func TestBackspaceJoinsLines(t *testing.T) { e := newEditorWith("ab", "cd") e.Cursor = Position{Row: 1, Col: 0} e.Backspace() if len(e.Lines) != 1 || e.Lines[0] != "abcd" { t.Fatalf("Lines = %v", e.Lines) } if e.Cursor.Row != 0 || e.Cursor.Col != 2 { t.Errorf("Cursor = %+v, want {0 2}", e.Cursor) } } func TestBackspaceWithinLine(t *testing.T) { e := newEditorWith("abc") e.Cursor = Position{Row: 0, Col: 2} e.Backspace() if e.Lines[0] != "ac" || e.Cursor.Col != 1 { t.Errorf("Lines[0]=%q Cursor.Col=%d", e.Lines[0], e.Cursor.Col) } } func TestMovementClampsAndCrossesLines(t *testing.T) { e := newEditorWith("ab", "cde") e.Cursor = Position{Row: 0, Col: 2} // end of "ab" e.MoveRight() // wraps to start of next line if e.Cursor != (Position{Row: 1, Col: 0}) { t.Errorf("after MoveRight: %+v", e.Cursor) } e.MoveLeft() // back to end of "ab" if e.Cursor != (Position{Row: 0, Col: 2}) { t.Errorf("after MoveLeft: %+v", e.Cursor) } e.MoveEnd() e.MoveDown() // col clamps to len("cde")=3 if e.Cursor != (Position{Row: 1, Col: 2}) { t.Errorf("after MoveDown: %+v, want {1 2}", e.Cursor) } } func TestScrollFollowsCursorDown(t *testing.T) { e := New() e.SetSize(80, 3) // 3 visible rows for i := 0; i < 10; i++ { e.Lines = append(e.Lines, "x") } e.Cursor = Position{Row: 0, Col: 0} for i := 0; i < 9; i++ { e.MoveDown() } // cursor at row 9 must be visible within [Scroll, Scroll+3) if e.Cursor.Row < e.Scroll || e.Cursor.Row >= e.Scroll+e.Height { t.Errorf("cursor row %d not in viewport [%d,%d)", e.Cursor.Row, e.Scroll, e.Scroll+e.Height) } } func TestHandleKeyInsertsRunes(t *testing.T) { e := newEditorWith("") e.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h', 'i'}}) if e.Lines[0] != "hi" { t.Errorf("Lines[0] = %q, want hi", e.Lines[0]) } } func TestViewRendersVisibleRowsOnly(t *testing.T) { e := New() e.SetSize(80, 2) e.Lines = []string{"one", "two", "three"} e.Cursor = Position{Row: 0, Col: 0} view := e.View() // 2 visible rows -> exactly 2 newline-terminated lines if got := countByte(view, '\n'); got != 2 { t.Errorf("view has %d newlines, want 2", got) } } func countByte(s string, b byte) int { n := 0 for i := 0; i < len(s); i++ { if s[i] == b { n++ } } return n } func TestHandleKeySpaceInsertsSingleSpace(t *testing.T) { e := newEditorWith("ab") e.Cursor = Position{Row: 0, Col: 1} e.HandleKey(tea.KeyMsg{Type: tea.KeySpace, Runes: []rune{' '}}) if e.Lines[0] != "a b" { t.Errorf("Lines[0] = %q, want 'a b'", e.Lines[0]) } } func TestDeleteWithinLine(t *testing.T) { e := newEditorWith("abc") e.Cursor = Position{Row: 0, Col: 1} e.Delete() if e.Lines[0] != "ac" { t.Errorf("Lines[0] = %q, want 'ac'", e.Lines[0]) } if e.Cursor.Col != 1 { t.Errorf("Cursor.Col = %d, want 1", e.Cursor.Col) } if !e.Dirty { t.Error("Delete should set Dirty") } } func TestDeleteAtEndOfLastLineIsNoop(t *testing.T) { e := newEditorWith("ab") e.Cursor = Position{Row: 0, Col: 2} original := e.Lines[0] e.Delete() if e.Lines[0] != original { t.Errorf("Lines[0] = %q, want %q", e.Lines[0], original) } if len(e.Lines) != 1 { t.Errorf("len(Lines) = %d, want 1", len(e.Lines)) } } func TestDeleteJoinsNextLine(t *testing.T) { e := newEditorWith("ab", "cd") e.Cursor = Position{Row: 0, Col: 2} e.Delete() if e.Lines[0] != "abcd" { t.Errorf("Lines[0] = %q, want 'abcd'", e.Lines[0]) } if len(e.Lines) != 1 { t.Errorf("len(Lines) = %d, want 1", len(e.Lines)) } } func TestBackspaceAtOriginIsNoop(t *testing.T) { e := newEditorWith("ab") e.Cursor = Position{Row: 0, Col: 0} original := e.Lines[0] e.Backspace() if e.Lines[0] != original { t.Errorf("Lines[0] = %q, want %q", e.Lines[0], original) } if e.Cursor != (Position{Row: 0, Col: 0}) { t.Errorf("Cursor = %+v, want {0 0}", e.Cursor) } } func TestMoveHomeAndEnd(t *testing.T) { e := newEditorWith("hello") e.Cursor = Position{Row: 0, Col: 3} e.MoveHome() if e.Cursor.Col != 0 { t.Errorf("after MoveHome: Cursor.Col = %d, want 0", e.Cursor.Col) } e.MoveEnd() if e.Cursor.Col != 5 { t.Errorf("after MoveEnd: Cursor.Col = %d, want 5", e.Cursor.Col) } } func TestVisualDownAndUpAcrossWrappedLine(t *testing.T) { e := New() e.Lines = []string{"aaaa bbbb cccc"} // wraps at width 9 into ["aaaa ","bbbb cccc"] e.SetSize(9, 10) e.Cursor = Position{Row: 0, Col: 0} e.setGoal() // ops normally set the goal; set it directly here e.MoveDown() // to the second visual row, goal column 0 if e.Cursor != (Position{Row: 0, Col: 5}) { t.Errorf("after MoveDown: %+v, want {0 5}", e.Cursor) } e.MoveUp() // back to the first visual row, goal column 0 if e.Cursor != (Position{Row: 0, Col: 0}) { t.Errorf("after MoveUp: %+v, want {0 0}", e.Cursor) } } func TestVisualDownKeepsGoalColumn(t *testing.T) { e := New() e.Lines = []string{"aaaa bbbb cccc"} e.SetSize(9, 10) e.Cursor = Position{Row: 0, Col: 2} // visual column 2 in segment 0 e.setGoal() // ops normally set the goal; set it directly here e.MoveDown() // segment 1 starts at col 5 → col 5+2 = 7 if e.Cursor != (Position{Row: 0, Col: 7}) { t.Errorf("after MoveDown with goal: %+v, want {0 7}", e.Cursor) } } func TestBytesRoundTripUnaffectedByWrap(t *testing.T) { e := New() e.SetContent([]byte("a very long single logical line that will wrap many times\nsecond")) e.SetSize(10, 5) if string(e.Bytes()) != "a very long single logical line that will wrap many times\nsecond" { t.Errorf("wrap altered the buffer: %q", string(e.Bytes())) } } func TestScrollIsVisualRowIndex(t *testing.T) { e := New() e.Lines = []string{"aaaa bbbb cccc dddd eeee ffff"} // one logical line, many visual rows at width 9 e.SetSize(9, 2) // only 2 visible visual rows e.Cursor = Position{Row: 0, Col: 0} // Move down several visual rows; Scroll must follow so the cursor stays visible. for i := 0; i < 3; i++ { e.MoveDown() } rows := e.buildVisual() ci := cursorVIndex(rows, e.Cursor) if ci < e.Scroll || ci >= e.Scroll+e.Height { t.Errorf("cursor visual row %d not in viewport [%d,%d)", ci, e.Scroll, e.Scroll+e.Height) } } func TestBuildVisualSpansCarryThemeBackground(t *testing.T) { e := New() th := theme.FlexokiDark() e.SetTheme(th) e.Lines = []string{"# Heading **bold**", "plain text", ""} for _, vr := range e.buildVisual() { for _, sp := range vr.spans { if sp.Style.GetBackground() != th.Background { t.Errorf("span %q background = %v, want theme background %v", sp.Text, sp.Style.GetBackground(), th.Background) } } } }