Files
reader/web/test/windowed.test.ts
T

497 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment node
import { describe, expect, it } from 'vitest';
import { adoptWindow, createStagedDoc, isMarkupMediaType, windowIndexOf } from '../src/formats/windowed.ts';
import { HttpRangeSource, RemoteZip, type RangeSource } from '../src/formats/remote-zip.ts';
import type { BookContent, ContentItem } from '../src/net/api.ts';
/**
* Staged, one-window-at-a-time reading.
*
* The property being protected is the product's own hard requirement: opening a
* book must not download the book. These tests pin the two things that decide
* whether it holds — that a section's body is fetched only when it is displayed,
* and that a position survives crossing a window boundary.
*/
function item(index: number): ContentItem {
return {
id: `c${index}`,
seq: index,
title: `第 ${index + 1} 章`,
kind: 'chapter',
mediaType: 'application/xhtml+xml',
href: `xhtml:ch${index}.xhtml`,
};
}
/** A plain-text chapter, as the server's manifest declares one. */
function textItem(index: number): ContentItem {
return {
id: `c${index}`,
seq: index,
title: `第 ${index + 1} 章`,
kind: 'chapter',
// The declaration the reader typesets from. `format: 'html'` and a `text/plain`
// media type are the pair the server actually sends for a TXT.
mediaType: 'text/plain; charset=utf-8',
href: `chapter:${index}`,
format: 'html',
};
}
function windowOf(from: number, count: number, total: number): BookContent {
return {
kind: 'reflowable',
total,
groups: [
{ id: `spine:${from}`, seq: from / count, title: `${from}..`, count, offset: from },
],
items: Array.from({ length: count }, (_, i) => item(from + i)),
group: from / count,
};
}
describe('staged documents', () => {
it('carries the manifest\'s plain-text declaration onto the section', async () => {
// The declaration has to reach the *section*, because that is what the reader
// asks when it decides whether to typeset. It cannot be re-derived from the body:
// the server sends a TXT chapter as bare characters with no wrapper, so a body
// that is nothing but plain text carries nothing to recognise it by — and a
// reader that answers "not plain text" draws the novel as an unstyled slab.
const doc = createStagedDoc({
kind: 'reflowable',
toc: [],
content: {
kind: 'reflowable',
total: 2,
groups: [{ id: 'g', seq: 0, title: '', count: 2, offset: 0 }],
items: [textItem(0), item(1)],
},
loader: { async read() { return { html: '第一章\n\n正文。' }; } },
});
expect(doc.sections[0]?.plainText).toBe(true);
// An EPUB chapter in the same window is not swept up by it: the decision is per
// section, and an EPUB's own markup must survive untouched.
expect(doc.sections[1]?.plainText).toBeUndefined();
});
it('keeps the declaration across a window swap', async () => {
// A jump to chapter 900 replaces every section. Losing the flag there would turn
// the rest of the book into an EPUB — paragraphs gone, stylesheet gone — and only
// after the reader had navigated, which is the hardest kind of defect to report.
const doc = createStagedDoc({
kind: 'reflowable',
toc: [],
content: {
kind: 'reflowable',
total: 4,
groups: [{ id: 'g', seq: 0, title: '', count: 2, offset: 0 }],
items: [textItem(0), textItem(1)],
group: 0,
},
loader: { async read() { return { html: '第一章\n\n正文。' }; } },
});
expect(doc.sections[0]?.plainText).toBe(true);
doc.setWindow({
kind: 'reflowable',
total: 4,
groups: [{ id: 'g', seq: 1, title: '', count: 2, offset: 2 }],
items: [textItem(2), textItem(3)],
group: 1,
}, 2);
expect(doc.sections[0]?.plainText).toBe(true);
});
it('separates "typeset this" from "parse this", which is what a TXT needs', async () => {
// The report: a TXT chapter's paragraphs were all rendered as one. The two
// questions were one field — `plainText` — and the renderer used it to decide
// both whether to typeset *and* whether to parse the body as markup. A TXT
// chapter answers "typeset it" and "it is not markup", so reading the second
// answer off the first sent the paragraphs the client had just built down the
// text path, where their tags were stripped and they were joined back into one
// slab.
//
// Answered from the manifest's `mediaType`, which is the server's own statement
// about its own bytes, and asserted *separately* from `plainText` because it is
// precisely their disagreement that a TXT is.
const doc = createStagedDoc({
kind: 'reflowable',
toc: [],
content: {
kind: 'reflowable',
total: 2,
groups: [{ id: 'g', seq: 0, title: '', count: 2, offset: 0 }],
items: [textItem(0), item(1)],
},
loader: { async read() { return { html: '第一章\n正文。' }; } },
});
expect(doc.sections[0]?.plainText).toBe(true);
expect(doc.sections[0]?.bodyIsMarkup).toBe(false);
// The EPUB chapter in the same window is the mirror image: markup to parse, and
// not the reader's own text to typeset.
expect(doc.sections[1]?.plainText).toBeUndefined();
expect(doc.sections[1]?.bodyIsMarkup).toBe(true);
});
it('re-derives the markup answer for a window swap, and forgets nothing', async () => {
// The answer is a statement about a *chapter's bytes*, so a jump to a different
// window is a different set of chapters. Carried over from the old window it
// would be a statement about the wrong chapters — an EPUB window read as text,
// or a TXT window parsed as markup.
const doc = createStagedDoc({
kind: 'reflowable',
toc: [],
content: {
kind: 'reflowable',
total: 4,
groups: [{ id: 'g', seq: 0, title: '', count: 2, offset: 0 }],
items: [textItem(0), textItem(1)],
group: 0,
},
loader: { async read() { return { html: '第一章\n正文。' }; } },
});
expect(doc.sections[0]?.bodyIsMarkup).toBe(false);
doc.setWindow({
kind: 'reflowable',
total: 4,
groups: [{ id: 'g', seq: 1, title: '', count: 2, offset: 2 }],
items: [item(2), textItem(3)],
group: 1,
}, 2);
expect(doc.sections[0]?.bodyIsMarkup).toBe(true);
expect(doc.sections[1]?.bodyIsMarkup).toBe(false);
});
it('reads the markup answer from the media type, not from the body', () => {
// A book that *discusses* markup must not be parsed as markup. Deciding from
// the body means a novel with a `<` in it gets an HTML parser run over it, which
// is how a paragraph is silently eaten — so the answer comes from the one field
// that is the server's own statement about its own bytes.
expect(isMarkupMediaType('application/xhtml+xml')).toBe(true);
expect(isMarkupMediaType('text/html; charset=utf-8')).toBe(true);
expect(isMarkupMediaType('text/plain; charset=utf-8')).toBe(false);
expect(isMarkupMediaType(undefined)).toBe(false);
});
it('does not read a section until it is asked for', async () => {
const read: string[] = [];
const doc = createStagedDoc({
kind: 'reflowable',
toc: [{ id: 'xhtml:ch0.xhtml', label: '第一章', depth: 0 }],
content: windowOf(0, 40, 1200),
loader: {
async read(it) {
read.push(it.href);
return { html: `<p>${it.title}</p>` };
},
},
});
expect(doc.sections).toHaveLength(40);
expect(read).toEqual([]);
const section = await doc.loadSection(3);
expect(read).toEqual(['xhtml:ch3.xhtml']);
expect(section?.html).toBe('<p>第 4 章</p>');
});
it('reads a section once, however often it is displayed', async () => {
// Re-styling and resizing both re-render the current chapter, so a loader
// that re-fetched on every call would make a font-size change a network
// round trip.
let calls = 0;
const doc = createStagedDoc({
kind: 'reflowable',
toc: [],
content: windowOf(0, 4, 4),
loader: {
async read() {
calls += 1;
return { html: '<p>x</p>' };
},
},
});
await doc.loadSection(0);
await doc.loadSection(0);
await doc.loadSection(0);
expect(calls).toBe(1);
});
it('keeps the table of contents whole while the window is not', async () => {
// The failure this guards against is visible in the UI: a 1200-chapter book
// whose contents panel reads "第 1 章 – 第 40 章" because that is where the
// window ended.
const doc = createStagedDoc({
kind: 'reflowable',
toc: Array.from({ length: 1200 }, (_, i) => ({
id: `xhtml:ch${i}.xhtml`,
label: `第 ${i + 1} 章`,
depth: 0,
})),
content: windowOf(0, 40, 1200),
loader: { async read() { return { html: '' }; } },
});
expect(doc.sections).toHaveLength(40);
expect(doc.toc).toHaveLength(1200);
expect(doc.toc[1199]?.label).toBe('第 1200 章');
});
it('swaps a window through either method name it might answer to', async () => {
// The regression this pins is the reason the 目录 looked dead on a long book:
// the screen duck-typed a `loadWindow` method off the document, the document
// answered to `setWindow`, and the check therefore never succeeded — so a
// jump to a chapter outside the loaded window was a silent no-op. One helper
// now knows both names, and it is tested with a document that uses *only* the
// other one.
const plain = createStagedDoc({
kind: 'reflowable',
toc: [],
content: windowOf(0, 40, 1200),
loader: { async read() { return { html: '' }; } },
});
expect(adoptWindow(plain, windowOf(880, 40, 1200), 900)).toBe(20);
expect(plain.windowOffset()).toBe(880);
const legacy = {
sections: [],
toc: [],
loadWindow: (_content: unknown, spine: number) => (spine === 900 ? 20 : -1),
} as unknown as import('../src/formats/types.ts').BookDoc;
expect(adoptWindow(legacy, windowOf(880, 40, 1200), 900)).toBe(20);
expect(adoptWindow(legacy, windowOf(880, 40, 1200), 1)).toBe(-1);
});
it('answers -1 for a document that has no window at all', () => {
// A book read whole (the fallback for a server with no addressable structure)
// must say so rather than throw, because the caller's next move is to report
// "this chapter cannot be reached" rather than to crash the reader.
const whole = { sections: [], toc: [] } as unknown as import('../src/formats/types.ts').BookDoc;
expect(adoptWindow(whole, windowOf(0, 40, 1200), 0)).toBe(-1);
});
it('computes the window from the groups, not from a chapter constant', () => {
// A comic windows by *volume*, and a volume is not forty pages. Deriving the
// window from `spine / 40` asks for a group that does not exist and the jump
// lands nowhere; the manifest's own group sizes are the only authority.
const volumes: BookContent = {
kind: 'paged',
total: 900,
groups: [
{ id: 'v0', seq: 0, title: '卷一', count: 500, offset: 0 },
{ id: 'v1', seq: 1, title: '卷二', count: 400, offset: 500 },
],
items: [],
};
expect(windowIndexOf(volumes, 0)).toBe(0);
expect(windowIndexOf(volumes, 499)).toBe(0);
expect(windowIndexOf(volumes, 500)).toBe(1);
expect(windowIndexOf(volumes, 899)).toBe(1);
// Past the end clamps to the last group rather than to a nonexistent one.
expect(windowIndexOf(volumes, 9000)).toBe(1);
});
it('falls back to the chapter window when a manifest ships no groups', () => {
const bare: BookContent = { kind: 'text', total: 120, groups: [], items: [] };
expect(windowIndexOf(bare, 0)).toBe(0);
expect(windowIndexOf(bare, 39)).toBe(0);
expect(windowIndexOf(bare, 40)).toBe(1);
expect(windowIndexOf(bare, 119)).toBe(2);
});
it('finds a whole-book position inside a newly loaded window', async () => {
const doc = createStagedDoc({
kind: 'reflowable',
toc: [],
content: windowOf(0, 40, 1200),
loader: { async read() { return { html: '' }; } },
});
expect(doc.windowOffset()).toBe(0);
// Jump to chapter 900: window 22 (900/40), local index 20.
const local = doc.setWindow(windowOf(880, 40, 1200), 900);
expect(local).toBe(20);
expect(doc.windowOffset()).toBe(880);
expect(doc.sections[20]?.id).toBe('xhtml:ch900.xhtml');
});
it('loads a page image for a fixed-layout window', async () => {
const doc = createStagedDoc({
kind: 'paged',
toc: [],
content: {
kind: 'paged',
total: 3,
groups: [{ id: 'v0', seq: 0, title: '卷', count: 3, offset: 0 }],
items: [
{ id: '0', seq: 0, title: '001.jpg', kind: 'page', mediaType: 'image/jpeg', href: 'page:0' },
{ id: '1', seq: 1, title: '002.jpg', kind: 'page', mediaType: 'image/jpeg', href: 'page:1' },
{ id: '2', seq: 2, title: '003.jpg', kind: 'page', mediaType: 'image/jpeg', href: 'page:2' },
],
group: 0,
},
loader: {
async read(it) {
return { image: { mediaType: 'image/jpeg', bytes: new Uint8Array([it.seq]) } };
},
},
});
expect(doc.layout).toBe('fixed');
expect(doc.render).toBe('image');
const section = await doc.loadSection(1);
expect(section?.image?.bytes[0]).toBe(1);
});
});
/** A byte source backed by an in-memory buffer, with range accounting. */
function memorySource(bytes: Uint8Array): RangeSource & { reads: Array<[number, number]> } {
const reads: Array<[number, number]> = [];
return {
reads,
async size() {
return bytes.byteLength;
},
async read(start, end) {
reads.push([start, end]);
return bytes.subarray(start, end + 1);
},
async whole() {
return bytes;
},
};
}
describe('remote zip', () => {
it('reads only the central directory and the requested entry', async () => {
// The whole point: opening an archive must not read it. A test that only
// checked the returned bytes would pass even if the implementation slurped
// the file, so this asserts on the ranges that were actually requested.
const zip = buildStoredZip([
['META-INF/container.xml', '<container/>'],
['OEBPS/ch1.xhtml', '<html><body>一</body></html>'],
]);
const source = memorySource(zip);
const remote = await RemoteZip.open(source);
const beforeEntryReads = source.reads.length;
const chapter = await remote.bytes('OEBPS/ch1.xhtml');
expect(new TextDecoder().decode(chapter!)).toContain('一');
const entryReads = source.reads.slice(beforeEntryReads);
const requested = entryReads.reduce((sum, [start, end]) => sum + (end - start + 1), 0);
// Well under the archive, and more than zero: it read something, but not
// everything.
expect(requested).toBeLessThan(zip.byteLength);
expect(requested).toBeGreaterThan(0);
});
it('reports a stored entry as byte-addressable and a deflated one as not', async () => {
const zip = buildStoredZip([['page.jpg', 'raw-bytes']]);
const remote = await RemoteZip.open(memorySource(zip));
expect(remote.isSeekable('page.jpg')).toBe(true);
const url = await remote.resourceUrl('page.jpg', 'https://host/api/v1/books/x/content');
expect(url).toContain('#bytes=');
});
it('falls back to a whole-file read when ranges are ignored', async () => {
// A proxy that strips Range is not an exotic case, and a client that trusted
// the range response would then inflate the wrong bytes and show garbage.
const zip = buildStoredZip([['a.txt', 'hello']]);
let servedWhole = false;
const source: RangeSource = {
async size() {
servedWhole = true;
return zip.byteLength;
},
async read(start, end) {
return zip.subarray(start, end + 1);
},
async whole() {
return zip;
},
};
const remote = await RemoteZip.open(source);
expect(servedWhole).toBe(true);
expect(new TextDecoder().decode((await remote.bytes('a.txt'))!)).toBe('hello');
});
it('asks for the last byte once and remembers the size', async () => {
const zip = buildStoredZip([['a.txt', 'hello']]);
let probes = 0;
const source = new HttpRangeSource(async (range) => {
if (range) {
probes += 1;
return { bytes: zip.subarray(range.start, range.end + 1), total: zip.byteLength, ranged: true };
}
return { bytes: zip, total: zip.byteLength, ranged: false };
});
const size = await source.size();
await source.size();
expect(size).toBe(zip.byteLength);
expect(probes).toBe(1);
});
});
/** Minimal `stored` ZIP writer, so the tests do not depend on a zip library. */
function buildStoredZip(entries: Array<[string, string]>): Uint8Array {
const encoder = new TextEncoder();
const chunks: Uint8Array[] = [];
const central: Uint8Array[] = [];
let offset = 0;
for (const [name, body] of entries) {
const nameBytes = encoder.encode(name);
const data = encoder.encode(body);
const local = new Uint8Array(30 + nameBytes.length);
const localView = new DataView(local.buffer);
localView.setUint32(0, 0x04034b50, true);
localView.setUint16(4, 20, true);
localView.setUint16(8, 0, true); // stored
localView.setUint32(18, data.byteLength, true);
localView.setUint32(22, data.byteLength, true);
localView.setUint16(26, nameBytes.length, true);
local.set(nameBytes, 30);
chunks.push(local, data);
const record = new Uint8Array(46 + nameBytes.length);
const recordView = new DataView(record.buffer);
recordView.setUint32(0, 0x02014b50, true);
recordView.setUint16(4, 20, true);
recordView.setUint16(6, 20, true);
recordView.setUint16(10, 0, true);
recordView.setUint32(20, data.byteLength, true);
recordView.setUint32(24, data.byteLength, true);
recordView.setUint16(28, nameBytes.length, true);
recordView.setUint32(42, offset, true);
record.set(nameBytes, 46);
central.push(record);
offset += local.byteLength + data.byteLength;
}
const centralSize = central.reduce((sum, part) => sum + part.byteLength, 0);
const end = new Uint8Array(22);
const endView = new DataView(end.buffer);
endView.setUint32(0, 0x06054b50, true);
endView.setUint16(8, entries.length, true);
endView.setUint16(10, entries.length, true);
endView.setUint32(12, centralSize, true);
endView.setUint32(16, offset, true);
const all = [...chunks, ...central, end];
const total = all.reduce((sum, part) => sum + part.byteLength, 0);
const out = new Uint8Array(total);
let cursor = 0;
for (const part of all) {
out.set(part, cursor);
cursor += part.byteLength;
}
return out;
}