// @vitest-environment jsdom import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { ReaderView } from '../src/ui/reader-view.ts'; import { createStagedDoc } from '../src/formats/windowed.ts'; import type { BookContent, ContentItem } from '../src/net/api.ts'; import type { BookDoc } from '../src/formats/types.ts'; /** * Navigation behaviour. * * jsdom has no layout engine, so scroll extents are all zero and the *measurement* * half of pagination cannot be exercised here — that is what the device is for. * What jsdom can test, and what this file covers, is the part that is pure logic: * which section a navigation command lands on, and how a position is reported. * Those are the parts that break silently and the parts a refactor is most likely * to get wrong. */ beforeAll(() => { // jsdom does not implement object URLs for Blob. The view uses them for covers // and comic pages, neither of which is under test here. if (!URL.createObjectURL) { Object.defineProperty(URL, 'createObjectURL', { value: () => 'blob:stub', writable: true }); } if (!URL.revokeObjectURL) { Object.defineProperty(URL, 'revokeObjectURL', { value: () => undefined, writable: true }); } globalThis.ResizeObserver ??= class { observe(): void {} unobserve(): void {} disconnect(): void {} }; }); function reflowableDoc(sectionCount: number): BookDoc { return { format: 'epub', layout: 'reflowable', render: 'reflowable', direction: 'ltr', sections: Array.from({ length: sectionCount }, (_value, index) => ({ id: `ch${index}.xhtml`, label: `第 ${index + 1} 章`, html: '
正文
', depth: 0, })), toc: Array.from({ length: sectionCount }, (_value, index) => ({ id: `ch${index}.xhtml`, label: `第 ${index + 1} 章`, depth: 0, })), styles: [], resources: new Map(), orderedByBook: true, }; } function fixedDoc(pageCount: number): BookDoc { return { format: 'cbz', layout: 'fixed', render: 'image', direction: 'ltr', sections: Array.from({ length: pageCount }, (_value, index) => ({ id: `p${index}.png`, label: `第 ${index + 1} 页`, image: { mediaType: 'image/png', bytes: new Uint8Array([1, 2, 3]) }, depth: 0, })), toc: [], styles: [], resources: new Map(), orderedByBook: true, }; } function make(container: HTMLDivElement, doc: BookDoc): ReaderView { return new ReaderView({ container, doc }); } /** One window of a staged book, as the manifest endpoint delivers it. */ function windowOf(from: number, total: number, count = 40): BookContent { const items: ContentItem[] = Array.from({ length: count }, (_value, index) => ({ id: `c${from + index}`, seq: from + index, title: `第 ${from + index + 1} 章`, kind: 'chapter', mediaType: 'application/xhtml+xml', href: `xhtml:ch${from + index}.xhtml`, })); const groups = Array.from({ length: Math.ceil(total / count) }, (_value, index) => ({ id: `spine:${index * count}`, seq: index, title: `${index * count + 1}..`, count: Math.min(count, total - index * count), offset: index * count, })); return { kind: 'reflowable', total, groups, items, group: Math.floor(from / count) }; } /** A `ReaderView` over a staged book whose first window starts at `from`. */ function stagedBook(count: number, total: number, from: number): BookDoc { const doc = createStagedDoc({ kind: 'reflowable', toc: Array.from({ length: total }, (_value, index) => ({ id: `xhtml:ch${index}.xhtml`, label: `第 ${index + 1} 章`, depth: 0, })), content: windowOf(from, total, count), loader: { async read(item) { return { html: `${item.title}
` }; } }, }); // The same hand-off the reader screen performs: a staged document answers to // `setWindow`, and the view finds it through this property. (doc as BookDoc & { staged?: unknown }).staged = doc; return doc; } describe('ReaderView navigation', () => { let container: HTMLDivElement; beforeEach(() => { document.documentElement.removeAttribute('data-theme'); container = document.createElement('div'); document.body.append(container); }); it('opens the first section by default', async () => { const view = make(container, reflowableDoc(3)); await view.open(0, 0); expect(view.currentSectionIndex()).toBe(0); expect(view.position().sectionId).toBe('ch0.xhtml'); }); it('reports a locator that round-trips back to the same section', async () => { const view = make(container, reflowableDoc(4)); await view.open(2, 0.5); const position = view.position(); expect(position.locator.startsWith('r1:')).toBe(true); expect(position.locator.endsWith('ch2.xhtml')).toBe(true); }); it('advances section by section when there is nowhere to scroll', async () => { // In jsdom every scroll extent is zero, so `next()` must fall through to the // section advance rather than reporting success forever on a dead scroll. const view = make(container, reflowableDoc(3)); await view.open(0, 0); await view.next(); expect(view.currentSectionIndex()).toBe(1); await view.next(); expect(view.currentSectionIndex()).toBe(2); }); it('stops at the last section instead of wrapping around', async () => { const view = make(container, reflowableDoc(2)); await view.open(1, 0); const moved = await view.next(); expect(moved).toBe(false); expect(view.currentSectionIndex()).toBe(1); }); it('stops at the first section going backwards', async () => { const view = make(container, reflowableDoc(2)); await view.open(0, 0); const moved = await view.previous(); expect(moved).toBe(false); expect(view.currentSectionIndex()).toBe(0); }); it('reports the chapter title from the TOC label', async () => { const view = make(container, reflowableDoc(3)); await view.open(1, 0); expect(view.position().chapterTitle).toBe('第 2 章'); }); it('exposes the TOC for the navigation panel', () => { const view = make(container, reflowableDoc(3)); expect(view.chapterLabels().map((entry) => entry.label)).toEqual(['第 1 章', '第 2 章', '第 3 章']); }); it('notifies a chapter change so the chrome can update', async () => { const seen: string[] = []; const doc = reflowableDoc(3); const view = new ReaderView({ container, doc, onChapterChange: (_index, section) => seen.push(section.label), }); await view.open(0, 0); await view.open(2, 0); expect(seen).toEqual(['第 1 章', '第 3 章']); }); it('lands on the chapter when a window is swapped in', async () => { // The bug this pins was reported as "点击章节没有反应": the screen asked the // document to swap windows, and the swap — when it happened at all — replaced // the section list without opening the new chapter. The reader was left // staring at the chapter they had just tried to leave. Swapping and landing // are one operation now. const view = make(container, stagedBook(40, 120, 0)); await view.open(0, 0); expect(view.currentSectionIndex()).toBe(0); expect(view.currentChapterPosition).toBe(0); const landed = await view.loadWindow(windowOf(80, 120), 90); expect(landed).toBe(true); expect(view.currentSectionIndex()).toBe(10); expect(view.currentChapterPosition).toBe(90); expect(view.position().chapterTitle).toBe('第 91 章'); }); it('refuses a window that does not hold the chapter', async () => { const view = make(container, stagedBook(40, 120, 0)); await view.open(7, 0); const landed = await view.loadWindow(windowOf(80, 120), 3); expect(landed).toBe(false); // The old chapter is still on screen: a failed jump that also moved the // reader would be worse than one that did nothing. expect(view.currentSectionIndex()).toBe(7); expect(view.currentChapterPosition).toBe(7); }); it('refuses a window for a book that is not staged', async () => { const view = make(container, reflowableDoc(5)); await view.open(0, 0); expect(await view.loadWindow(windowOf(0, 120), 0)).toBe(false); }); it('reports a whole-book chapter position that ignores the window boundary', () => { // `currentSectionIndex` is window-local and must not be used to decide where // the book ends: at the last chapter of a window it reads 39, which a naive // check against the window's length would call "the last chapter" of a // 120-chapter book. const view = make(container, stagedBook(40, 120, 80)); expect(view.sectionCount).toBe(40); expect(view.windowOffset).toBe(80); expect(view.currentChapterPosition).toBe(80); }); it('resolves a section id to its local index and whole-book position', async () => { const view = make(container, stagedBook(40, 120, 40)); await view.open(0, 0); expect(view.indexOfSection('xhtml:ch57.xhtml')).toBe(17); expect(view.indexOfSection('xhtml:ch0.xhtml')).toBe(-1); expect(view.windowIndexOfRef('xhtml:ch57.xhtml')).toBe(57); expect(view.windowIndexOfRef('xhtml:ch0.xhtml')).toBeNull(); expect(view.sectionIdAt(17)).toBe('xhtml:ch57.xhtml'); expect(view.sectionIdAt(99)).toBeNull(); }); it('counts the pages of a chapter from the scroll container', () => { // jsdom has no layout, so every extent is zero and the honest answer is "one // page" rather than a division by zero. What is asserted is that the two // page-turn modes do not read the *wrong element*: the host scrolls in scroll // mode and the column scrolls in paged mode, and measuring the other one // reports every chapter as a single page. const view = make(container, reflowableDoc(2)); const position = view.position(); expect(position.pageInChapter).toBe(1); expect(position.chapterPages).toBe(1); expect(position.chapterPosition).toBe(0); }); it('disposes without leaving the host in the container', () => { const view = make(container, reflowableDoc(2)); expect(container.querySelector('book-content')).not.toBeNull(); view.dispose(); expect(container.querySelector('book-content')).toBeNull(); }); }); describe('ReaderView fixed layout', () => { let container: HTMLDivElement; beforeEach(() => { container = document.createElement('div'); document.body.append(container); }); it('treats a page turn as a whole section', async () => { const view = make(container, fixedDoc(3)); await view.open(0, 0); await view.next(); expect(view.currentSectionIndex()).toBe(1); await view.previous(); expect(view.currentSectionIndex()).toBe(0); }); it('reports no sub-page offset, because a page is atomic', async () => { const view = make(container, fixedDoc(3)); await view.open(1, 0.7); expect(view.position().within).toBe(0); }); it('renders the page image into the shadow root', async () => { // The image lives inside the shadow root on purpose: it keeps a page's markup // from reaching the app chrome and vice versa. A test that queried the light // DOM would pass on a regression that leaked the book into the shell. const view = make(container, fixedDoc(2)); await view.open(0, 0); const host = container.querySelector('book-content') as HTMLElement; expect(host.shadowRoot?.querySelector('.fixed-page img')).not.toBeNull(); }); it('leaves no book markup in the light DOM', async () => { const view = make(container, fixedDoc(2)); await view.open(0, 0); expect(container.querySelector('.fixed-page')).toBeNull(); expect(container.textContent).toBe(''); }); it('restores a locator pointing at a page by id', async () => { const view = make(container, fixedDoc(5)); const restored = await view.openLocator('r1:0.0000:p3.png'); expect(restored).toBe(true); expect(view.currentSectionIndex()).toBe(3); }); it('refuses a locator for a page that is no longer in the book', async () => { const view = make(container, fixedDoc(5)); const restored = await view.openLocator('r1:0.0000:gone.png'); expect(restored).toBe(false); expect(view.currentSectionIndex()).toBe(0); }); }); describe('ReaderView settings', () => { let container: HTMLDivElement; beforeEach(() => { document.documentElement.removeAttribute('data-theme'); container = document.createElement('div'); document.body.append(container); }); it('leaves the document theme to the app theme controller', () => { // Colours come from ThemeController (); the legacy // settings.theme must not overwrite them, or 'green' would select 青绿. const view = make(container, reflowableDoc(1)); view.applySettings({ theme: 'dark' }); expect(document.documentElement.dataset['theme']).toBeUndefined(); }); it('toggles paged mode on the book host, not on the shell', () => { const view = make(container, reflowableDoc(1)); view.applySettings({ mode: 'paged' }); expect(container.querySelector('book-content')?.getAttribute('data-paginated')).toBe('true'); view.applySettings({ mode: 'scroll' }); expect(container.querySelector('book-content')?.getAttribute('data-paginated')).toBe('false'); }); it('passes line-height through as a variable so `inherit` means untouched', () => { const view = make(container, reflowableDoc(1)); view.applySettings({ lineHeight: 'inherit' }); expect(container.style.getPropertyValue('--reader-line-height')).toBe('inherit'); view.applySettings({ lineHeight: '1.8' }); expect(container.style.getPropertyValue('--reader-line-height')).toBe('1.8'); }); it('exposes the current settings for a settings panel to render from', () => { const view = make(container, reflowableDoc(1)); view.applySettings({ fontScale: 1.4, theme: 'sepia' }); expect(view.settingsSnapshot.fontScale).toBe(1.4); expect(view.settingsSnapshot.theme).toBe('sepia'); }); it('does not mutate the caller-provided settings object', () => { const view = make(container, reflowableDoc(1)); const patch = { fontScale: 1.4 }; view.applySettings(patch); expect(patch).toEqual({ fontScale: 1.4 }); }); }); describe('ReaderView appearance settings', () => { let container: HTMLDivElement; beforeEach(() => { container = document.createElement('div'); document.body.append(container); }); it('passes the font, margin and alignment through as inheriting variables', () => { const view = make(container, reflowableDoc(1)); view.applySettings({ fontFamily: 'serif', pageMargin: 2.5, textAlign: 'justify' }); expect(container.style.getPropertyValue('--reader-font-family')).toBe('serif'); expect(container.style.getPropertyValue('--reader-page-margin')).toBe('2.5rem'); expect(container.style.getPropertyValue('--reader-text-align')).toBe('justify'); }); it('defaults every typography control to inherit, so the book is untouched', () => { const view = make(container, reflowableDoc(1)); const settings = view.settingsSnapshot; expect(settings.fontFamily).toBe('inherit'); expect(settings.textAlign).toBe('inherit'); expect(settings.lineHeight).toBe('inherit'); }); it('exposes brightness as a number so a dimming overlay can be computed', () => { const view = make(container, reflowableDoc(1)); view.applySettings({ brightness: 0.6 }); expect(container.style.getPropertyValue('--reader-brightness')).toBe('0.6'); }); it('sets a page-turn animation on the host and clears it again', () => { vi.useFakeTimers(); try { const view = make(container, reflowableDoc(2)); view.applySettings({ pageAnimation: 'slide' }); view.animatePage('next'); // Scroll mode (the default) turns a *screenful*, so the axis is `y`; the // paged case is asserted in the paged-mode test below. expect(container.querySelector('book-content')?.getAttribute('data-animating')).toBe('slide-next-y'); vi.runAllTimers(); expect(container.querySelector('book-content')?.getAttribute('data-animating')).toBeNull(); } finally { vi.useRealTimers(); } }); it('turns a *page* on a paged turn but a *screenful* on a scroll-mode one', () => { // The axis is the mode's, and the attribute is where the stylesheet reads it // from: a horizontal keyframe in scroll mode animated a movement that had not // happened, which is the reported "翻页的样式不对". const view = make(container, reflowableDoc(2)); view.applySettings({ pageAnimation: 'slide', mode: 'paged' }); view.animatePage('previous'); expect(container.querySelector('book-content')?.getAttribute('data-animating')).toBe('slide-previous-x'); }); it('does not animate when the reader turned animation off', () => { const view = make(container, reflowableDoc(2)); view.applySettings({ pageAnimation: 'none' }); view.animatePage('next'); expect(container.querySelector('book-content')?.getAttribute('data-animating')).toBeNull(); }); it('speaks in the order the reader sees, with stable block indices', async () => { const view = make(container, reflowableDoc(1)); await view.open(0, 0); const chunks = view.refreshSpokenChunks(); expect(chunks.length).toBeGreaterThan(0); // Every sentence points at a connected text node, which is what the // highlight needs to paint a range. for (const chunk of chunks) expect(chunk.node?.isConnected).toBe(true); }); it('hands back the first sentence of the chapter when nothing can be measured', async () => { // jsdom has no layout, so every rect is zero; the anchor must degrade to the // start of the chapter rather than to null, or "read from here" would do // nothing at all on a host without a layout engine. const view = make(container, reflowableDoc(1)); await view.open(0, 0); const chunks = view.refreshSpokenChunks(); expect(view.speechAnchor()).toEqual(chunks[0]); }); it('reports no anchor before a chapter has been rendered', () => { const view = make(container, reflowableDoc(1)); expect(view.speechAnchor()).toBeNull(); }); it('clears the speech highlight without touching the book markup', async () => { const view = make(container, reflowableDoc(1)); await view.open(0, 0); const bookContent = container.querySelector('book-content') as HTMLElement & { shadow: ShadowRoot }; const before = bookContent.shadow.querySelector('.book-flow')?.innerHTML ?? ''; view.highlightSpokenChunk(view.refreshSpokenChunks()[0] ?? null); view.clearSpeechHighlight(); // The highlight is an overlay appended to the flow, so it *is* removed from // the markup when cleared; what must not survive is an edit to the book's own // elements, which is why the sentence is never wrapped in a span. expect(bookContent.shadow.querySelector('.reader-speech-highlight')).toBeNull(); expect(bookContent.shadow.querySelector('.book-flow')?.innerHTML).toBe(before); expect(bookContent.shadow.innerHTML).not.toContain(' { it('releases object URLs on dispose so a long session does not leak', async () => { const container = document.createElement('div'); document.body.append(container); const revoke = vi.fn(); const original = URL.revokeObjectURL; URL.revokeObjectURL = revoke; try { const view = make(container, fixedDoc(3)); await view.open(0, 0); await view.open(1, 0); view.dispose(); expect(revoke).toHaveBeenCalled(); } finally { URL.revokeObjectURL = original; } }); }); describe('plain-text chapters are typeset by the reader', () => { /** `format: 'txt'` is the declaration; the marker is the observation. */ function txtDoc(html: string, format: BookDoc['format'] = 'txt'): BookDoc { const doc = reflowableDoc(1); return { ...doc, format, sections: [{ id: 'ch0', label: '第一章', html, depth: 0 }] }; } /** * The chapter's own shadow root, which is where the reading surface lives. * * `container.querySelector` reaches nothing inside it — that is the whole point of * the shadow root — so the tests have to go through the host element. */ function shadowOf(root: HTMLElement): ShadowRoot { const host = root.querySelector('book-content') as (HTMLElement & { shadow: ShadowRoot }) | null; if (!host) throw new Error('the reading surface is not mounted'); return host.shadow; } /** * A chapter exactly as the server now sends it: the characters, and nothing else. * * `chapter-full:` markup. It answers
* with the text now, because paragraph boundaries are a *reading* decision — see
* `server/src/indexer/formats/text.ts`. Every assertion below is written against
* this shape, because this is the shape the reader actually receives.
*/
const serverChapter = ['第二章 落雨', ' 雨来了。', '他走在雨里。'].join('\n');
it('typesets the text the server sends, and drops the leading spaces it carried', async () => {
// The whole point of moving the typography to the client: the server hands over
// characters, and one of the characters a scraper wrote is the full-width space
// that was standing in for an indent. Left in, it is indent added to whatever the
// reader chose, and the setting looks broken on the files that need it most.
const container = document.createElement('div');
document.body.append(container);
const view = make(container, txtDoc(serverChapter));
await view.open(0, 0);
const body = shadowOf(container).querySelector('.txt-body');
expect(body).not.toBeNull();
const paragraphs = [...body!.querySelectorAll('p')].map((p) => p.textContent);
expect(paragraphs).toEqual(['雨来了。', '他走在雨里。']);
// The chapter's first line is its title, and the *client* promotes it now —
// otherwise it renders as `第二章 落雨雨来了。`, the title and the first sentence
// glued together, which is what a reader reports as "格式乱了".
expect(body!.querySelector('h3')?.textContent).toBe('第二章 落雨');
});
it('escapes a chapter whose own text contains markup', async () => {
// The server sends `text/plain` now and for exactly this reason: served as HTML,
// a chapter containing `` would be parsed as markup by anything that trusted
// the label, and the escaping that makes a TXT safe would be sidestepped. The
// assertion is on the *absence* of an element, not on the presence of the words.
const container = document.createElement('div');
document.body.append(container);
const view = make(container, txtDoc('\n\n粗'));
await view.open(0, 0);
const shadow = shadowOf(container);
expect(shadow.querySelector('.txt-body img')).toBeNull();
expect(shadow.querySelector('.txt-body b')).toBeNull();
const paragraphs = [...shadow.querySelectorAll('.txt-body > p')].map((p) => p.textContent);
expect(paragraphs).toEqual(['
', '粗']);
});
it('re-typesets a chapter an older server sent as markup', async () => {
// A window may have been cached against a server that still rendered `
`s (or // a proxy may re-wrap one). The blocks are read back as text and re-split, so an // old response cannot be rendered as if it were a new one — and the heading it // carried is kept rather than re-derived, because a heuristic run twice is two // copies of the heuristic. const container = document.createElement('div'); document.body.append(container); const legacy = [ '
雨来了。
', '他走在雨里。
', '正文
', 'epub')); await view.open(0, 0); const shadow = shadowOf(container); expect(shadow.querySelector('.txt-body')).toBeNull(); expect(shadow.querySelector('p')?.getAttribute('data-authored')).toBe('1'); }); }); /** * A link *inside* a chapter that points at another chapter. * * An EPUB ships its own table of contents as a spine item, so a reader who opens it * and taps 第三章 is asking for a chapter change. The href they tap has already been * rewritten by the server to point at the asset endpoint — left unhandled, the tap * downloads another chapter's document and the reader sees a broken page instead of * the chapter they asked for. */ describe('ReaderView chapter links', () => { let container: HTMLDivElement; beforeEach(() => { container = document.createElement('div'); document.body.append(container); }); afterEach(() => { container.remove(); }); /** A chapter document whose body contains one link the server rewrote. */ function docWithLink(href: string, id = 'nav.xhtml'): BookDoc { const doc = reflowableDoc(1); return { ...doc, sections: [{ id, label: '目录', html: ``, depth: 0 }], }; } function clickLink(view: ReaderView): boolean { const anchor = view.elementHost.shadow.querySelector('a'); if (!anchor) throw new Error('no anchor rendered'); return anchor.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, composed: true })); } it('routes a chapter link to the screen instead of letting the browser fetch it', async () => { // The marker is in the URL, so the sanitiser keeps it — that is the point of // the pair (marker + same origin). `document.baseURI` is jsdom's about:blank, // so the URL is built against it rather than against a made-up origin. const origin = document.baseURI; const href = new URL( `/api/v1/books/b1/assets?__reader-book-resource__=1&ref=OEBPS/ch3.xhtml`, origin, ).href; const seen: string[] = []; const view = new ReaderView({ container, doc: docWithLink(href), onChapterLink: (ref) => { seen.push(ref); return true; }, }); await view.open(0, 0); // The click is *not* dispatched by default: a consumed chapter link must not // also be a navigation. expect(clickLink(view)).toBe(false); expect(seen).toEqual(['xhtml:OEBPS/ch3.xhtml']); view.dispose(); }); it('refuses a link that would leave the reader', async () => { // An outbound link in a book must not be able to replace the app with a web // page: in a WebView shell there is no way back to the book at all. The // sanitiser drops the attribute outright, which is stronger than refusing the // tap — there is no href left for the browser to follow, and no listener that // has to be correct for the rule to hold. const view = new ReaderView({ container, doc: docWithLink('https://example.com/'), }); await view.open(0, 0); const anchor = view.elementHost.shadow.querySelector('a'); expect(anchor?.hasAttribute('href')).toBe(false); expect(clickLink(view)).toBe(true); view.dispose(); }); it('lets the browser perform an in-document footnote link', async () => { const view = new ReaderView({ container, doc: docWithLink('#note3') }); await view.open(0, 0); expect(clickLink(view)).toBe(true); view.dispose(); }); });