mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-11 21:41:02 +08:00
用户的三个问题在这里落地: 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)。
105 lines
4.3 KiB
TypeScript
105 lines
4.3 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|
||
});
|