forked from akai/readest
293 lines
11 KiB
TypeScript
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);
|
|
});
|