package editor import ( "fmt" "testing" "git.gophernest.net/azpect/TextEditor/internal/core" ) // NOTE: AI Generated tests // generateLines creates n lines of content func generateLines(n int) []string { lines := make([]string, n) for i := range n { lines[i] = fmt.Sprintf("line %d", i+1) } return lines } func TestScrollBasic(t *testing.T) { t.Run("small file does not scroll", func(t *testing.T) { // 10 lines, viewport 24 -> no scrolling needed lines := generateLines(10) tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 24) sendKeys(tm, "G") // go to bottom m := getFinalModel(t, tm) if m.ActiveWindow().ScrollY != 0 { t.Errorf("ScrollY() = %d, want 0", m.ActiveWindow().ScrollY) } }) t.Run("scrolls down when cursor moves past bottom margin", func(t *testing.T) { // 50 lines, viewport 20, scrollOff 8 // Viewport shows lines 0-18 (19 lines, -1 for status bar) // Safe zone: lines 8 to 10 (19-1-8=10) // Moving to line 11+ should trigger scroll lines := generateLines(50) tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 20) // Move down 15 times to get to line 15 for range 15 { sendKeys(tm, "j") } m := getFinalModel(t, tm) if m.ActiveWindow().Cursor.Line != 15 { t.Errorf("CursorY() = %d, want 15", m.ActiveWindow().Cursor.Line) } // With scrollOff=8, viewport=19, cursor at 15 means: // cursor should be at position 10 from top (19-1-8=10) // so scrollY = 15 - 10 = 5 if m.ActiveWindow().ScrollY < 1 { t.Errorf("ScrollY() = %d, want > 0 (should have scrolled)", m.ActiveWindow().ScrollY) } }) t.Run("scrolls up when cursor moves past top margin", func(t *testing.T) { // Start at line 20, move up lines := generateLines(50) tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 20}, 80, 20) // First, let the model adjust (it will scroll to show cursor) // Then move up 15 times for range 15 { sendKeys(tm, "k") } m := getFinalModel(t, tm) if m.ActiveWindow().Cursor.Line != 5 { t.Errorf("CursorY() = %d, want 5", m.ActiveWindow().Cursor.Line) } // Cursor at line 5 with scrollOff=8 means scrollY should be 0 // (can't scroll negative, and cursor is within safe zone from top) if m.ActiveWindow().ScrollY != 0 { t.Errorf("ScrollY() = %d, want 0", m.ActiveWindow().ScrollY) } }) t.Run("G jumps to bottom and scrolls", func(t *testing.T) { lines := generateLines(100) tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 20) sendKeys(tm, "G") m := getFinalModel(t, tm) if m.ActiveWindow().Cursor.Line != 99 { t.Errorf("CursorY() = %d, want 99", m.ActiveWindow().Cursor.Line) } // With 100 lines and viewport 18 (height - 2 for status + command bar), // max scrollY = 100 - 18 = 82 if m.ActiveWindow().ScrollY != 82 { t.Errorf("ScrollY() = %d, want 82", m.ActiveWindow().ScrollY) } }) t.Run("gg jumps to top and scrolls", func(t *testing.T) { lines := generateLines(100) tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 50}, 80, 20) sendKeys(tm, "g", "g") m := getFinalModel(t, tm) if m.ActiveWindow().Cursor.Line != 0 { t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) } if m.ActiveWindow().ScrollY != 0 { t.Errorf("ScrollY() = %d, want 0", m.ActiveWindow().ScrollY) } }) } func TestScrollEdgeCases(t *testing.T) { t.Run("scrollY never goes negative", func(t *testing.T) { lines := generateLines(50) tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 20) // Try to move up from top for range 5 { sendKeys(tm, "k") } m := getFinalModel(t, tm) if m.ActiveWindow().ScrollY < 0 { t.Errorf("ScrollY() = %d, want >= 0", m.ActiveWindow().ScrollY) } }) t.Run("scrollY clamped to max scroll", func(t *testing.T) { lines := generateLines(30) tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 20) sendKeys(tm, "G") m := getFinalModel(t, tm) // 30 lines, viewport 18 (height - 2) -> maxScroll = 30 - 18 = 12 maxScroll := 30 - 18 if m.ActiveWindow().ScrollY > maxScroll { t.Errorf("ScrollY() = %d, want <= %d", m.ActiveWindow().ScrollY, maxScroll) } }) t.Run("cursor stays visible after delete at bottom", func(t *testing.T) { lines := generateLines(30) tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 29}, 80, 20) // Delete some lines at bottom sendKeys(tm, "d", "d", "d", "d") m := getFinalModel(t, tm) // Cursor should still be visible viewportHeight := 19 if m.ActiveWindow().Cursor.Line < m.ActiveWindow().ScrollY || m.ActiveWindow().Cursor.Line >= m.ActiveWindow().ScrollY+viewportHeight { t.Errorf("Cursor at %d not visible in viewport [%d, %d)", m.ActiveWindow().Cursor.Line, m.ActiveWindow().ScrollY, m.ActiveWindow().ScrollY+viewportHeight) } }) } // Tests use terminal 80x30: viewportH=28, half-scroll=14, scrollOff=8, safe zone relY 8-19. func TestHalfPageScrollDown(t *testing.T) { t.Run("ctrl+d scrolls viewport down by half", func(t *testing.T) { // cursor at line 15 (relY=15, in safe zone), scrollY starts at 0 // After ctrl+d: newScrollY=14, newCursorY=14+15=29 lines := generateLines(100) tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 15}, 80, 30) sendKeys(tm, "ctrl+d") m := getFinalModel(t, tm) if m.ActiveWindow().ScrollY != 14 { t.Errorf("ScrollY() = %d, want 14", m.ActiveWindow().ScrollY) } if m.ActiveWindow().Cursor.Line != 29 { t.Errorf("CursorY() = %d, want 29", m.ActiveWindow().Cursor.Line) } }) t.Run("ctrl+d preserves cursor relative position in viewport", func(t *testing.T) { // relY=15 before and after lines := generateLines(100) tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 15}, 80, 30) sendKeys(tm, "ctrl+d") m := getFinalModel(t, tm) relY := m.ActiveWindow().Cursor.Line - m.ActiveWindow().ScrollY if relY != 15 { t.Errorf("relative position = %d, want 15", relY) } }) t.Run("ctrl+d clamps cursor to scrollOff when cursor is near top", func(t *testing.T) { // cursor at line 0 (relY=0 < scrollOff=8), clamp to scrollOff // After ctrl+d: newScrollY=14, newCursorY=14+8=22 lines := generateLines(100) tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 30) sendKeys(tm, "ctrl+d") m := getFinalModel(t, tm) if m.ActiveWindow().ScrollY != 14 { t.Errorf("ScrollY() = %d, want 14", m.ActiveWindow().ScrollY) } if m.ActiveWindow().Cursor.Line != 22 { t.Errorf("CursorY() = %d, want 22", m.ActiveWindow().Cursor.Line) } }) t.Run("ctrl+d at end of file does not scroll past max", func(t *testing.T) { // 40 lines, viewport 28: maxScroll=12 // AdjustScroll puts cursor 35 at scrollY=12 (clamped), relY=23 // After ctrl+d: newScrollY clamped to 12, relY=23>19 clamped to 19, newCursorY=31 lines := generateLines(40) tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 35}, 80, 30) sendKeys(tm, "ctrl+d") m := getFinalModel(t, tm) maxScroll := 40 - 28 if m.ActiveWindow().ScrollY > maxScroll { t.Errorf("ScrollY() = %d, want <= %d", m.ActiveWindow().ScrollY, maxScroll) } if m.ActiveWindow().Cursor.Line != 31 { t.Errorf("CursorY() = %d, want 31", m.ActiveWindow().Cursor.Line) } }) t.Run("ctrl+d on file smaller than viewport does not crash", func(t *testing.T) { // 20 lines < viewport 28: maxScroll=0, scrollY stays 0 // relY=0 < scrollOff, clamp to 8; newCursorY=0+8=8 lines := generateLines(20) tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 30) sendKeys(tm, "ctrl+d") m := getFinalModel(t, tm) if m.ActiveWindow().ScrollY != 0 { t.Errorf("ScrollY() = %d, want 0", m.ActiveWindow().ScrollY) } if m.ActiveWindow().Cursor.Line != 8 { t.Errorf("CursorY() = %d, want 8", m.ActiveWindow().Cursor.Line) } }) t.Run("ctrl+d clamps cursor x to new line length", func(t *testing.T) { // Lines 0-14: "hello world" (11 chars), lines 15-99: "hi" (2 chars) // cursor at line 5, cursorX=10 // ctrl+d: relY=5<8 clamp to 8; newScrollY=14; newCursorY=22 (a "hi" line) // ClampCursorX: 10 >= 2, so cursorX=2 lines := make([]string, 100) for i := range 15 { lines[i] = "hello world" } for i := 15; i < 100; i++ { lines[i] = "hi" } tm := newTestModelWithTermSize(t, lines, core.Position{Col: 10, Line: 5}, 80, 30) sendKeys(tm, "ctrl+d") m := getFinalModel(t, tm) if m.ActiveWindow().Cursor.Line != 22 { t.Errorf("CursorY() = %d, want 22", m.ActiveWindow().Cursor.Line) } if m.ActiveWindow().Cursor.Col > m.ActiveBuffer().Lines[m.ActiveWindow().Cursor.Line].Len() { t.Errorf("CursorX() = %d exceeds line length %d", m.ActiveWindow().Cursor.Col, m.ActiveBuffer().Lines[m.ActiveWindow().Cursor.Line].Len()) } }) t.Run("multiple ctrl+d presses scroll incrementally", func(t *testing.T) { // cursor at line 15, scrollY=0, relY=15 // ctrl+d #1: scrollY=14, cursorY=29, relY=15 // ctrl+d #2: scrollY=28, cursorY=43, relY=15 lines := generateLines(200) tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 15}, 80, 30) sendKeys(tm, "ctrl+d", "ctrl+d") m := getFinalModel(t, tm) if m.ActiveWindow().ScrollY != 28 { t.Errorf("ScrollY() = %d, want 28", m.ActiveWindow().ScrollY) } if m.ActiveWindow().Cursor.Line != 43 { t.Errorf("CursorY() = %d, want 43", m.ActiveWindow().Cursor.Line) } }) } func TestHalfPageScrollUp(t *testing.T) { t.Run("ctrl+u scrolls viewport up by half", func(t *testing.T) { // cursor at line 50: AdjustScroll -> scrollY=31, relY=19 // After ctrl+u: newScrollY=17, newCursorY=17+19=36 lines := generateLines(100) tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 50}, 80, 30) sendKeys(tm, "ctrl+u") m := getFinalModel(t, tm) if m.ActiveWindow().ScrollY != 17 { t.Errorf("ScrollY() = %d, want 17", m.ActiveWindow().ScrollY) } if m.ActiveWindow().Cursor.Line != 36 { t.Errorf("CursorY() = %d, want 36", m.ActiveWindow().Cursor.Line) } }) t.Run("ctrl+u preserves cursor relative position in viewport", func(t *testing.T) { // relY=19 before and after lines := generateLines(100) tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 50}, 80, 30) sendKeys(tm, "ctrl+u") m := getFinalModel(t, tm) relY := m.ActiveWindow().Cursor.Line - m.ActiveWindow().ScrollY if relY != 19 { t.Errorf("relative position = %d, want 19", relY) } }) t.Run("ctrl+u at top of file does not make scrollY negative", func(t *testing.T) { // cursor at line 10, scrollY=0, relY=10 (in safe zone) // ctrl+u: newScrollY=max(0,-14)=0, relY=10 preserved, cursorY=10 lines := generateLines(100) tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 10}, 80, 30) sendKeys(tm, "ctrl+u") m := getFinalModel(t, tm) if m.ActiveWindow().ScrollY < 0 { t.Errorf("ScrollY() = %d, want >= 0", m.ActiveWindow().ScrollY) } if m.ActiveWindow().ScrollY != 0 { t.Errorf("ScrollY() = %d, want 0", m.ActiveWindow().ScrollY) } if m.ActiveWindow().Cursor.Line != 10 { t.Errorf("CursorY() = %d, want 10", m.ActiveWindow().Cursor.Line) } }) t.Run("ctrl+u clamps cursor to scrollOff when cursor is near top of viewport", func(t *testing.T) { // cursor at line 5, scrollY=0, relY=5 < scrollOff=8 // ctrl+u: newScrollY=0; relY clamp to 8; newCursorY=8 lines := generateLines(100) tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 5}, 80, 30) sendKeys(tm, "ctrl+u") m := getFinalModel(t, tm) if m.ActiveWindow().ScrollY != 0 { t.Errorf("ScrollY() = %d, want 0", m.ActiveWindow().ScrollY) } if m.ActiveWindow().Cursor.Line != 8 { t.Errorf("CursorY() = %d, want 8", m.ActiveWindow().Cursor.Line) } }) t.Run("multiple ctrl+u presses scroll incrementally", func(t *testing.T) { // cursor at line 80: AdjustScroll -> scrollY=61, relY=19 // ctrl+u #1: newScrollY=47, cursorY=66 // ctrl+u #2: newScrollY=33, cursorY=52 lines := generateLines(200) tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 80}, 80, 30) sendKeys(tm, "ctrl+u", "ctrl+u") m := getFinalModel(t, tm) if m.ActiveWindow().ScrollY != 33 { t.Errorf("ScrollY() = %d, want 33", m.ActiveWindow().ScrollY) } if m.ActiveWindow().Cursor.Line != 52 { t.Errorf("CursorY() = %d, want 52", m.ActiveWindow().Cursor.Line) } }) } func TestHalfPageScrollRoundTrip(t *testing.T) { t.Run("ctrl+d then ctrl+u returns cursor to original position", func(t *testing.T) { // cursor at line 15, scrollY=0, relY=15 // ctrl+d: scrollY=14, cursorY=29, relY=15 // ctrl+u: newScrollY=max(0,14-14)=0, cursorY=0+15=15 lines := generateLines(200) tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 15}, 80, 30) sendKeys(tm, "ctrl+d", "ctrl+u") m := getFinalModel(t, tm) if m.ActiveWindow().ScrollY != 0 { t.Errorf("ScrollY() = %d, want 0", m.ActiveWindow().ScrollY) } if m.ActiveWindow().Cursor.Line != 15 { t.Errorf("CursorY() = %d, want 15", m.ActiveWindow().Cursor.Line) } }) t.Run("ctrl+u then ctrl+d returns cursor to original position", func(t *testing.T) { // cursor at line 50: AdjustScroll -> scrollY=31, relY=19 // ctrl+u: scrollY=17, cursorY=36, relY=19 // ctrl+d: scrollY=31, cursorY=50, relY=19 lines := generateLines(200) tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 50}, 80, 30) sendKeys(tm, "ctrl+u", "ctrl+d") m := getFinalModel(t, tm) if m.ActiveWindow().ScrollY != 31 { t.Errorf("ScrollY() = %d, want 31", m.ActiveWindow().ScrollY) } if m.ActiveWindow().Cursor.Line != 50 { t.Errorf("CursorY() = %d, want 50", m.ActiveWindow().Cursor.Line) } }) t.Run("alternating ctrl+d and ctrl+u maintains scroll stability", func(t *testing.T) { lines := generateLines(200) tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 15}, 80, 30) sendKeys(tm, "ctrl+d", "ctrl+u", "ctrl+d", "ctrl+u") m := getFinalModel(t, tm) if m.ActiveWindow().ScrollY != 0 { t.Errorf("ScrollY() = %d, want 0 after 2 round trips", m.ActiveWindow().ScrollY) } if m.ActiveWindow().Cursor.Line != 15 { t.Errorf("CursorY() = %d, want 15 after 2 round trips", m.ActiveWindow().Cursor.Line) } }) } func TestScrollWithCount(t *testing.T) { t.Run("5j scrolls appropriately", func(t *testing.T) { lines := generateLines(50) tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 5}, 80, 20) sendKeys(tm, "1", "0", "j") // move down 10 lines m := getFinalModel(t, tm) if m.ActiveWindow().Cursor.Line != 15 { t.Errorf("CursorY() = %d, want 15", m.ActiveWindow().Cursor.Line) } // Should have scrolled since we moved past the safe zone if m.ActiveWindow().ScrollY == 0 { t.Errorf("ScrollY() = %d, want > 0", m.ActiveWindow().ScrollY) } }) t.Run("5k scrolls appropriately", func(t *testing.T) { lines := generateLines(50) tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 25}, 80, 20) sendKeys(tm, "1", "5", "k") // move up 15 lines m := getFinalModel(t, tm) if m.ActiveWindow().Cursor.Line != 10 { t.Errorf("CursorY() = %d, want 10", m.ActiveWindow().Cursor.Line) } }) }