import { describe, it, expect } from 'vitest' /** * Unit tests for the virtual scrolling logic in SequenceViewer.vue. * Tests the pure computation functions extracted from the component's computed properties. * * Virtual scrolling parameters: * - ITEM_HEIGHT = 60px * - BUFFER_SIZE = 5 items * - VIRTUAL_SCROLL_THRESHOLD = 200 messages */ const ITEM_HEIGHT = 60 const BUFFER_SIZE = 5 const VIRTUAL_SCROLL_THRESHOLD = 200 // --- Pure logic functions (mirror of component computed properties) --- function useVirtualScroll(messageCount: number): boolean { return messageCount > VIRTUAL_SCROLL_THRESHOLD } function totalHeight(messageCount: number): number { return messageCount * ITEM_HEIGHT } function visibleStart(scrollTop: number, messageCount: number): number { if (!useVirtualScroll(messageCount)) return 0 const start = Math.floor(scrollTop / ITEM_HEIGHT) - BUFFER_SIZE return Math.max(0, start) } function visibleEnd( scrollTop: number, containerHeight: number, messageCount: number ): number { if (!useVirtualScroll(messageCount)) return messageCount const visibleCount = Math.ceil(containerHeight / ITEM_HEIGHT) const end = Math.floor(scrollTop / ITEM_HEIGHT) + visibleCount + BUFFER_SIZE return Math.min(messageCount, end) } function offsetY(scrollTop: number, messageCount: number): number { return visibleStart(scrollTop, messageCount) * ITEM_HEIGHT } describe('Virtual Scrolling Logic', () => { describe('useVirtualScroll threshold', () => { it('should NOT use virtual scroll for 200 messages or fewer', () => { expect(useVirtualScroll(0)).toBe(false) expect(useVirtualScroll(100)).toBe(false) expect(useVirtualScroll(200)).toBe(false) }) it('should use virtual scroll for more than 200 messages', () => { expect(useVirtualScroll(201)).toBe(true) expect(useVirtualScroll(500)).toBe(true) expect(useVirtualScroll(5000)).toBe(true) }) }) describe('totalHeight', () => { it('should compute total height as messageCount * ITEM_HEIGHT', () => { expect(totalHeight(0)).toBe(0) expect(totalHeight(1)).toBe(60) expect(totalHeight(100)).toBe(6000) expect(totalHeight(1000)).toBe(60000) expect(totalHeight(5000)).toBe(300000) }) }) describe('visibleStart', () => { it('should return 0 when virtual scroll is disabled (≤200 messages)', () => { expect(visibleStart(0, 100)).toBe(0) expect(visibleStart(1000, 200)).toBe(0) }) it('should return 0 when scrolled to top with virtual scroll enabled', () => { expect(visibleStart(0, 500)).toBe(0) }) it('should include buffer items before the visible area', () => { // scrollTop = 600 means first visible item is at index 10 // With buffer of 5, visibleStart = 10 - 5 = 5 expect(visibleStart(600, 500)).toBe(5) }) it('should clamp to 0 when buffer would go negative', () => { // scrollTop = 120 means first visible item is at index 2 // With buffer of 5, visibleStart = 2 - 5 = -3 → clamped to 0 expect(visibleStart(120, 500)).toBe(0) }) it('should compute correctly for large scroll positions', () => { // scrollTop = 30000 means first visible item is at index 500 // With buffer of 5, visibleStart = 500 - 5 = 495 expect(visibleStart(30000, 1000)).toBe(495) }) }) describe('visibleEnd', () => { it('should return messageCount when virtual scroll is disabled', () => { expect(visibleEnd(0, 500, 100)).toBe(100) expect(visibleEnd(0, 500, 200)).toBe(200) }) it('should compute visible end based on container height and buffer', () => { // containerHeight = 500, so visibleCount = ceil(500/60) = 9 // scrollTop = 0, so first visible = 0 // end = 0 + 9 + 5 = 14 expect(visibleEnd(0, 500, 500)).toBe(14) }) it('should clamp to messageCount when end exceeds total', () => { // With few messages relative to viewport // scrollTop = 0, containerHeight = 500, visibleCount = 9 // end = 0 + 9 + 5 = 14, but messageCount = 201 // For scrollTop near the end: // scrollTop = 12000 → first visible = 200, end = 200 + 9 + 5 = 214 → clamped to 201 expect(visibleEnd(12000, 500, 201)).toBe(201) }) it('should compute correctly for mid-scroll positions', () => { // scrollTop = 3000, containerHeight = 500 // first visible = floor(3000/60) = 50 // visibleCount = ceil(500/60) = 9 // end = 50 + 9 + 5 = 64 expect(visibleEnd(3000, 500, 1000)).toBe(64) }) }) describe('offsetY', () => { it('should return 0 when virtual scroll is disabled', () => { expect(offsetY(0, 100)).toBe(0) expect(offsetY(5000, 200)).toBe(0) }) it('should return 0 when scrolled to top', () => { expect(offsetY(0, 500)).toBe(0) }) it('should compute offset based on visibleStart', () => { // visibleStart(600, 500) = 5, so offsetY = 5 * 60 = 300 expect(offsetY(600, 500)).toBe(300) }) it('should compute correctly for large scroll positions', () => { // visibleStart(30000, 1000) = 495, so offsetY = 495 * 60 = 29700 expect(offsetY(30000, 1000)).toBe(29700) }) }) describe('rendered item count', () => { it('should render only a subset of items when virtual scrolling is active', () => { const messageCount = 1000 const containerH = 500 // For any scroll position, rendered items = visibleEnd - visibleStart const scrollPos = 3000 const start = visibleStart(scrollPos, messageCount) const end = visibleEnd(scrollPos, containerH, messageCount) const renderedCount = end - start // visibleCount = ceil(500/60) = 9, plus 2*buffer = 10 → max ~19 items rendered // Way less than the 1000 total messages expect(renderedCount).toBeLessThan(30) expect(renderedCount).toBeGreaterThan(0) }) it('should render all items when below threshold', () => { const messageCount = 150 const containerH = 500 const scrollPos = 0 const start = visibleStart(scrollPos, messageCount) const end = visibleEnd(scrollPos, containerH, messageCount) expect(start).toBe(0) expect(end).toBe(messageCount) }) it('should maintain render performance target (≤100ms render budget)', () => { // With 5000 messages, virtual scroll renders only ~19 items max // At 60px height, a 500px container shows ~8 items + 2*5 buffer = 18 items // 18 items at worst case should render well under 100ms const messageCount = 5000 const containerH = 500 for (let scrollPos = 0; scrollPos < totalHeight(messageCount); scrollPos += 6000) { const start = visibleStart(scrollPos, messageCount) const end = visibleEnd(scrollPos, containerH, messageCount) const renderedCount = end - start // Never render more than visibleCount + 2*buffer items const maxRendered = Math.ceil(containerH / ITEM_HEIGHT) + 2 * BUFFER_SIZE expect(renderedCount).toBeLessThanOrEqual(maxRendered) } }) }) })