Files
reader/web/test/window.test.ts
T
CodeBuddy d0a475919d feat(web): H5 客户端,精排 epub 渲染 + 多格式阅读
用户的三个问题在这里落地:

1. 「H5 是一个完整的客户端」——同一份构建产物既由服务端托管(`/` 直接
   就是完整阅读器),也可被 Android 外壳打进 assets 复用,两种宿主只差
   一个 serverUrl。

2. 「纯 H5 时 epub 等资源是否需要 iframe 或 shadow DOM」——用 shadow DOM,
   但不是为了隔离出版方样式(那做不到也不该做:书必须能给自己排版)。
   它挡的是反方向:客户端自身的 CSS 不会漏进书里。iframe 只在 PDF 那一处
   用,因为 PDF 需要自己的浏览上下文,理由写在 render/container.ts 里。

3. 「Android 端 epub 用 H5 渲染,其他类型用原生」——按 kind 分流,
   capabilities() 明确声明:reflowable 走 WebView,paged/document 在
   Android 交回原生。理由写进 reader-view.ts:WebView 每页一个合成层,
   300MB 的画册在一台中端机上就是 OOM。

服务端同步配合的两处:
- 资源流式下发 + HTTP Range
- epub 按包内路径寻址、items/manifest 分窗

前端无框架:渲染热路径是命令式 DOM 操作,引框架只会把"最小覆盖 CSS"
的策略搞复杂;最终产物 35KB(gzip 10.7KB)。
2026-09-16 14:45:04 +08:00

105 lines
4.3 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.
/**
* Window arithmetic tests.
*
* This is the highest-risk logic in the client: every translation between a
* whole-book index and an index into the loaded array goes through it, and a
* mistake shows up as "resume opens the wrong chapter" — which the user only
* notices after their position has been corrupted.
*/
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { contains, groupForSpine, toLocalIndex, toSpineIndex, toWindow } from '../src/render/window.ts';
import type { BookContent, ContentItem } from '../src/net/api.ts';
function items(from: number, count: number): ContentItem[] {
return Array.from({ length: count }, (_, i) => ({
id: `s${from + i}`,
seq: from + i,
title: `第 ${from + i + 1} 章`,
kind: 'chapter' as const,
mediaType: 'application/xhtml+xml',
href: `xhtml:ch${from + i}.xhtml`,
}));
}
/** A windowed manifest: chapters 40..79 of a 200-chapter book, window size 40. */
function windowedContent(): BookContent {
return {
kind: 'reflowable',
total: 200,
groups: [
{ id: 'spine:0', seq: 0, title: '1–40', count: 40, offset: 0 },
{ id: 'spine:40', seq: 1, title: '41–80', count: 40, offset: 40 },
{ id: 'spine:80', seq: 2, title: '81–120', count: 40, offset: 80 },
],
items: items(40, 40),
group: 1,
};
}
describe('window arithmetic', () => {
test('a windowed response reports the group it actually carries', () => {
const window = toWindow(windowedContent());
assert.equal(window.group?.offset, 40);
assert.equal(window.total, 200);
assert.equal(window.items.length, 40);
});
test('local index 0 maps to the window group offset, not to 0', () => {
// The bug this pins: computing the spine index from the array position makes
// chapter 41 look like chapter 1, so a synced position lands 40 chapters
// early and the reader's next device resumes in the wrong place.
const window = toWindow(windowedContent());
assert.equal(toSpineIndex(window, 0), 40);
assert.equal(toSpineIndex(window, 7), 47);
assert.equal(toLocalIndex(window, 40), 0);
assert.equal(toLocalIndex(window, 47), 7);
});
test('round-tripping a spine index through local and back is stable', () => {
const window = toWindow(windowedContent());
for (const spine of [40, 55, 79]) {
assert.equal(toSpineIndex(window, toLocalIndex(window, spine)), spine);
}
});
test('an unwindowed response has no group and identity offsets', () => {
const content: BookContent = { kind: 'paged', total: 3, groups: [{ id: 'p', seq: 0, title: '页', count: 3, offset: 0 }], items: items(0, 3) };
const window = toWindow(content);
assert.equal(toSpineIndex(window, 2), 2);
assert.equal(toLocalIndex(window, 2), 2);
});
test('containment is answered from the group, not from the array length', () => {
const window = toWindow(windowedContent());
// Chapter 5 exists in the book but not in this window: a jump to it must
// trigger a fetch, and answering from `items.length` would wrongly claim it.
assert.equal(contains(window, 5), false);
assert.equal(contains(window, 40), true);
assert.equal(contains(window, 79), true);
// Chapter 81 belongs to a group that is not loaded.
assert.equal(contains(window, 81), false);
assert.equal(contains(window, 120), false);
});
test('a spine index resolves to the group that holds it', () => {
const window = toWindow(windowedContent());
assert.equal(groupForSpine(window, 0)?.seq, 0);
assert.equal(groupForSpine(window, 39)?.seq, 0);
assert.equal(groupForSpine(window, 40)?.seq, 1);
// The fixture declares three windows; spine 199 is beyond all of them, so
// the caller must treat it as unreachable rather than as the last group.
assert.equal(groupForSpine(window, 119)?.seq, 2);
assert.equal(groupForSpine(window, 199), null);
assert.equal(groupForSpine(window, 500), null);
});
test('a manifest without content falls back to the flat fields', () => {
// The server sends `content` plus flattened copies for older clients; a build
// that predates `content` must still open the book.
const window = toWindow(null, { kind: 'paged', total: 2, items: items(0, 2), groups: [] });
assert.equal(window.kind, 'paged');
assert.equal(window.items.length, 2);
});
});