forked from akai/readest
428 lines
15 KiB
TypeScript
428 lines
15 KiB
TypeScript
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<void>((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);
|
|
});
|
|
});
|
|
});
|