Files
automaker/apps/server/tests/unit/ui/summary-auto-scroll.test.ts
gsxdsm 9747faf1b9 Fix agent output summary for pipeline steps (#812)
* Changes from fix/agent-output-summary-for-pipeline-steps

* feat: Optimize pipeline summary extraction and fix regex vulnerability

* fix: Use fallback summary for pipeline steps when extraction fails

* fix: Strip follow-up session scaffold from pipeline step fallback summaries
2026-02-25 22:13:38 -08:00

239 lines
8.8 KiB
TypeScript

/**
* 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);
});
});
});