diff --git a/internal/core/buffer_test.go b/internal/core/buffer_test.go new file mode 100644 index 0000000..4620aa8 --- /dev/null +++ b/internal/core/buffer_test.go @@ -0,0 +1,302 @@ +package core + +import "testing" + +// -------------------------------------------------- +// Buffer Tests (generated by ClaudeCode) +// -------------------------------------------------- + +func TestBuffer_InsertLine(t *testing.T) { + t.Run("inserts at beginning", func(t *testing.T) { + buf := NewBufferBuilder(). + WithLines([]string{"line 1", "line 2"}). + Build() + + buf.InsertLine(0, "new line") + + if buf.LineCount() != 3 { + t.Errorf("expected 3 lines, got %d", buf.LineCount()) + } + if buf.Line(0) != "new line" { + t.Errorf("expected 'new line', got '%s'", buf.Line(0)) + } + if buf.Line(1) != "line 1" { + t.Errorf("expected 'line 1' at index 1, got '%s'", buf.Line(1)) + } + }) + + t.Run("inserts at end", func(t *testing.T) { + buf := NewBufferBuilder(). + WithLines([]string{"line 1", "line 2"}). + Build() + + buf.InsertLine(2, "new line") + + if buf.LineCount() != 3 { + t.Errorf("expected 3 lines, got %d", buf.LineCount()) + } + if buf.Line(2) != "new line" { + t.Errorf("expected 'new line' at end, got '%s'", buf.Line(2)) + } + }) + + t.Run("inserts in middle", func(t *testing.T) { + buf := NewBufferBuilder(). + WithLines([]string{"line 1", "line 3"}). + Build() + + buf.InsertLine(1, "line 2") + + if buf.LineCount() != 3 { + t.Errorf("expected 3 lines, got %d", buf.LineCount()) + } + if buf.Line(1) != "line 2" { + t.Errorf("expected 'line 2' at index 1, got '%s'", buf.Line(1)) + } + }) + + t.Run("handles empty buffer", func(t *testing.T) { + buf := NewBufferBuilder().Build() + + buf.InsertLine(0, "first line") + + if buf.LineCount() != 2 { // Original empty line + new line + t.Errorf("expected 2 lines, got %d", buf.LineCount()) + } + }) +} + +func TestBuffer_DeleteLine(t *testing.T) { + t.Run("deletes from beginning", func(t *testing.T) { + buf := NewBufferBuilder(). + WithLines([]string{"DELETE ME", "line 2", "line 3"}). + Build() + + buf.DeleteLine(0) + + if buf.LineCount() != 2 { + t.Errorf("expected 2 lines, got %d", buf.LineCount()) + } + if buf.Line(0) != "line 2" { + t.Errorf("expected 'line 2' at index 0, got '%s'", buf.Line(0)) + } + }) + + t.Run("deletes from middle", func(t *testing.T) { + buf := NewBufferBuilder(). + WithLines([]string{"line 1", "DELETE ME", "line 3"}). + Build() + + buf.DeleteLine(1) + + if buf.LineCount() != 2 { + t.Errorf("expected 2 lines, got %d", buf.LineCount()) + } + if buf.Line(1) != "line 3" { + t.Errorf("expected 'line 3' at index 1, got '%s'", buf.Line(1)) + } + }) + + t.Run("deletes from end", func(t *testing.T) { + buf := NewBufferBuilder(). + WithLines([]string{"line 1", "line 2", "DELETE ME"}). + Build() + + buf.DeleteLine(2) + + if buf.LineCount() != 2 { + t.Errorf("expected 2 lines, got %d", buf.LineCount()) + } + if buf.Line(1) != "line 2" { + t.Errorf("expected 'line 2' at index 1, got '%s'", buf.Line(1)) + } + }) + + t.Run("can delete all lines", func(t *testing.T) { + buf := NewBufferBuilder(). + WithLines([]string{"only line"}). + Build() + + buf.DeleteLine(0) + + // Buffer allows being completely empty (0 lines) + if buf.LineCount() != 0 { + t.Errorf("expected 0 lines after deleting last line, got %d", buf.LineCount()) + } + }) +} + +func TestBuffer_SetLine(t *testing.T) { + t.Run("updates existing line", func(t *testing.T) { + buf := NewBufferBuilder(). + WithLines([]string{"old content"}). + Build() + + buf.SetLine(0, "new content") + + if buf.Line(0) != "new content" { + t.Errorf("expected 'new content', got '%s'", buf.Line(0)) + } + }) + + t.Run("updates middle line", func(t *testing.T) { + buf := NewBufferBuilder(). + WithLines([]string{"line 1", "old", "line 3"}). + Build() + + buf.SetLine(1, "new") + + if buf.Line(1) != "new" { + t.Errorf("expected 'new', got '%s'", buf.Line(1)) + } + // Verify other lines unchanged + if buf.Line(0) != "line 1" { + t.Error("line 0 should be unchanged") + } + if buf.Line(2) != "line 3" { + t.Error("line 2 should be unchanged") + } + }) + + t.Run("can set to empty string", func(t *testing.T) { + buf := NewBufferBuilder(). + WithLines([]string{"has content"}). + Build() + + buf.SetLine(0, "") + + if buf.Line(0) != "" { + t.Errorf("expected empty line, got '%s'", buf.Line(0)) + } + }) +} + +func TestBuffer_LineCount(t *testing.T) { + t.Run("empty buffer has one line", func(t *testing.T) { + buf := NewBufferBuilder().Build() + + if buf.LineCount() != 1 { + t.Errorf("expected 1 line in empty buffer, got %d", buf.LineCount()) + } + }) + + t.Run("counts multiple lines", func(t *testing.T) { + buf := NewBufferBuilder(). + WithLines([]string{"a", "b", "c", "d", "e"}). + Build() + + if buf.LineCount() != 5 { + t.Errorf("expected 5 lines, got %d", buf.LineCount()) + } + }) + + t.Run("counts after insertions", func(t *testing.T) { + buf := NewBufferBuilder(). + WithLines([]string{"line 1"}). + Build() + + buf.InsertLine(1, "line 2") + buf.InsertLine(2, "line 3") + + if buf.LineCount() != 3 { + t.Errorf("expected 3 lines, got %d", buf.LineCount()) + } + }) + + t.Run("counts after deletions", func(t *testing.T) { + buf := NewBufferBuilder(). + WithLines([]string{"a", "b", "c", "d", "e"}). + Build() + + buf.DeleteLine(2) + buf.DeleteLine(2) + + if buf.LineCount() != 3 { + t.Errorf("expected 3 lines after 2 deletions, got %d", buf.LineCount()) + } + }) +} + +func TestBuffer_Line(t *testing.T) { + t.Run("retrieves correct line", func(t *testing.T) { + buf := NewBufferBuilder(). + WithLines([]string{"first", "second", "third"}). + Build() + + if buf.Line(0) != "first" { + t.Errorf("expected 'first', got '%s'", buf.Line(0)) + } + if buf.Line(1) != "second" { + t.Errorf("expected 'second', got '%s'", buf.Line(1)) + } + if buf.Line(2) != "third" { + t.Errorf("expected 'third', got '%s'", buf.Line(2)) + } + }) + + t.Run("handles special characters", func(t *testing.T) { + buf := NewBufferBuilder(). + WithLines([]string{"hello\tworld", "foo\nbar"}). + Build() + + if buf.Line(0) != "hello\tworld" { + t.Errorf("expected tabs preserved, got '%s'", buf.Line(0)) + } + }) + + t.Run("handles unicode", func(t *testing.T) { + buf := NewBufferBuilder(). + WithLines([]string{"hello δΈ–η•Œ", "emoji πŸŽ‰"}). + Build() + + if buf.Line(0) != "hello δΈ–η•Œ" { + t.Errorf("expected unicode preserved, got '%s'", buf.Line(0)) + } + if buf.Line(1) != "emoji πŸŽ‰" { + t.Errorf("expected emoji preserved, got '%s'", buf.Line(1)) + } + }) +} + +func TestBufferBuilder(t *testing.T) { + t.Run("builds with default values", func(t *testing.T) { + buf := NewBufferBuilder().Build() + + if buf.LineCount() != 1 { + t.Errorf("expected 1 empty line, got %d", buf.LineCount()) + } + if buf.Filename != "" { + t.Errorf("expected empty filename, got '%s'", buf.Filename) + } + }) + + t.Run("builds with custom lines", func(t *testing.T) { + buf := NewBufferBuilder(). + WithLines([]string{"line 1", "line 2"}). + Build() + + if buf.LineCount() != 2 { + t.Errorf("expected 2 lines, got %d", buf.LineCount()) + } + }) + + t.Run("builds with filename", func(t *testing.T) { + buf := NewBufferBuilder(). + WithFilename("test.txt"). + Build() + + if buf.Filename != "test.txt" { + t.Errorf("expected filename 'test.txt', got '%s'", buf.Filename) + } + }) + + t.Run("builds with filetype", func(t *testing.T) { + buf := NewBufferBuilder(). + WithFiletype("go"). + Build() + + if buf.Filetype != "go" { + t.Errorf("expected filetype 'go', got '%s'", buf.Filetype) + } + }) +} diff --git a/internal/core/window_test.go b/internal/core/window_test.go new file mode 100644 index 0000000..a755819 --- /dev/null +++ b/internal/core/window_test.go @@ -0,0 +1,493 @@ +package core + +import "testing" + +// -------------------------------------------------- +// Window Tests (generated by ClaudeCode) +// -------------------------------------------------- + +func TestWindow_SetCursorLine(t *testing.T) { + t.Run("clamps cursor below zero", func(t *testing.T) { + buf := NewBufferBuilder(). + WithLines([]string{"line 1", "line 2", "line 3"}). + Build() + win := NewWindowBuilder(). + WithBuffer(&buf). + Build() + + win.SetCursorLine(-5) + + if win.Cursor.Line != 0 { + t.Errorf("expected cursor at line 0, got %d", win.Cursor.Line) + } + }) + + t.Run("clamps cursor past end", func(t *testing.T) { + buf := NewBufferBuilder(). + WithLines([]string{"line 1", "line 2", "line 3"}). + Build() + win := NewWindowBuilder(). + WithBuffer(&buf). + Build() + + win.SetCursorLine(999) + + if win.Cursor.Line != 2 { // 3 lines, max index is 2 + t.Errorf("expected cursor at line 2, got %d", win.Cursor.Line) + } + }) + + t.Run("allows valid position", func(t *testing.T) { + buf := NewBufferBuilder(). + WithLines([]string{"line 1", "line 2", "line 3"}). + Build() + win := NewWindowBuilder(). + WithBuffer(&buf). + Build() + + win.SetCursorLine(1) + + if win.Cursor.Line != 1 { + t.Errorf("expected cursor at line 1, got %d", win.Cursor.Line) + } + }) + + t.Run("handles empty buffer", func(t *testing.T) { + buf := NewBufferBuilder().Build() + win := NewWindowBuilder(). + WithBuffer(&buf). + Build() + + win.SetCursorLine(5) + + if win.Cursor.Line != 0 { + t.Errorf("expected cursor at line 0 for empty buffer, got %d", win.Cursor.Line) + } + }) +} + +func TestWindow_SetCursorCol(t *testing.T) { + t.Run("clamps to line length", func(t *testing.T) { + buf := NewBufferBuilder(). + WithLines([]string{"hello"}). + Build() + win := NewWindowBuilder(). + WithBuffer(&buf). + Build() + + win.SetCursorCol(999) + + // "hello" is 5 chars, max col should be 5 (after last char for insert mode) + if win.Cursor.Col > 5 { + t.Errorf("expected cursor col <= 5, got %d", win.Cursor.Col) + } + }) + + t.Run("clamps below zero", func(t *testing.T) { + buf := NewBufferBuilder(). + WithLines([]string{"hello"}). + Build() + win := NewWindowBuilder(). + WithBuffer(&buf). + Build() + + win.SetCursorCol(-10) + + if win.Cursor.Col != 0 { + t.Errorf("expected cursor col 0, got %d", win.Cursor.Col) + } + }) + + t.Run("handles empty line", func(t *testing.T) { + buf := NewBufferBuilder(). + WithLines([]string{""}). + Build() + win := NewWindowBuilder(). + WithBuffer(&buf). + Build() + + win.SetCursorCol(5) + + if win.Cursor.Col != 0 { + t.Errorf("expected cursor at col 0 on empty line, got %d", win.Cursor.Col) + } + }) + + t.Run("allows cursor at end of line", func(t *testing.T) { + buf := NewBufferBuilder(). + WithLines([]string{"hello"}). + Build() + win := NewWindowBuilder(). + WithBuffer(&buf). + Build() + + win.SetCursorCol(5) // After last char + + if win.Cursor.Col != 5 { + t.Errorf("expected cursor at col 5, got %d", win.Cursor.Col) + } + }) +} + +func TestWindow_AdjustScroll(t *testing.T) { + t.Run("scrolls down when cursor goes below viewport", func(t *testing.T) { + // Create buffer with many lines + lines := make([]string, 100) + for i := range lines { + lines[i] = "line" + } + buf := NewBufferBuilder().WithLines(lines).Build() + + win := NewWindowBuilder(). + WithBuffer(&buf). + WithHeight(24). + Build() + + // Start at top + win.SetCursorLine(0) + win.AdjustScroll() + initialScroll := win.ScrollY + + // Move cursor way down + win.SetCursorLine(50) + win.AdjustScroll() + + // Scroll should have increased + if win.ScrollY <= initialScroll { + t.Errorf("expected scroll to increase, was %d, now %d", initialScroll, win.ScrollY) + } + + // Cursor should be visible + viewport := win.ViewportHeight() + if win.Cursor.Line < win.ScrollY || win.Cursor.Line >= win.ScrollY+viewport { + t.Errorf("cursor at %d not visible in scroll range [%d, %d)", + win.Cursor.Line, win.ScrollY, win.ScrollY+viewport) + } + }) + + t.Run("scrolls up when cursor goes above viewport", func(t *testing.T) { + lines := make([]string, 100) + for i := range lines { + lines[i] = "line" + } + buf := NewBufferBuilder().WithLines(lines).Build() + + win := NewWindowBuilder(). + WithBuffer(&buf). + WithHeight(24). + Build() + + // Start at bottom + win.SetCursorLine(80) + win.AdjustScroll() + initialScroll := win.ScrollY + + // Move cursor to top + win.SetCursorLine(5) + win.AdjustScroll() + + // Scroll should have decreased + if win.ScrollY >= initialScroll { + t.Errorf("expected scroll to decrease, was %d, now %d", initialScroll, win.ScrollY) + } + + // Cursor should be visible + viewport := win.ViewportHeight() + if win.Cursor.Line < win.ScrollY || win.Cursor.Line >= win.ScrollY+viewport { + t.Errorf("cursor at %d not visible in scroll range [%d, %d)", + win.Cursor.Line, win.ScrollY, win.ScrollY+viewport) + } + }) + + t.Run("respects scrolloff margin", func(t *testing.T) { + lines := make([]string, 100) + for i := range lines { + lines[i] = "line" + } + buf := NewBufferBuilder().WithLines(lines).Build() + + win := NewWindowBuilder(). + WithBuffer(&buf). + WithHeight(24). + Build() + + // Set scrolloff to 5 + opts := win.Options + opts.ScrollOff = 5 + win.SetOptions(opts) + + // Move to line 20 and adjust + win.SetCursorLine(20) + win.AdjustScroll() + + viewport := win.ViewportHeight() + distFromTop := win.Cursor.Line - win.ScrollY + distFromBottom := (win.ScrollY + viewport - 1) - win.Cursor.Line + + // At least one should respect scrolloff + if distFromTop < opts.ScrollOff && distFromBottom < opts.ScrollOff { + t.Errorf("scrolloff %d not respected: top=%d, bottom=%d", + opts.ScrollOff, distFromTop, distFromBottom) + } + }) + + t.Run("handles scrolloff larger than half viewport", func(t *testing.T) { + lines := make([]string, 100) + for i := range lines { + lines[i] = "line" + } + buf := NewBufferBuilder().WithLines(lines).Build() + + win := NewWindowBuilder(). + WithBuffer(&buf). + WithHeight(24). + Build() + + // Set scrolloff larger than half viewport + opts := win.Options + opts.ScrollOff = 999 + win.SetOptions(opts) + + win.SetCursorLine(50) + win.AdjustScroll() + + // Should not panic or error + viewport := win.ViewportHeight() + if win.Cursor.Line < win.ScrollY || win.Cursor.Line >= win.ScrollY+viewport { + t.Error("cursor should still be visible with large scrolloff") + } + }) + + t.Run("handles small viewport", func(t *testing.T) { + lines := make([]string, 100) + for i := range lines { + lines[i] = "line" + } + buf := NewBufferBuilder().WithLines(lines).Build() + + win := NewWindowBuilder(). + WithBuffer(&buf). + WithHeight(5). // Very small + Build() + + win.SetCursorLine(50) + win.AdjustScroll() + + // Should not panic + viewport := win.ViewportHeight() + if viewport > 0 && (win.Cursor.Line < win.ScrollY || win.Cursor.Line >= win.ScrollY+viewport) { + t.Error("cursor should be visible in small viewport") + } + }) +} + +func TestWindow_ViewportHeight(t *testing.T) { + t.Run("calculates viewport height correctly", func(t *testing.T) { + buf := NewBufferBuilder().Build() + win := NewWindowBuilder(). + WithBuffer(&buf). + WithHeight(24). + Build() + + // Height - 2 (status bar + command bar) + expected := 22 + if win.ViewportHeight() != expected { + t.Errorf("expected viewport height %d, got %d", expected, win.ViewportHeight()) + } + }) + + t.Run("handles small window", func(t *testing.T) { + buf := NewBufferBuilder().Build() + win := NewWindowBuilder(). + WithBuffer(&buf). + WithHeight(3). + Build() + + // 3 - 2 = 1 + expected := 1 + if win.ViewportHeight() != expected { + t.Errorf("expected viewport height %d, got %d", expected, win.ViewportHeight()) + } + }) + + t.Run("handles zero height", func(t *testing.T) { + buf := NewBufferBuilder().Build() + win := NewWindowBuilder(). + WithBuffer(&buf). + WithHeight(0). + Build() + + // With height 0, viewport is 0 - 2 (status + command bars) = -2 + // This is an edge case that shouldn't occur in practice, but shouldn't panic + result := win.ViewportHeight() + expected := -2 + if result != expected { + t.Errorf("expected viewport height %d for zero height window, got %d", expected, result) + } + }) +} + +func TestWindow_SetOptions(t *testing.T) { + t.Run("updates options", func(t *testing.T) { + buf := NewBufferBuilder().Build() + win := NewWindowBuilder(). + WithBuffer(&buf). + Build() + + newOpts := WinOptions{ + Number: false, + RelativeNumber: false, + GutterSize: 10, + ScrollOff: 3, + } + + win.SetOptions(newOpts) + + if win.Options.Number != false { + t.Error("expected Number to be false") + } + if win.Options.RelativeNumber != false { + t.Error("expected RelativeNumber to be false") + } + if win.Options.GutterSize != 10 { + t.Errorf("expected GutterSize 10, got %d", win.Options.GutterSize) + } + if win.Options.ScrollOff != 3 { + t.Errorf("expected ScrollOff 3, got %d", win.Options.ScrollOff) + } + }) + + t.Run("can toggle individual options", func(t *testing.T) { + buf := NewBufferBuilder().Build() + win := NewWindowBuilder(). + WithBuffer(&buf). + Build() + + // Get current options + opts := win.Options + originalNumber := opts.Number + + // Toggle number + opts.Number = !opts.Number + win.SetOptions(opts) + + if win.Options.Number == originalNumber { + t.Error("Number option should have toggled") + } + }) +} + +func TestWindow_SetAnchor(t *testing.T) { + t.Run("sets anchor line", func(t *testing.T) { + buf := NewBufferBuilder(). + WithLines([]string{"a", "b", "c"}). + Build() + win := NewWindowBuilder(). + WithBuffer(&buf). + Build() + + win.SetAnchorLine(2) + + if win.Anchor.Line != 2 { + t.Errorf("expected anchor line 2, got %d", win.Anchor.Line) + } + }) + + t.Run("sets anchor col", func(t *testing.T) { + buf := NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + win := NewWindowBuilder(). + WithBuffer(&buf). + Build() + + win.SetAnchorCol(5) + + if win.Anchor.Col != 5 { + t.Errorf("expected anchor col 5, got %d", win.Anchor.Col) + } + }) +} + +func TestWindow_SetDimensions(t *testing.T) { + t.Run("updates width and height", func(t *testing.T) { + buf := NewBufferBuilder().Build() + win := NewWindowBuilder(). + WithBuffer(&buf). + Build() + + win.SetDimensions(100, 50) + + if win.Width != 100 { + t.Errorf("expected width 100, got %d", win.Width) + } + if win.Height != 50 { + t.Errorf("expected height 50, got %d", win.Height) + } + }) +} + +func TestWindowBuilder(t *testing.T) { + t.Run("builds with defaults", func(t *testing.T) { + buf := NewBufferBuilder().Build() + win := NewWindowBuilder(). + WithBuffer(&buf). + Build() + + // Should have default options + if win.Options.Number != true { + t.Error("expected default Number to be true") + } + if win.Options.RelativeNumber != true { + t.Error("expected default RelativeNumber to be true") + } + if win.Options.ScrollOff != 8 { + t.Errorf("expected default ScrollOff 8, got %d", win.Options.ScrollOff) + } + if win.Options.GutterSize != 5 { + t.Errorf("expected default GutterSize 5, got %d", win.Options.GutterSize) + } + }) + + t.Run("builds with custom cursor position", func(t *testing.T) { + buf := NewBufferBuilder(). + WithLines([]string{"a", "b", "c"}). + Build() + win := NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(2, 0). + Build() + + if win.Cursor.Line != 2 { + t.Errorf("expected cursor line 2, got %d", win.Cursor.Line) + } + if win.Cursor.Col != 0 { + t.Errorf("expected cursor col 0, got %d", win.Cursor.Col) + } + }) + + t.Run("builds with custom dimensions", func(t *testing.T) { + buf := NewBufferBuilder().Build() + win := NewWindowBuilder(). + WithBuffer(&buf). + WithDimensions(120, 40). + Build() + + if win.Width != 120 { + t.Errorf("expected width 120, got %d", win.Width) + } + if win.Height != 40 { + t.Errorf("expected height 40, got %d", win.Height) + } + }) + + t.Run("assigns unique IDs", func(t *testing.T) { + buf := NewBufferBuilder().Build() + win1 := NewWindowBuilder().WithBuffer(&buf).Build() + win2 := NewWindowBuilder().WithBuffer(&buf).Build() + + if win1.Id == win2.Id { + t.Errorf("expected unique IDs, both were %d", win1.Id) + } + }) +}