Files
readest/apps/readest-app/src/__tests__/utils/book-nav-cache.test.ts
T

293 lines
11 KiB
TypeScript

import { describe, it, expect, beforeAll, vi } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { DocumentLoader } from '@/libs/document';
import type { BookDoc, TOCItem, SectionItem } from '@/libs/document';
import {
computeBookNav,
hydrateBookNav,
updateToc,
findTocItemBS,
BOOK_NAV_VERSION,
type BookNav,
type SectionFragment,
} from '@/services/nav';
// Polyfill CSS.escape for jsdom
if (typeof globalThis['CSS'] === 'undefined') {
(globalThis as Record<string, unknown>)['CSS'] = {
escape: (s: string) => s.replace(/([^\w-])/g, '\\$1'),
};
}
// Register a stub paginator custom element so View.open() doesn't fail in jsdom
if (!customElements.get('foliate-paginator')) {
customElements.define(
'foliate-paginator',
class extends HTMLElement {
override setAttribute() {}
override addEventListener() {}
open() {}
},
);
}
// Mock the paginator module import so View doesn't try to load the real one
vi.mock('foliate-js/paginator.js', () => ({}));
const openFixture = async (name: string): Promise<BookDoc> => {
const epubPath = resolve(__dirname, `../fixtures/data/${name}`);
const buffer = readFileSync(epubPath);
const file = new File([buffer], name, { type: 'application/epub+zip' });
const loader = new DocumentLoader(file);
const result = await loader.open();
return result.book;
};
const collectFragments = (fragments: SectionFragment[] | undefined): SectionFragment[] => {
if (!fragments?.length) return [];
const out: SectionFragment[] = [];
for (const f of fragments) {
out.push(f);
if (f.fragments?.length) out.push(...collectFragments(f.fragments));
}
return out;
};
describe('computeBookNav (fragment-link TOC)', () => {
let book: BookDoc;
let nav: BookNav;
beforeAll(async () => {
book = await openFixture('repro-3688.epub');
nav = await computeBookNav(book);
}, 30000);
it('produces a nav with the current algorithm version', () => {
expect(nav.version).toBe(BOOK_NAV_VERSION);
});
it('preserves the TOC with at least one entry', () => {
expect(nav.toc.length).toBeGreaterThan(0);
});
it('produces a sections map keyed by section id', () => {
expect(nav.sections).toBeTypeOf('object');
const keys = Object.keys(nav.sections);
const sectionIds = new Set((book.sections ?? []).map((s) => s.id));
for (const key of keys) {
expect(sectionIds.has(key)).toBe(true);
}
});
it('attaches fragments with DOM-derived CFIs and non-negative sizes', () => {
const allFragments: SectionFragment[] = [];
for (const section of Object.values(nav.sections)) {
allFragments.push(...collectFragments(section.fragments));
}
// repro-3688 is known to have fragment-link TOC items
expect(allFragments.length).toBeGreaterThan(0);
for (const f of allFragments) {
// CFI is derived at compute time by resolving the fragment anchor in the
// section's DOM and joining it with the section's CFI. Must be a valid
// epubcfi string on every cached fragment.
expect(f.cfi, `fragment ${f.href} should have a DOM-derived CFI`).toMatch(/^epubcfi\(/);
expect(f.size, `fragment ${f.href} should have non-negative size`).toBeGreaterThanOrEqual(0);
expect(f.id).toBeTruthy();
expect(f.href).toBeTruthy();
}
});
it('derives per-fragment CFIs from the section DOM, not the TOC item CFI', () => {
// If we used the TOC item's own cfi we would see duplicates (multiple TOC
// items in the same section often share an inherited cfi or have none).
// DOM-derived CFIs should be distinct per anchor within a section.
for (const section of Object.values(nav.sections)) {
const flat = collectFragments(section.fragments);
if (flat.length < 2) continue;
const cfis = flat.map((f) => f.cfi);
const distinct = new Set(cfis);
expect(distinct.size, `section ${section.id} fragments should have distinct CFIs`).toBe(
cfis.length,
);
}
});
it('does not mutate the original bookDoc.toc during compute', async () => {
const freshBook = await openFixture('repro-3688.epub');
const tocBefore = JSON.stringify(freshBook.toc);
await computeBookNav(freshBook);
const tocAfter = JSON.stringify(freshBook.toc);
expect(tocAfter).toBe(tocBefore);
});
});
describe('hydrateBookNav', () => {
it('round-trips through JSON without loss', async () => {
const book = await openFixture('repro-3688.epub');
const nav = await computeBookNav(book);
const serialized = JSON.stringify(nav);
const parsed = JSON.parse(serialized) as BookNav;
expect(parsed.version).toBe(nav.version);
expect(JSON.stringify(parsed.toc)).toBe(JSON.stringify(nav.toc));
expect(JSON.stringify(parsed.sections)).toBe(JSON.stringify(nav.sections));
}, 30000);
it('attaches toc and section.fragments onto bookDoc', async () => {
const bookA = await openFixture('repro-3688.epub');
const nav = await computeBookNav(bookA);
const bookB = await openFixture('repro-3688.epub');
hydrateBookNav(bookB, JSON.parse(JSON.stringify(nav)) as BookNav);
expect(JSON.stringify(bookB.toc)).toBe(JSON.stringify(nav.toc));
for (const section of bookB.sections ?? []) {
const expected = nav.sections[section.id]?.fragments;
expect(JSON.stringify(section.fragments ?? undefined), `section ${section.id}`).toBe(
JSON.stringify(expected),
);
}
}, 30000);
it('does not call loadText or createDocument on any section (no I/O)', async () => {
const bookA = await openFixture('repro-3688.epub');
const nav = await computeBookNav(bookA);
const bookB = await openFixture('repro-3688.epub');
const loadTextSpies: Array<ReturnType<typeof vi.spyOn>> = [];
const createDocSpies: Array<ReturnType<typeof vi.spyOn>> = [];
for (const s of bookB.sections ?? []) {
const withLoadText = s as SectionItem & {
loadText: () => Promise<string | null>;
};
if (typeof withLoadText.loadText === 'function') {
loadTextSpies.push(vi.spyOn(withLoadText, 'loadText'));
}
createDocSpies.push(vi.spyOn(s, 'createDocument'));
}
hydrateBookNav(bookB, JSON.parse(JSON.stringify(nav)) as BookNav);
for (const spy of loadTextSpies) expect(spy).not.toHaveBeenCalled();
for (const spy of createDocSpies) expect(spy).not.toHaveBeenCalled();
}, 30000);
});
describe('updateToc compatibility after hydrateBookNav', () => {
// The historical regression from #3688: fragment-suffixed TOC entries must
// resolve to the correct CFI after updateToc runs. Since computeBookNav
// replaces the former #updateSubItems work, hydration must produce TOC
// mappings identical to the pre-refactor behavior.
let book: BookDoc;
beforeAll(async () => {
book = await openFixture('repro-3688.epub');
const nav = await computeBookNav(book);
hydrateBookNav(book, nav);
await updateToc(book, false, 'none');
}, 30000);
it('assigns valid CFIs to all TOC items with href', () => {
const collectItems = (items: TOCItem[]): TOCItem[] =>
items.flatMap((item) => [item, ...(item.subitems ? collectItems(item.subitems) : [])]);
const toc = book.toc ?? [];
const allItems = collectItems(toc);
const itemsWithHref = allItems.filter((item) => item.href);
expect(itemsWithHref.length).toBeGreaterThan(1);
for (const item of itemsWithHref) {
expect(
item.cfi,
`TOC item "${item.label}" (href: ${item.href}) should have a valid CFI`,
).toBeTruthy();
}
});
it('maps fragment-level CFIs from different sections to different TOC items', () => {
const toc = book.toc ?? [];
const sections = book.sections ?? [];
const linearSections = sections.filter((s) => s.linear !== 'no' && s.cfi);
expect(linearSections.length).toBeGreaterThan(2);
// In production, findTocItemBS is queried with booknote CFIs (precise
// positions inside a section). After the DOM-CFI refactor, TOC items
// carry fragment-level CFIs, so we query using the fragment CFIs that
// hydrateBookNav attached to each section.
const firstSection = linearSections[0]!;
const lastSection = linearSections[linearSections.length - 1]!;
const firstFragmentCfi = firstSection.fragments?.[0]?.cfi ?? firstSection.cfi;
const lastFragmentCfi = lastSection.fragments?.[0]?.cfi ?? lastSection.cfi;
const firstTocItem = findTocItemBS(toc, firstFragmentCfi);
const lastTocItem = findTocItemBS(toc, lastFragmentCfi);
expect(firstTocItem).not.toBeNull();
expect(lastTocItem).not.toBeNull();
expect(firstTocItem!.label).toBe('Chapter One');
expect(lastTocItem!.label).toBe('Chapter Three');
});
});
describe('computeBookNav on a book without fragment TOC', () => {
let book: BookDoc;
let nav: BookNav;
beforeAll(async () => {
book = await openFixture('sample-alice.epub');
nav = await computeBookNav(book);
}, 30000);
it('returns a well-formed nav even when no sections have fragments', () => {
expect(nav.version).toBe(BOOK_NAV_VERSION);
expect(Array.isArray(nav.toc)).toBe(true);
expect(typeof nav.sections).toBe('object');
// Either no section entries at all, or all entries have empty fragments arrays.
const allFragments: SectionFragment[] = [];
for (const sec of Object.values(nav.sections)) {
allFragments.push(...collectFragments(sec.fragments));
}
// Fine if this is 0 — just asserting the compute path is stable.
expect(allFragments.length).toBeGreaterThanOrEqual(0);
});
it('round-trips unchanged through JSON', () => {
const serialized = JSON.stringify(nav);
const parsed = JSON.parse(serialized) as BookNav;
expect(JSON.stringify(parsed)).toBe(serialized);
});
});
describe('hierarchical fragments under a single section', () => {
it('nests fragments that mirror TOC hierarchy within the same section', async () => {
const book = await openFixture('repro-3688.epub');
const nav = await computeBookNav(book);
// Scan for at least one section whose computed fragments are themselves
// nested — i.e. mirror a multi-level TOC under one HTML section.
let foundNested = false;
for (const section of Object.values(nav.sections)) {
for (const f of section.fragments ?? []) {
if (f.fragments?.length) {
foundNested = true;
break;
}
}
if (foundNested) break;
}
// repro-3688 has fragment-link TOC entries; if the TOC nests them we'll
// see hierarchical fragments. If the fixture doesn't nest, this test is
// lenient — but the shape contract (optional recursive fragments) must
// at least be type-correct and not crash.
if (foundNested) {
expect(foundNested).toBe(true);
}
}, 30000);
});