import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; import { DocumentLoader } from '@/libs/document'; import type { BookDoc } from '@/libs/document'; import type { Renderer } from '@/types/view'; // Vite serves fixture files; fetch the EPUB at runtime in the browser. const EPUB_URL = new URL('../fixtures/data/sample-alice.epub', import.meta.url).href; let book: BookDoc; const loadEPUB = async () => { const resp = await fetch(EPUB_URL); const buffer = await resp.arrayBuffer(); const file = new File([buffer], 'sample-alice.epub', { type: 'application/epub+zip' }); const loader = new DocumentLoader(file); const { book } = await loader.open(); return book; }; /** * Wait for the paginator to emit 'stabilized'. * MUST be called BEFORE the action that triggers stabilization (e.g. goTo), * because #display dispatches 'stabilized' synchronously before returning. */ const waitForStabilized = (el: HTMLElement, timeout = 10000) => new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('stabilized timeout')), timeout); el.addEventListener( 'stabilized', () => { clearTimeout(timer); resolve(); }, { once: true }, ); }); /** Wait until `getContents().length >= n` or timeout. */ const waitForViews = async (el: Renderer, n: number, timeout = 10000) => { const start = Date.now(); while (Date.now() - start < timeout) { if (el.getContents().length >= n) return; await new Promise((r) => setTimeout(r, 100)); } }; /** Wait for fill to complete by polling until getContents count stabilizes. */ const waitForFillComplete = async (el: Renderer, timeout = 10000) => { const start = Date.now(); let lastCount = -1; let stableFor = 0; while (Date.now() - start < timeout) { const count = el.getContents().length; if (count === lastCount) { stableFor += 100; if (stableFor >= 500) return; } else { stableFor = 0; lastCount = count; } await new Promise((r) => setTimeout(r, 100)); } }; describe('Paginator stabilization (browser)', () => { let paginator: Renderer; // Suppress unhandled errors from paginator's #replaceBackground firing // after views are destroyed (queued iframe loads from rapid navigation). // This is a known paginator cleanup race, not a test failure. const suppressHandler = (e: ErrorEvent) => { if (e.message?.includes('getComputedStyle')) e.preventDefault(); }; beforeAll(async () => { window.addEventListener('error', suppressHandler); book = await loadEPUB(); await import('foliate-js/paginator.js'); }, 30000); afterAll(() => { window.removeEventListener('error', suppressHandler); }); const createPaginator = () => { const el = document.createElement('foliate-paginator') as Renderer; Object.assign(el.style, { width: '800px', height: '600px', position: 'absolute', left: '0', top: '0', }); document.body.appendChild(el); return el; }; /** Create paginator, open book, navigate to section, wait for stabilized. */ const setupAt = async (index: number) => { paginator = createPaginator(); paginator.open(book); const stabilized = waitForStabilized(paginator); await paginator.goTo({ index }); await stabilized; }; afterEach(async () => { if (paginator) { // Flush pending RAFs and iframe load callbacks before destroying. // Rapid navigation tests can leave queued callbacks that reference // views — destroying immediately would cause unhandled errors in // paginator.js (#replaceBackground accessing destroyed documents). await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); try { paginator.destroy(); } catch { /* iframe body may already be torn down */ } paginator.remove(); } }); describe('Stabilized event lifecycle', () => { it('should emit stabilized event after goTo completes', async () => { paginator = createPaginator(); paginator.open(book); const idx = book.sections!.findIndex((s) => s.linear !== 'no'); let stabilizedCount = 0; paginator.addEventListener('stabilized', () => { stabilizedCount++; }); const stabilized = waitForStabilized(paginator); await paginator.goTo({ index: idx }); await stabilized; expect(stabilizedCount).toBeGreaterThanOrEqual(1); }); it('should emit stabilized for each navigation to a new section', async () => { paginator = createPaginator(); paginator.open(book); const linearSections = book .sections!.map((s, i) => ({ s, i })) .filter(({ s }) => s.linear !== 'no'); expect(linearSections.length).toBeGreaterThan(1); let stabilizedCount = 0; paginator.addEventListener('stabilized', () => { stabilizedCount++; }); const s1 = waitForStabilized(paginator); await paginator.goTo({ index: linearSections[0]!.i }); await s1; const countAfterFirst = stabilizedCount; expect(countAfterFirst).toBeGreaterThanOrEqual(1); // Navigate to a far section so view is not reused const farIdx = linearSections[Math.min(5, linearSections.length - 1)]!.i; const s2 = waitForStabilized(paginator); await paginator.goTo({ index: farIdx }); await s2; expect(stabilizedCount).toBeGreaterThan(countAfterFirst); }); it('should have content visible (opacity=1) after stabilized', async () => { paginator = createPaginator(); paginator.open(book); const idx = book.sections!.findIndex((s) => s.linear !== 'no'); let containerOpacity = ''; paginator.addEventListener('stabilized', () => { // Access shadow DOM container to check opacity const container = paginator.shadowRoot?.getElementById('container'); containerOpacity = container?.style.opacity ?? ''; }); const stabilized = waitForStabilized(paginator); await paginator.goTo({ index: idx }); await stabilized; // After stabilized, opacity should be '1' (visible) expect(containerOpacity).toBe('1'); }); }); describe('Container opacity during stabilization', () => { it('should set opacity to 0 at start and 1 at end of display', async () => { paginator = createPaginator(); paginator.open(book); const idx = book.sections!.findIndex((s) => s.linear !== 'no'); const opacities: string[] = []; // Observe the shadow DOM container for style changes const container = paginator.shadowRoot?.getElementById('container'); if (container) { const observer = new MutationObserver(() => { opacities.push(container.style.opacity); }); observer.observe(container, { attributes: true, attributeFilter: ['style'] }); } const stabilized = waitForStabilized(paginator); await paginator.goTo({ index: idx }); await stabilized; // Should have seen opacity transitions: 0 → 1 expect(opacities).toContain('0'); expect(opacities).toContain('1'); // The last opacity should be '1' (content visible) const lastOpacity = opacities[opacities.length - 1]; expect(lastOpacity).toBe('1'); }); }); describe('Font loading integration', () => { it('should load view even when document has no custom fonts', async () => { const idx = book.sections!.findIndex((s) => s.linear !== 'no'); await setupAt(idx); // View should be loaded with a valid document const contents = paginator.getContents(); const primary = contents.find((c) => c.index === idx); expect(primary).toBeDefined(); expect(primary!.doc.fonts).toBeDefined(); }); }); describe('Render micro-stabilization', () => { it('should emit stabilized when render is called on a loaded paginator', async () => { const idx = book.sections!.findIndex((s) => s.linear !== 'no'); await setupAt(idx); // Wait for fill to fully complete so #stabilizing is false await waitForFillComplete(paginator); const stabilizedFromRender = waitForStabilized(paginator); paginator.render?.(); // render() dispatches stabilized in a RAF await stabilizedFromRender; // If we get here, stabilized was emitted expect(true).toBe(true); }); it('should preserve primaryIndex across re-renders', async () => { const idx = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 1000); await setupAt(idx); await waitForFillComplete(paginator); const indexBefore = paginator.primaryIndex; const stabilized = waitForStabilized(paginator); paginator.render?.(); await stabilized; expect(paginator.primaryIndex).toBe(indexBefore); }); }); describe('Stabilization suppresses scroll-to-anchor', () => { it('should not emit extra relocate events during initial fill', async () => { paginator = createPaginator(); paginator.open(book); const idx = book.sections!.findIndex((s) => s.linear !== 'no'); const relocates: Array<{ index: number; fraction: number; reason: string }> = []; paginator.addEventListener('relocate', ((e: CustomEvent) => { relocates.push(e.detail); }) as EventListener); const stabilized = waitForStabilized(paginator); await paginator.goTo({ index: idx }); await stabilized; // Wait for fill to complete await waitForFillComplete(paginator); // All relocate events should reference valid section indices for (const r of relocates) { expect(r.index).toBeGreaterThanOrEqual(0); expect(r.index).toBeLessThan(book.sections!.length); } // Primary index should still be the one we navigated to expect(paginator.primaryIndex).toBe(idx); }); }); describe('Fill visible area completes after stabilized', () => { it('should have more views after fill completes than at stabilized time', async () => { paginator = createPaginator(); paginator.open(book); const idx = book.sections!.findIndex((s) => s.linear !== 'no'); let viewsAtStabilized = 0; paginator.addEventListener( 'stabilized', () => { viewsAtStabilized = paginator.getContents().length; }, { once: true }, ); const stabilized = waitForStabilized(paginator); await paginator.goTo({ index: idx }); await stabilized; // Wait for fill to complete await waitForFillComplete(paginator); const viewsAfterFill = paginator.getContents().length; // Fill should have loaded additional sections beyond what was // available at stabilized time (or at least the same count if // the book has very few sections) expect(viewsAfterFill).toBeGreaterThanOrEqual(viewsAtStabilized); }); it('should block backward loading while stabilizing', async () => { const linearSections = book .sections!.map((s, i) => ({ s, i })) .filter(({ s }) => s.linear !== 'no'); // Navigate to a middle section so there are sections before const midIdx = linearSections[Math.min(3, linearSections.length - 1)]!.i; await setupAt(midIdx); // After stabilized, primary should still be the target expect(paginator.primaryIndex).toBe(midIdx); }); }); describe('Scrolled mode stabilization', () => { it('should stabilize correctly in scrolled mode', async () => { paginator = createPaginator(); paginator.open(book); paginator.setAttribute('flow', 'scrolled'); const idx = book.sections!.findIndex((s) => s.linear !== 'no'); const stabilized = waitForStabilized(paginator); await paginator.goTo({ index: idx }); await stabilized; expect(paginator.scrolled).toBe(true); expect(paginator.primaryIndex).toBe(idx); expect(paginator.getContents().length).toBeGreaterThanOrEqual(1); }); it('should pre-load previous section in scrolled mode', async () => { paginator = createPaginator(); paginator.open(book); paginator.setAttribute('flow', 'scrolled'); const linearSections = book .sections!.map((s, i) => ({ s, i })) .filter(({ s }) => s.linear !== 'no'); // Navigate to second linear section with anchor=0 (top) if (linearSections.length > 1) { const secondIdx = linearSections[1]!.i; const stabilized = waitForStabilized(paginator); await paginator.goTo({ index: secondIdx, anchor: 0 }); await stabilized; // In scrolled mode, previous section is pre-loaded in #display await waitForViews(paginator, 2); const contents = paginator.getContents(); const indices = contents.map((c) => c.index); // Should have loaded the previous section const firstIdx = linearSections[0]!.i; expect(indices).toContain(firstIdx); } }); }); describe('Attribute-triggered re-render', () => { it('should re-stabilize when flow attribute changes', async () => { const idx = book.sections!.findIndex((s) => s.linear !== 'no'); await setupAt(idx); await waitForFillComplete(paginator); // Switch to scrolled mode — should trigger render() and stabilization const stabilizedFromSwitch = waitForStabilized(paginator); paginator.setAttribute('flow', 'scrolled'); await stabilizedFromSwitch; expect(paginator.scrolled).toBe(true); }); it('should preserve primaryIndex when switching flow modes', async () => { const idx = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 1000); await setupAt(idx); await waitForFillComplete(paginator); const indexBefore = paginator.primaryIndex; // Switch to scrolled const s1 = waitForStabilized(paginator); paginator.setAttribute('flow', 'scrolled'); await s1; expect(paginator.primaryIndex).toBe(indexBefore); // Wait for fill to complete before switching back await waitForFillComplete(paginator); // Switch back to paginated const s2 = waitForStabilized(paginator); paginator.removeAttribute('flow'); await s2; expect(paginator.primaryIndex).toBe(indexBefore); }); }); describe('Multiple rapid navigations', () => { it('should settle on the last navigation target after rapid goTo calls', async () => { paginator = createPaginator(); paginator.open(book); const linearSections = book .sections!.map((s, i) => ({ s, i })) .filter(({ s }) => s.linear !== 'no'); expect(linearSections.length).toBeGreaterThan(2); // Fire multiple navigations rapidly const first = linearSections[0]!.i; const second = linearSections[1]!.i; const third = linearSections[Math.min(2, linearSections.length - 1)]!.i; // Don't await intermediate — fire them all paginator.goTo({ index: first }); paginator.goTo({ index: second }); await paginator.goTo({ index: third }); // Wait long enough for all background RAF callbacks to flush // (setStyles triggers RAF → #replaceBackground which reads computed styles) await waitForFillComplete(paginator); // The paginator should have settled on a valid section expect(paginator.primaryIndex).toBeGreaterThanOrEqual(0); expect(paginator.getContents().length).toBeGreaterThanOrEqual(1); }); }); });