/** * Unit tests for the summary auto-scroll detection logic. * * These tests verify the behavior of the scroll detection function used in * AgentOutputModal to determine if auto-scroll should be enabled. * * The logic mirrors the handleSummaryScroll function in: * apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx * * Auto-scroll behavior: * - When user is at or near the bottom (< 50px from bottom), auto-scroll is enabled * - When user scrolls up to view older content, auto-scroll is disabled * - Scrolling back to bottom re-enables auto-scroll */ import { describe, it, expect } from 'vitest'; /** * Determines if the scroll position is at the bottom of the container. * This is the core logic from handleSummaryScroll in AgentOutputModal. * * @param scrollTop - Current scroll position from top * @param scrollHeight - Total scrollable height * @param clientHeight - Visible height of the container * @param threshold - Distance from bottom to consider "at bottom" (default: 50px) * @returns true if at bottom, false otherwise */ function isScrollAtBottom( scrollTop: number, scrollHeight: number, clientHeight: number, threshold = 50 ): boolean { const distanceFromBottom = scrollHeight - scrollTop - clientHeight; return distanceFromBottom < threshold; } describe('Summary Auto-Scroll Detection Logic', () => { describe('basic scroll position detection', () => { it('should return true when scrolled to exact bottom', () => { // Container: 500px tall, content: 1000px tall // ScrollTop: 500 (scrolled to bottom) const result = isScrollAtBottom(500, 1000, 500); expect(result).toBe(true); }); it('should return true when near bottom (within threshold)', () => { // 49px from bottom - within 50px threshold const result = isScrollAtBottom(451, 1000, 500); expect(result).toBe(true); }); it('should return true when exactly at threshold boundary (49px)', () => { // 49px from bottom const result = isScrollAtBottom(451, 1000, 500); expect(result).toBe(true); }); it('should return false when just outside threshold (51px)', () => { // 51px from bottom - outside 50px threshold const result = isScrollAtBottom(449, 1000, 500); expect(result).toBe(false); }); it('should return false when scrolled to top', () => { const result = isScrollAtBottom(0, 1000, 500); expect(result).toBe(false); }); it('should return false when scrolled to middle', () => { const result = isScrollAtBottom(250, 1000, 500); expect(result).toBe(false); }); }); describe('edge cases with small content', () => { it('should return true when content fits in viewport (no scroll needed)', () => { // Content is smaller than container - no scrolling possible const result = isScrollAtBottom(0, 300, 500); expect(result).toBe(true); }); it('should return true when content exactly fits viewport', () => { const result = isScrollAtBottom(0, 500, 500); expect(result).toBe(true); }); it('should return true when content slightly exceeds viewport (within threshold)', () => { // Content: 540px, Viewport: 500px, can scroll 40px // At scroll 0, we're 40px from bottom - within threshold const result = isScrollAtBottom(0, 540, 500); expect(result).toBe(true); }); }); describe('large content scenarios', () => { it('should correctly detect bottom in very long content', () => { // Simulate accumulated summary from many pipeline steps // Content: 10000px, Viewport: 500px const result = isScrollAtBottom(9500, 10000, 500); expect(result).toBe(true); }); it('should correctly detect non-bottom in very long content', () => { // User scrolled up to read earlier summaries const result = isScrollAtBottom(5000, 10000, 500); expect(result).toBe(false); }); it('should detect when user scrolls up from bottom', () => { // Started at bottom (scroll: 9500), then scrolled up 100px const result = isScrollAtBottom(9400, 10000, 500); expect(result).toBe(false); }); }); describe('custom threshold values', () => { it('should work with larger threshold (100px)', () => { // 75px from bottom - within 100px threshold const result = isScrollAtBottom(425, 1000, 500, 100); expect(result).toBe(true); }); it('should work with smaller threshold (10px)', () => { // 15px from bottom - outside 10px threshold const result = isScrollAtBottom(485, 1000, 500, 10); expect(result).toBe(false); }); it('should work with zero threshold (exact match only)', () => { // At exact bottom - distanceFromBottom = 0, which is NOT < 0 with strict comparison // This is an edge case: the implementation uses < not <= const result = isScrollAtBottom(500, 1000, 500, 0); expect(result).toBe(false); // 0 < 0 is false // 1px from bottom - also fails const result2 = isScrollAtBottom(499, 1000, 500, 0); expect(result2).toBe(false); // For exact match with 0 threshold, we need negative distanceFromBottom // which happens when scrollTop > scrollHeight - clientHeight (overscroll) const result3 = isScrollAtBottom(501, 1000, 500, 0); expect(result3).toBe(true); // -1 < 0 is true }); }); describe('pipeline summary scrolling scenarios', () => { it('should enable auto-scroll when new content arrives while at bottom', () => { // User is at bottom viewing step 2 summary // Step 3 summary is added, increasing scrollHeight from 1000 to 1500 // ScrollTop stays at 950 (was at bottom), but now user needs to scroll // Before new content: isScrollAtBottom(950, 1000, 500) = true // After new content: auto-scroll should kick in to scroll to new bottom // Simulating the auto-scroll effect setting scrollTop to new bottom const newScrollTop = 1500 - 500; // scrollHeight - clientHeight const result = isScrollAtBottom(newScrollTop, 1500, 500); expect(result).toBe(true); }); it('should not auto-scroll when user is reading earlier summaries', () => { // User scrolled up to read step 1 summary while step 3 is added // scrollHeight increases, but scrollTop stays same // User is now further from bottom // User was at scroll position 200 (reading early content) // New content increases scrollHeight from 1000 to 1500 // Distance from bottom goes from 300 to 800 const result = isScrollAtBottom(200, 1500, 500); expect(result).toBe(false); }); it('should re-enable auto-scroll when user scrolls back to bottom', () => { // User was reading step 1 (scrollTop: 200) // User scrolls back to bottom to see latest content const result = isScrollAtBottom(1450, 1500, 500); expect(result).toBe(true); }); }); describe('decimal scroll values', () => { it('should handle fractional scroll positions', () => { // Browsers can report fractional scroll values const result = isScrollAtBottom(499.5, 1000, 500); expect(result).toBe(true); }); it('should handle fractional scroll heights', () => { const result = isScrollAtBottom(450.7, 1000.3, 500); expect(result).toBe(true); }); }); describe('negative and invalid inputs', () => { it('should handle negative scrollTop (bounce scroll)', () => { // iOS can report negative scrollTop during bounce const result = isScrollAtBottom(-10, 1000, 500); expect(result).toBe(false); }); it('should handle zero scrollHeight', () => { // Empty content const result = isScrollAtBottom(0, 0, 500); expect(result).toBe(true); }); it('should handle zero clientHeight', () => { // Hidden container - distanceFromBottom = 1000 - 0 - 0 = 1000 // This is not < threshold, so returns false // This edge case represents a broken/invisible container const result = isScrollAtBottom(0, 1000, 0); expect(result).toBe(false); }); }); describe('real-world accumulated summary dimensions', () => { it('should handle typical 3-step pipeline summary dimensions', () => { // Approximate: 3 steps x ~800px each = ~2400px // Viewport: 400px (modal height) const result = isScrollAtBottom(2000, 2400, 400); expect(result).toBe(true); }); it('should handle large 10-step pipeline summary dimensions', () => { // Approximate: 10 steps x ~800px each = ~8000px // Viewport: 400px const result = isScrollAtBottom(7600, 8000, 400); expect(result).toBe(true); }); it('should detect scroll to top of large summary', () => { // User at top of 10-step summary const result = isScrollAtBottom(0, 8000, 400); expect(result).toBe(false); }); }); });