mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-11 05:20:54 +08:00
117 lines
9.6 KiB
TypeScript
117 lines
9.6 KiB
TypeScript
import { applyCorrections } from '../src/ui/reading-overrides.ts';
|
|
import { loadTxt } from '../src/formats/txt.ts';
|
|
// @vitest-environment jsdom
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
import { Blob as NodeBlob } from 'node:buffer';
|
|
import { parseNavigation } from '../src/formats/navigation.ts';
|
|
import { anchorFor, anchorRange, selectedAnchor, encodeAnchor, decodeAnchor, findText, searchableText } from '../src/ui/text-anchor.ts';
|
|
import { PublicationCache } from '../src/store/publications.ts';
|
|
import { OfflineDownload } from '../src/core/offline-download.ts';
|
|
import { MemoryKv, MemoryBlobs } from './helpers/env.ts';
|
|
import type { Manifest } from '../src/api/types.ts';
|
|
import type { ReaderApi } from '../src/api/client.ts';
|
|
|
|
describe('navigation and text anchors', () => {
|
|
it('preserves three levels, fragments, entities, and TOC navigation selection', () => {
|
|
expect(parseNavigation('<html><nav><a href="cover">封面</a></nav><nav epub:type="toc"><ol><li><a href="a#top">一 & 二</a><ol><li><a href="a#middle">第二层</a><ol><li><a href="b#end">第三层</a></li></ol></li></ol></li><li><a href="c">尾章</a></li></ol></nav></html>', 'nav')).toEqual([
|
|
{ href: 'a#top', title: '一 & 二', depth: 0 }, { href: 'a#middle', title: '第二层', depth: 1 }, { href: 'b#end', title: '第三层', depth: 2 }, { href: 'c', title: '尾章', depth: 0 },
|
|
]);
|
|
expect(parseNavigation('<ncx><navMap><navPoint><navLabel><text>父</text></navLabel><content src="a#x"/><navPoint><navLabel><text>子</text></navLabel><content src="b#y"/></navPoint></navPoint></navMap></ncx>', 'ncx').map(n => n.depth)).toEqual([0,1]);
|
|
});
|
|
it('locates different occurrences across text nodes and rejects ambiguous moved text', () => {
|
|
const root = document.createElement('div'); root.innerHTML = '<p>开头词语<b>结尾</b></p><p>后面词语结束</p>';
|
|
const hits = findText('s', '章', searchableText(root.innerHTML), '词语');
|
|
expect(hits).toHaveLength(2);
|
|
const first = anchorRange(root, hits[0]!.anchor)!, second = anchorRange(root, hits[1]!.anchor)!;
|
|
expect(first.startContainer).not.toBe(second.startContainer);
|
|
expect(decodeAnchor(encodeAnchor(hits[0]!.anchor))).toEqual(hits[0]!.anchor);
|
|
expect(anchorRange(root, { ...hits[0]!.anchor, start: 90, prefix: '不存在', suffix: '不存在' })).toBeNull();
|
|
const spanning = anchorFor('s', searchableText(root.innerHTML), 2, 6);
|
|
expect(anchorRange(root, spanning)?.toString()).toBe('词语结尾');
|
|
});
|
|
it('captures a browser selection and relocates a unique quote after edits', () => {
|
|
const root = document.createElement('div'); root.innerHTML = '<p>前文<b>选中文字</b>后文</p>'; document.body.append(root);
|
|
const range = document.createRange(); range.selectNodeContents(root.querySelector('b')!);
|
|
const selection = document.getSelection()!; selection.removeAllRanges(); selection.addRange(range);
|
|
const anchor = selectedAnchor(root, 's', selection)!; expect(anchor.quote).toBe('选中文字');
|
|
root.prepend(document.createTextNode('新内容')); expect(anchorRange(root, anchor)?.toString()).toBe('选中文字'); root.remove();
|
|
});
|
|
it('reads mobile shadow selections with re-scoped ranges and backwards handles', () => {
|
|
const host = document.createElement('div'); document.body.append(host);
|
|
const shadow = host.attachShadow({mode:'open'}), root = document.createElement('p');
|
|
root.textContent = '前文选中文字后文'; shadow.append(root);
|
|
const text = root.firstChild!;
|
|
const outside = document.createRange(); outside.selectNode(host);
|
|
const selection = {rangeCount:1,isCollapsed:false,getRangeAt:()=>outside,
|
|
anchorNode:text,anchorOffset:6,focusNode:text,focusOffset:2} as unknown as Selection;
|
|
expect(selectedAnchor(root,'s',selection)?.quote).toBe('选中文字');
|
|
const composed = {...selection,anchorNode:document.body,focusNode:document.body,
|
|
getComposedRanges:()=>[{startContainer:text,startOffset:2,endContainer:text,endOffset:6,collapsed:false}]} as unknown as Selection;
|
|
expect(selectedAnchor(root,'s',composed)?.quote).toBe('选中文字');
|
|
expect(selectedAnchor(root,'s',{...selection,anchorNode:document.body} as Selection)).toBeNull();
|
|
host.remove();
|
|
});
|
|
});
|
|
describe('scoped quota and offline tasks', () => {
|
|
it('serializes quota checks, isolates accounts, and rolls back failed metadata writes', async () => {
|
|
const kv = new MemoryKv(), blobs = new MemoryBlobs(); const cache = new PublicationCache(kv, blobs, 'one');
|
|
await cache.setQuota(1024 * 1024);
|
|
const results = await Promise.allSettled([cache.putResource('a','x',new Uint8Array(700000)),cache.putResource('b','y',new Uint8Array(700000))]);
|
|
expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1);
|
|
expect(await new PublicationCache(kv,blobs,'two').resource('a','x')).toBeNull();
|
|
const write = vi.spyOn(kv,'set').mockRejectedValueOnce(new Error('disk full'));
|
|
await expect(cache.putResource('a','x',new Uint8Array([1]))).rejects.toThrow('disk full'); write.mockRestore();
|
|
expect((await cache.resource('a','x'))?.length).toBe(700000);
|
|
await cache.removeBook('a'); expect(await cache.entries()).toEqual([]);
|
|
});
|
|
it('downloads selected windows and resumes without downloading completed resources', async () => {
|
|
const cache = new PublicationCache(new MemoryKv(), new MemoryBlobs(),'scope');
|
|
const item = (seq: number) => ({ id: String(seq), seq, title: String(seq), href: 'chapter:' + seq, resourceRef: 'r:' + seq, kind: 'chapter', format: 'html', mediaType: 'text/plain' });
|
|
const content = { revision: 'one', kind: 'text', total: 3, groups: [{ id:'a',seq:0,offset:0,count:1,title:'a' },{ id:'b',seq:1,offset:1,count:2,title:'b' }], items:[item(0)] };
|
|
const manifest = { book:{ id:'b', format:'txt' }, content } as unknown as Manifest;
|
|
const asset = vi.fn(async (_id: string, ref: string) => new NodeBlob([ref]));
|
|
const api = { asset, items: vi.fn(async () => ({ ...content, group: 1, items:[item(1),item(2)] })) } as unknown as ReaderApi;
|
|
const task = new OfflineDownload(api,cache,manifest,()=>{}); await task.start(2,3);
|
|
expect(task.state.status).toBe('complete'); expect(asset).toHaveBeenCalledTimes(2);
|
|
expect((await cache.window('b',1))?.items).toHaveLength(2);
|
|
const resumed = new OfflineDownload(api,cache,manifest,()=>{}); await resumed.load(); await resumed.start(2,3);
|
|
expect(asset).toHaveBeenCalledTimes(2); expect(resumed.state.completed).toBe(2);
|
|
const changed = new OfflineDownload(api,cache,{ ...manifest,content:{ ...manifest.content!,revision:'two' } },()=>{});
|
|
await changed.load(); expect(changed.state.status).toBe('idle');
|
|
});
|
|
it('aborts an in-flight task without marking incomplete bytes as cached', async () => {
|
|
const cache = new PublicationCache(new MemoryKv(),new MemoryBlobs(),'pause');
|
|
const manifest = { book:{id:'b',format:'epub'},files:[] } as unknown as Manifest;
|
|
const bookBytes = vi.fn((_id: string,{signal}: {signal:AbortSignal}) => new Promise((_resolve,reject) => signal.addEventListener('abort',()=>reject(signal.reason))));
|
|
const task = new OfflineDownload({bookBytes} as unknown as ReaderApi,cache,manifest,()=>{});
|
|
const run = task.start(); await vi.waitFor(()=>expect(bookBytes).toHaveBeenCalled()); await task.pause(true); await run;
|
|
expect(task.state.status).toBe('cancelled'); expect(await cache.entries()).toEqual([]);
|
|
});
|
|
});
|
|
|
|
it('applies literal corrections safely, supports chained edits, and preserves original source', () => {
|
|
const root = document.createElement('div'); root.innerHTML = '<p>需要纠错的原文</p>';
|
|
const first = anchorFor('xhtml:a',root.textContent!,2,4);
|
|
const second = anchorFor('a','需要修改的原文',2,4);
|
|
expect(applyCorrections(root,'a',{version:1,headingPrefix:'',corrections:[{id:'1',anchor:first,replacement:'修改'},{id:'2',anchor:second,replacement:'<script>文本</script>'}]})).toBe(2);
|
|
expect(root.querySelector('script')).toBeNull(); expect(root.textContent).toContain('<script>文本</script>');
|
|
});
|
|
it('previews and applies a literal TXT heading prefix without executing regular expressions', () => {
|
|
const bytes = new TextEncoder().encode('前言\n@@ 一\n正文\n@@ 二\n正文');
|
|
const result = loadTxt({bytes,fileName:'a.txt',bookId:'a'},{headingPrefix:'@@'});
|
|
expect(result.doc.toc.map(t => t.label)).toEqual(['前言','@@ 一','@@ 二']);
|
|
expect(loadTxt({bytes,fileName:'a.txt',bookId:'a'},{headingPrefix:'(a+)+'}).chapterCount).toBe(0);
|
|
});
|
|
|
|
it('retries a failed chapter download and uses a complete TXT file for custom headings', async () => {
|
|
const cache=new PublicationCache(new MemoryKv(),new MemoryBlobs(),'retry');
|
|
const content={kind:'text',total:2,revision:'v',groups:[{id:'all',seq:0,offset:0,count:2,title:'全部'}],items:[0,1].map(seq=>({id:String(seq),seq,title:String(seq),href:'chapter:'+seq,resourceRef:'r:'+seq,kind:'chapter',mediaType:'text/plain'}))};
|
|
const manifest={book:{id:'b',format:'txt'},files:[],content} as unknown as Manifest;
|
|
const asset=vi.fn().mockResolvedValueOnce(new NodeBlob(['a'])).mockRejectedValueOnce(new Error('offline')).mockResolvedValue(new NodeBlob(['b']));
|
|
const api={asset,bookBytes:vi.fn(async()=>new Uint8Array([1,2,3]))} as unknown as ReaderApi;
|
|
const task=new OfflineDownload(api,cache,manifest,()=>{});await task.start(1,2);expect(task.state.status).toBe('error');expect(task.state.completed).toBe(1);
|
|
await task.start(1,2);expect(task.state.status).toBe('complete');expect(asset).toHaveBeenCalledTimes(3);
|
|
await cache.putOverrides('b',{version:1,headingPrefix:'@@',corrections:[]});await task.start(1,1);
|
|
expect(api.bookBytes).toHaveBeenCalledTimes(1);expect(task.state.message).toBe('整书已可离线阅读');
|
|
});
|