Files
reader/server/test/identity.test.ts
T
cnb.dQC5J6ohAGA 664141f0f1 feat: 支持 txt 与漫画等多种格式,格式层改为注册表
原先只认 .epub 和 .pdf,本地书库里占很大比重的 txt 长篇与漫画
(cbz、图片目录)会整片「消失」—— 用户能看到文件在 NAS 上,却在
书架里找不到,这是最让人困惑的一类问题。

格式列表硬编码在三处(扫描器白名单、parseBookFile 分支、content-type
映射表),加一种格式要同时改三处,漏掉任何一处就得到一个「扫到了但读不
出来」的安静 bug。这次把格式收进注册表,并补齐四种格式。

新增格式
- cbz / zip:按包内页序自然排序翻页,扫描时不解压到磁盘
- 漫画目录:一个目录一本书,子目录为卷,支持卷/页两级寻址
- txt:UTF-8 严格解码失败才回退 GB18030(UTF-16 BOM 也识别),
  按中文网文章节标题分章,正文可按章节或字节偏移读取
- 单张图片按单页读物入库
- 明确不支持 cbr/rar:需要非自由解压实现,与单容器约束冲突

格式层
- registry.ts 定义 handler 契约(parse/manifest/asset)与扩展名路由,
  扫描器与 HTTP 层只与它对话;扩展名白名单、能力声明、遍历过滤都自动派生
- zip-reader.ts 自研只读 ZIP,只用 node:zlib。JsZip 会把整包解压进内存,
  而漫画动辄几百 MB,首次扫描真实漫画库会 OOM
- natural-sort.ts 自研自然排序:page10 必须排在 page2 之后,
  否则整本漫画的阅读顺序是乱的;不用 localeCompare 是因为 ICU 行为
  在不同平台上不一致
- epub 章节返回前重写章节内的相对资源引用,否则 WebView 里图片/字体/
  样式全部 404;绝对 URL、data: URI 与文内锚点保持原样,脚注不会坏

修复
- 目录型读物认领后,其内部文件从索引中排除,否则同一部漫画会以
  「系列 + 每卷 + 每页」的形式重复出现
- 目录认领顺序改为从外往里:反过来的话一部漫画会变成 N 本独立卷
- 主键新增原地编辑规则:txt/漫画没有内嵌标识,追加一章会算出新 id
  并把读者进度留在旧记录上。现在同路径原地编辑算更新,移动/重命名
  仍走内容哈希规则,所以进度都保得住
- 损坏的 .cbz 仍然入库并记录失败原因,而不是静默从书架消失
- PDF 不再用正则猜页数(会匹配到无关的 /Count 并报出错误页数,
  让客户端分页错乱),改为诚实上报 null
- 清理已迁出的 parseBookFile / pdfInfo / SUPPORTED_EXTENSIONS

测试
- 新增 65 个用例(共 92 个):真实 zip 与真实文件,不用 mock;
  fixtures 由测试自己生成,不需要签入二进制
- 覆盖自然排序、编码探测、ZIP 边界(ZIP64/加密/超大条目)、
  目录认领、原地编辑、只读约束、各格式的读取端点
2026-09-16 14:31:15 +08:00

114 lines
4.5 KiB
TypeScript

/**
* Book identity regression tests.
*
* The identity rule is the single most load-bearing decision in the product
* (design §6): get it wrong and readers lose their progress, which is the most
* common complaint about self-hosted libraries. Every branch is pinned here.
*/
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { computeBookId, resolveBookId } from '../src/indexer/identity.ts';
const hash = (seed: string): string => seed.padEnd(64, '0');
describe('computeBookId', () => {
test('the same identifier and content give the same id', () => {
assert.equal(computeBookId('urn:isbn:9787536692930', hash('a')), computeBookId('urn:isbn:9787536692930', hash('a')));
});
test('a different identifier separates otherwise identical content', () => {
assert.notEqual(computeBookId('urn:isbn:1', hash('a')), computeBookId('urn:isbn:2', hash('a')));
});
test('a re-encoded copy is a distinct book, not an alias', () => {
// Same book, different bytes: the design wants it kept as a separate copy
// rather than silently merged into the existing row.
assert.notEqual(computeBookId('urn:isbn:1', hash('a')), computeBookId('urn:isbn:1', hash('b')));
});
test('the identifier is case and whitespace insensitive', () => {
assert.equal(computeBookId(' URN:ISBN:ABC ', hash('a')), computeBookId('urn:isbn:abc', hash('a')));
});
test('identity never depends on a path', () => {
// Two calls with identical inputs cannot diverge, which is what makes a
// rename/move a no-op for progress.
const fromOld = computeBookId(null, hash('a'));
const fromNew = computeBookId(null, hash('a'));
assert.equal(fromOld, fromNew);
});
});
describe('resolveBookId', () => {
test('an in-place edit keeps the existing id', () => {
// Appending a chapter to a txt file must not mint a second book; doing so
// would drop the reader's progress on the first one.
const previousId = computeBookId(null, hash('v1'));
const next = resolveBookId({
identifier: null,
contentHash: hash('v2-different'),
previousId,
previousIdentifier: null,
});
assert.equal(next, previousId);
});
test('an edit of an epub without a dc:identifier also keeps its id', () => {
const previousId = computeBookId(null, hash('v1'));
assert.equal(
resolveBookId({ identifier: null, contentHash: hash('v2'), previousId, previousIdentifier: null }),
previousId,
);
});
test('a genuinely different book at the same path gets a new id', () => {
// The old file had an identifier and the replacement does not (or vice
// versa): this is a different book, not an edit, so it must not inherit.
const previousId = computeBookId('urn:isbn:old', hash('v1'));
const next = resolveBookId({
identifier: 'urn:isbn:brand-new',
contentHash: hash('v2'),
previousId,
previousIdentifier: 'urn:isbn:old',
});
assert.notEqual(next, previousId);
assert.equal(next, computeBookId('urn:isbn:brand-new', hash('v2')));
});
test('a replaced epub that keeps the same identifier is treated as an edit', () => {
const identifier = 'urn:isbn:9787536692930';
const previousId = computeBookId(identifier, hash('v1'));
const next = resolveBookId({
identifier,
contentHash: hash('re-encoded'),
previousId,
previousIdentifier: identifier,
});
assert.equal(next, previousId, 'the identifier is the anchor, so the id survives re-encoding');
});
test('replacing a file that had an identifier with one that has none creates a new book', () => {
const previousId = computeBookId('urn:isbn:old', hash('v1'));
const next = resolveBookId({
identifier: null,
contentHash: hash('v2'),
previousId,
previousIdentifier: 'urn:isbn:old',
});
assert.notEqual(next, previousId);
});
test('with no previous file the content rule applies', () => {
const expected = computeBookId(null, hash('fresh'));
assert.equal(resolveBookId({ identifier: null, contentHash: hash('fresh') }), expected);
});
test('renaming still preserves identity through the content rule', () => {
// No previousId is available under a brand-new path, so identity falls back
// to the content hash — which is exactly why a rename keeps progress.
const original = computeBookId(null, hash('same-bytes'));
const afterRename = resolveBookId({ identifier: null, contentHash: hash('same-bytes') });
assert.equal(afterRename, original);
});
});