Files
reader/web/test/chapter-reader.test.ts
T
hectorqinandClaude Opus 5.5 23290305c9 fix(web): reader opens offline, explains failures, and pages from the keyboard
- a book that cannot open keeps saying why over the stage, with 重试 and 返回;
  offline, a book downloaded for offline reading opens from its mirrored record.
- 返回 from a deep link or cold start goes to the shelf instead of leaving.
- keys are heard without focusing anything first; Space/Shift+Space page,
  S toggles 朗读, and focused buttons keep Space/Enter.
- 右→左 mirrors taps, swipes, drags and arrow keys.
- the tap that closes a note card no longer also turns the page.
- 目录 opens at the current chapter; the 朗读 button shows 朗读中 while reading.
- the selection toolbar is placed by its measured height and flips above the
  selection when it does not fit below.
- staged books fetch the next section while the current one is read, and a
  section is never fetched twice at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 03:48:46 +00:00

460 lines
28 KiB
TypeScript

// @vitest-environment jsdom
import { noticeText } from './helpers/notices.ts';
import { Blob as NodeBlob } from 'node:buffer';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { act } from 'react';
import { ReaderApi } from '../src/api/client.ts';
import { ApiError } from '../src/api/errors.ts';
import type { Book, BookContent, Manifest, Session } from '../src/api/types.ts';
import { OfflineStore } from '../src/store/offline.ts';
import { PublicationCache, publicationScope } from '../src/store/publications.ts';
import { DEFAULT_APP_SETTINGS } from '../src/store/settings.ts';
import { ReaderScreen } from '../src/ui/reader-screen.tsx';
import { App } from '../src/app.ts';
import type { SyncEngine } from '../src/core/sync.ts';
import { FakeTransport, MemoryBlobs, MemoryKv, makePlatform } from './helpers/env.ts';
const book: Book = {
id: 'book-1', title: '远程小说', author: '作者', publisher: '', language: 'zh', isbn: '',
description: '', series: '', seriesIndex: null, tags: [], pubdate: '', format: 'chapters',
coverUrl: null, fileSize: 0, pageCount: null, source: '', manualFields: [], updatedAt: 1,
};
const session: Session = {
user: { id: 'u1', username: 'one', displayName: '一', role: 'member', createdAt: 0 },
accessToken: 'access', accessTokenExpiresAt: 9e15, refreshToken: 'refresh', refreshTokenExpiresAt: 9e15,
};
function content(ids: string[], revision = 'r1'): BookContent {
return {
kind: 'text', total: ids.length, revision,
groups: [{ id: 'all', seq: 0, offset: 0, count: ids.length, title: '全部章节' }],
items: ids.map((id, seq) => ({
id, seq, title: `章节 ${id}`, href: `chapter:${id}`, resourceRef: `resource:${revision}:${id}`,
kind: 'chapter', format: 'html', mediaType: 'text/plain; charset=utf-8',
})),
};
}
function manifest(value: BookContent): Manifest {
return { book, contentUrl: '', coverUrl: null, files: [], ...value, content: value };
}
beforeAll(() => {
window.matchMedia ??= () => ({matches:false,addEventListener(){},removeEventListener(){}} as unknown as MediaQueryList);
window.scrollTo = () => {};
HTMLDialogElement.prototype.showModal ??= function() { this.setAttribute('open', ''); };
HTMLDialogElement.prototype.close ??= function() { this.removeAttribute('open'); };
vi.stubGlobal('Blob', NodeBlob);
globalThis.ResizeObserver ??= class {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
};
URL.createObjectURL ??= () => 'blob:test';
URL.revokeObjectURL ??= () => undefined;
});
const screens: ReaderScreen[] = [];
afterEach(() => {
for (const screen of screens.splice(0)) screen.dispose();
document.body.replaceChildren();
});
async function setup(kv = new MemoryKv(), blobs = new MemoryBlobs(), userId = 'u1', server = 'http://one.test') {
const transport = new FakeTransport();
const platform = makePlatform(transport, kv, blobs);
const api = new ReaderApi(platform, {
async load() { return { ...session, user: { ...session.user, id: userId } }; },
async save() {}, async clear() {},
});
api.setBaseUrl(server);
await api.restore();
const offline = new OfflineStore(kv);
await offline.setScope(publicationScope(server, userId));
const onSignedOut = vi.fn();
const sync = {
status: () => ({ state: 'idle', pending: false, message: '', lastSyncAt: null }),
onStatus: () => () => undefined,
schedule: vi.fn(),
} as unknown as SyncEngine;
const screen = new ReaderScreen({ api, offline, sync, platform, settings: DEFAULT_APP_SETTINGS,
onBack() {}, onSettingsChange() {}, onSignedOut });
screens.push(screen);
document.body.append(screen.element);
return { screen, api, offline, transport, platform, onSignedOut };
}
function serve(transport: FakeTransport, current: BookContent, next = current) {
transport.respondWith((request) => {
if (request.url.endsWith('/manifest')) return { status: 200, headers: {}, json: manifest(current) };
if (request.url.includes('/search?')) return { status:200,headers:{},json:{hits:current.items.map(item=>({title:item.title,excerpt:'正文',anchor:{sectionId:item.href,start:0,end:2,quote:'正文',prefix:'',suffix:''}})),scanned:current.total,total:current.total,failures:[],limited:false} };
if (request.url.endsWith('/refresh')) return { status: 200, headers: {}, json: next };
if (request.url.includes('/assets?')) {
const ref = new URL(request.url, 'http://test').searchParams.get('ref');
return { status: 200, headers: {}, bytes: new TextEncoder().encode(`${ref}\n\n<script>alert(1)</script> 正文`) };
}
return { status: 200, headers: {}, json: { progress: null } };
});
}
function body(screen: ReaderScreen): ShadowRoot | null | undefined {
return screen.element.querySelector('book-content')?.shadowRoot;
}
async function click(screen: ReaderScreen, label: string): Promise<void> {
const button = [...screen.element.querySelectorAll('button')].find((candidate) =>
candidate.getAttribute('aria-label') === label || candidate.textContent?.trim().endsWith(label));
expect(button, label).toBeDefined();
button!.click();
await Promise.resolve();
}
describe('chapter publication reading', () => {
it('does not save a temporary opening position while waiting for remote progress', async () => {
const env = await setup(); serve(env.transport, content(['a', 'b']));
const old = { bookId: book.id, locator: 'r1:0.0000:chapter:b', percentage: 0.5,
chapterTitle: 'b', device: 'phone', updatedAt: 10 };
await env.offline.setProgress(old);
let finish!: (value: typeof old) => void;
vi.spyOn(env.api, 'getProgress').mockImplementation(() => new Promise(resolve => { finish = resolve; }));
const opening = env.screen.open(book);
await vi.waitFor(() => expect(finish).toBeDefined());
await new Promise(resolve => setTimeout(resolve, 1700));
const duringOpen = env.offline.progressFor(book.id);
finish({ ...old, updatedAt: 20 }); await opening;
expect(duringOpen).toEqual(old);
});
it.each([{ localTime: 10, chapter: 'b' }, { localTime: 30, chapter: 'a' }])(
'opens the latest position when the local timestamp is $localTime', async ({ localTime, chapter }) => {
const env = await setup();
await env.offline.setProgress({ bookId: book.id, locator: 'r1:0.0000:chapter:a', percentage: 0,
chapterTitle: 'a', device: 'phone', updatedAt: localTime });
env.transport.respondWith(request => {
if (request.url.endsWith('/manifest')) return { status: 200, headers: {}, json: manifest(content(['a', 'b'])) };
if (request.url.includes('/assets?')) return { status: 200, headers: {}, bytes: new TextEncoder().encode(new URL(request.url, 'http://one.test').searchParams.get('ref')!) };
return { status: 200, headers: {}, json: { progress: { bookId: book.id, locator: 'r1:0.0000:chapter:b', percentage: 0.5,
chapterTitle: 'b', device: 'new phone', updatedAt: 20 } } };
});
await env.screen.open(book);
expect(body(env.screen)?.textContent).toContain(`resource:r1:${chapter}`);
});
it('flushes the visible chapter before app background sync, without waiting for the reading debounce', async () => {
const env = await setup();
serve(env.transport, content(['a', 'b']));
await env.screen.open(book);
// Exercise the actual App lifecycle flush and actual ReaderScreen/OfflineStore.
let savedLocator: string | undefined;
const sync = { flush: vi.fn(async () => {
const persisted = new OfflineStore(env.platform.kv);
await persisted.setScope(publicationScope(env.api.baseUrl, 'u1'));
savedLocator = persisted.current.progress[book.id]?.locator;
}) };
await App.prototype.flush.call({ reader: env.screen, offline: env.offline, sync } as unknown as App);
expect(sync.flush).toHaveBeenCalledOnce();
expect(savedLocator).toContain('chapter:a');
expect(env.offline.current.progress[book.id]?.locator).toContain('chapter:a');
});
it('keeps the local reading position when the progress service is unavailable', async () => {
const env = await setup(); serve(env.transport, content(['a', 'b']));
await env.offline.setProgress({ bookId: book.id, locator: 'r1:0.0000:chapter:b', percentage: 0.5,
chapterTitle: 'b', device: 'phone', updatedAt: 10 });
vi.spyOn(env.api, 'getProgress').mockRejectedValue(new ApiError('offline', 'offline'));
await env.screen.open(book);
expect(body(env.screen)?.textContent).toContain('resource:r1:b');
});
it('switches from the contents panel only after chapter selection and preserves the old view on failure', async () => {
const env = await setup(); let failSwitch = true;
env.transport.respondWith(request => {
if (request.url.endsWith('/source-options')) return { status: 200, headers: {}, json: { canSwitch: true } };
if (request.url.endsWith('/manifest')) return { status: 200, headers: {}, json: manifest(content(['a', 'b'])) };
if (request.url.endsWith('/alternatives')) return { status: 200, headers: {}, stream: new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode('event: results\ndata: ' + JSON.stringify({ items: [{ ref: 'other-book', title: '同一本书', authors: ['作者'], sourceName: '其它书源', latestChapter: '最新章节' }] }) + '\n\nevent: done\ndata: {}\n\n')); controller.close(); } }) };
if (request.url.endsWith('/switch-preview')) return { status: 200, headers: {}, json: { chapters: [{ id: 'x', title: '另一个章节' }] } };
if (request.url.endsWith('/switch-source')) return failSwitch
? { status: 502, headers: {}, json: { error: { code: 'SOURCE_ERROR', message: '正文获取失败' } } }
: { status: 200, headers: {}, json: { content: content(['x', 'y'], 'r2'), href: 'chapter:x' } };
if (request.url.includes('/assets?')) return { status: 200, headers: {}, bytes: new TextEncoder().encode(new URL(request.url, 'http://test').searchParams.get('ref')!) };
return { status: 200, headers: {}, json: { progress: null } };
});
await env.screen.open(book); await click(env.screen, '目录'); await click(env.screen, '切换书源');
await vi.waitFor(() => expect(env.screen.element.querySelector('.source-switch-card')?.textContent).toContain('其它书源')); await click(env.screen, '查看此源目录:其它书源');
await vi.waitFor(() => expect(env.screen.element.querySelector('section[aria-label="切换书源"] select')).not.toBeNull());
const submit = [...env.screen.element.querySelectorAll('button')].find(button => button.textContent === '确认换源并阅读')!;
expect(submit.disabled).toBe(true);
const select = env.screen.element.querySelector<HTMLSelectElement>('section[aria-label="切换书源"] select')!;
select.value = 'x'; select.dispatchEvent(new Event('change', { bubbles: true }));
const quality = vi.spyOn(env.api, 'switchQuality');
quality.mockRejectedValueOnce(new ApiError('bad_request', '空章不能读取', 'EMPTY_CHAPTER', 400));
await click(env.screen, '检测所选章节 / 重试');
await vi.waitFor(() => expect(env.screen.element.textContent).toContain('空章不能读取'));
quality.mockResolvedValueOnce({ chapterId: 'x', title: '另一个章节', latestChapter: '末章', characters: 100, images: 0, bytes: 300, elapsedMs: 10 });
await click(env.screen, '检测所选章节 / 重试');
await vi.waitFor(() => expect(env.screen.element.textContent).toContain('100 字符'));
expect(body(env.screen)?.textContent).toContain('resource:r1:a');
select.dispatchEvent(new Event('change', { bubbles: true }));
expect(env.screen.element.querySelector('dialog[aria-label="选择阅读章节"]')?.textContent).not.toContain('100 字符');
let finish!: (value: {chapterId:string;title:string;latestChapter:string;characters:number;images:number;bytes:number;elapsedMs:number}) => void;
quality.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
await click(env.screen, '检测所选章节 / 重试');
select.dispatchEvent(new Event('change', { bubbles: true }));
expect(quality.mock.calls.at(-1)?.[3]?.signal?.aborted).toBe(true);
finish({chapterId:'x',title:'迟到结果',latestChapter:'末章',characters:999,images:0,bytes:999,elapsedMs:1});
await new Promise(resolve => setTimeout(resolve, 0));
expect(env.screen.element.querySelector('dialog[aria-label="选择阅读章节"]')?.textContent).not.toContain('999 字符');
await click(env.screen, '确认换源并阅读');
await vi.waitFor(() => expect(noticeText()).toContain('正文获取失败'));
expect(body(env.screen)?.textContent).toContain('resource:r1:a');
expect(env.screen.element.querySelector('section[aria-label="切换书源"]')?.textContent).toContain('最新章节');
failSwitch = false; await click(env.screen, '确认换源并阅读');
await vi.waitFor(() => expect(body(env.screen)?.textContent).toContain('resource:r2:x'));
await vi.waitFor(() => expect(env.offline.current.progress[book.id]?.locator).toContain('chapter:x'));
expect(noticeText()).toContain('已切换书源');
});
it('renders cached rich chapters with images without requesting an external origin, including offline reopening', async () => {
const kv = new MemoryKv(); const blobs = new MemoryBlobs();
const first = await setup(kv, blobs);
const rich = content(['rich']); rich.kind = 'reflowable';
delete rich.items[0]!.format; rich.items[0]!.mediaType = 'text/html; charset=utf-8';
const html = '<h2>插图章节</h2><p>含有<strong>强调</strong>的正文。</p><img alt="插图" src="data:image/png;base64,iVBORw0KGgo=">';
first.transport.respondWith((request) => {
if (request.url.endsWith('/manifest')) return { status: 200, headers: {}, json: manifest(rich) };
if (request.url.includes('/assets?')) return { status: 200, headers: {}, bytes: new TextEncoder().encode(html) };
return { status: 200, headers: {}, json: { progress: null } };
});
await first.screen.open(book);
expect(body(first.screen)?.querySelector('strong')?.textContent).toBe('强调');
expect(body(first.screen)?.querySelector('img')?.getAttribute('src')).toMatch(/^data:image\/png/);
first.screen.dispose(); screens.splice(screens.indexOf(first.screen), 1); await first.offline.flush();
const second = await setup(kv, blobs); second.transport.failWith(new ApiError('offline', 'offline'));
await second.screen.open(book);
expect(body(second.screen)?.querySelector('h2')?.textContent).toBe('插图章节');
expect(body(second.screen)?.querySelector('img')?.getAttribute('src')).toMatch(/^data:image\/png/);
expect(second.transport.requests.some((request) => request.url.includes('/assets?'))).toBe(false);
});
it('ignores delayed chapter callbacks after disposal and an offline account switch', async () => {
const env = await setup();
let release!: () => void;
let started!: () => void;
const bodyGate = new Promise<void>((resolve) => { release = resolve; });
const bodyStarted = new Promise<void>((resolve) => { started = resolve; });
env.transport.respondWith(async (request) => {
if (request.url.endsWith('/manifest')) return { status: 200, headers: {}, json: manifest(content(['a'])) };
if (request.url.includes('/assets?')) {
started();
await bodyGate;
return { status: 200, headers: {}, bytes: new TextEncoder().encode('delayed private chapter') };
}
return { status: 200, headers: {}, json: { progress: null } };
});
const opening = env.screen.open(book);
await bodyStarted;
env.screen.dispose();
screens.splice(screens.indexOf(env.screen), 1);
await env.offline.setScope('other-account');
release();
await opening;
expect(env.offline.current.progress).toEqual({});
expect(env.screen.element.isConnected).toBe(false);
});
it('caches the complete manifest and read text, reopens offline, and renders markup as literal text', async () => {
const kv = new MemoryKv();
const blobs = new MemoryBlobs();
const first = await setup(kv, blobs);
// Three chapters: opening a also prefetches b, so c is the one never cached.
serve(first.transport, content(['a', 'b', 'c']));
await first.screen.open(book);
expect(body(first.screen)?.textContent).toContain('<script>alert(1)</script>');
expect(body(first.screen)?.querySelector('script')).toBeNull();
expect(first.transport.requests.some((request) => /\/(toc|items)$/.test(request.url))).toBe(false);
first.screen.dispose();
screens.splice(screens.indexOf(first.screen), 1);
await first.offline.flush();
const second = await setup(kv, blobs);
second.transport.failWith(new ApiError('offline', 'offline'));
await second.screen.open(book);
expect(body(second.screen)?.textContent).toContain('resource:r1:a');
await click(second.screen, '目录');
expect(second.screen.element.querySelectorAll('.toc-list li')).toHaveLength(3);
await click(second.screen, '3章节 c');
await vi.waitFor(() => expect(noticeText()).toContain('这一章尚未缓存'));
expect(body(second.screen)?.textContent).toContain('resource:r1:a');
expect(body(second.screen)?.textContent).not.toContain('resource:r1:c');
});
it('refreshes by stable href, preserving the chapter after insertion and keeping old content on failure', async () => {
const { screen, offline, transport } = await setup();
await offline.setProgress({ bookId: book.id, locator: 'r1:0.2500:chapter:b', chapterTitle: '章节 b',
percentage: 0.6, device: 'test', updatedAt: Date.now() });
serve(transport, content(['a', 'b']), content(['new', 'a', 'b'], 'r2'));
await screen.open(book);
expect(body(screen)?.textContent).toContain('resource:r1:b');
await click(screen, '目录');
await click(screen, '刷新目录');
await vi.waitFor(() => expect(noticeText()).toContain('新增 1 章'));
expect(body(screen)?.textContent).toContain('resource:r2:b');
expect(screen.element.querySelector('[aria-current="true"]')?.closest('li')?.getAttribute('data-section')).toBe('chapter:b');
expect(screen.element.querySelectorAll('.toc-list li')).toHaveLength(3);
transport.failWith(new ApiError('server', 'plugin unavailable', 'PLUGIN_DISABLED', 503));
await click(screen, '刷新目录');
await vi.waitFor(() => expect(noticeText()).toContain('已保留当前章节'));
expect(body(screen)?.textContent).toContain('resource:r2:b');
expect(screen.element.querySelectorAll('.toc-list li')).toHaveLength(3);
});
it.each([400, 401, 403, 404])('does not fall back to cached content after an HTTP %i response', async (status) => {
const env = await setup();
const cache = new PublicationCache(env.platform.kv, env.platform.blobs, publicationScope(env.api.baseUrl, 'u1'));
await cache.putManifest(book.id, manifest(content(['a'])));
await cache.putResource(book.id, 'resource:r1:a', new TextEncoder().encode('private chapter'));
env.transport.json({ error: { code: 'DENIED', message: 'Denied' } }, status);
await env.screen.open(book);
expect(body(env.screen)?.textContent ?? '').not.toContain('private chapter');
expect(env.transport.requests.some((request) => request.url.includes('/assets?'))).toBe(false);
if (status === 401) expect(env.onSignedOut).toHaveBeenCalled();
});
it('separates cached manifests, chapter bytes and progress between accounts and servers', async () => {
const kv = new MemoryKv();
const blobs = new MemoryBlobs();
const one = new PublicationCache(kv, blobs, publicationScope('http://one.test/', 'u1'));
await one.putManifest(book.id, manifest(content(['a'])));
await one.putResource(book.id, 'ref', new TextEncoder().encode('private'));
for (const [server, user] of [['http://one.test', 'u2'], ['http://two.test', 'u1']]) {
const other = new PublicationCache(kv, blobs, publicationScope(server!, user!));
expect(await other.manifest(book.id)).toBeNull();
expect(await other.resource(book.id, 'ref')).toBeNull();
}
const scope = new OfflineStore(kv);
await scope.setScope(publicationScope('http://one.test', 'u1'));
await scope.setProgress({ bookId: book.id, locator: 'chapter:a', percentage: 0.2, chapterTitle: '', device: '', updatedAt: 1 });
await scope.setScope(publicationScope('http://one.test', 'u2'));
expect(await scope.getProgress(book.id)).toBeUndefined();
await scope.setScope(publicationScope('http://one.test', 'u1'));
expect((await scope.getProgress(book.id))?.locator).toBe('chapter:a');
});
});
it('refreshes only the current chapter, updates offline cache and preserves text after errors', async () => {
const env = await setup(); const value = content(['a']); value.sourceName = '示例源'; value.items[0]!.sourceUrl = 'https://example.test/a';
let fail = false, refreshes = 0;
env.transport.respondWith(request => {
if (request.url.endsWith('/manifest')) return { status: 200, headers: {}, json: manifest(value) };
if (request.url.endsWith('/refresh-chapter')) {
refreshes++;
return fail ? { status: 502, headers: {}, json: { error: { code: 'SOURCE_ERROR', message: 'offline' } } }
: { status: 200, headers: {}, bytes: new TextEncoder().encode('已更新的章节正文') };
}
if (request.url.includes('/assets?')) return { status: 200, headers: {}, bytes: new TextEncoder().encode('原来的正文') };
return { status: 200, headers: {}, json: {} };
});
await env.screen.open(book);
expect(body(env.screen)?.textContent).toContain('原来的正文');
expect(env.screen.element.querySelector('.reader-source-info')?.textContent).toContain('示例源');
expect(env.screen.element.querySelector('.reader-source-info a')?.getAttribute('href')).toBe('https://example.test/a');
await click(env.screen, '刷新当前章节');
await vi.waitFor(() => expect(body(env.screen)?.textContent).toContain('已更新的章节正文'));
fail = true; await click(env.screen, '刷新当前章节');
await vi.waitFor(() => expect(noticeText()).toContain('刷新正文失败'));
expect(body(env.screen)?.textContent).toContain('已更新的章节正文'); expect(refreshes).toBe(2);
const cache = new PublicationCache(env.platform.kv, env.platform.blobs, publicationScope(env.api.baseUrl, 'u1'));
expect(new TextDecoder().decode((await cache.resource(book.id, 'resource:r1:a'))!)).toContain('已更新的章节正文');
});
describe('legacy offline migration', () => {
it('claims a restored session once and preserves pending progress, notes, deletes and the original backup', async () => {
const kv = new MemoryKv();
const legacy = new OfflineStore(kv);
await legacy.setProgress({ bookId: 'b', locator: 'r1:0.5000:chapter:a', percentage: 0.5, chapterTitle: '', device: '', updatedAt: 1 });
await legacy.upsertNotes([{ id: 'n', bookId: 'b', locator: 'x', type: 'note', text: '待同步', comment: '', color: '', updatedAt: 1 }]);
await legacy.deleteNote('old');
const backup = await kv.get('reader.offline.v1');
const migrated = new OfflineStore(kv);
await migrated.setScope('server/account-1', { claimLegacy: true });
expect(migrated.outbox()).toEqual(legacy.outbox());
expect(await kv.get('reader.offline.v1')).toBe(backup);
const second = new OfflineStore(kv);
await second.setScope('server/account-2', { claimLegacy: true });
expect(second.outbox()).toEqual({ progress: [], notes: [], deletes: [] });
});
it('does not give a fresh login legacy data when its session is restored on the next launch', async () => {
const kv = new MemoryKv();
await kv.set('reader.offline.v1', JSON.stringify({ progress: { secret: { locator: 'private' } } }));
const fresh = new OfflineStore(kv);
await fresh.setScope('new-account');
const reopened = new OfflineStore(kv);
await reopened.setScope('new-account', { claimLegacy: true });
expect(reopened.current.progress).toEqual({});
});
});
it('opens tools, searches the whole book, navigates and returns, and persists a bookmark', async () => {
const env = await setup(); serve(env.transport,content(['a','b'])); await env.screen.open(book);
await click(env.screen,'工具'); await vi.waitFor(()=>expect(env.screen.element.textContent).toContain('搜索全文'));
const input = env.screen.element.querySelector<HTMLInputElement>('input[maxlength="200"]')!;
input.value = '正文'; input.dispatchEvent(new Event('input',{bubbles:true}));
await vi.waitFor(()=>expect(env.screen.element.querySelector<HTMLButtonElement>('form button')?.disabled).toBe(false));
await click(env.screen,'搜索全文');
await vi.waitFor(()=>expect(env.screen.element.querySelectorAll('.reading-tool-results li')).toHaveLength(2));
const results = env.screen.element.querySelectorAll<HTMLButtonElement>('.reading-tool-results li button'); results[1]!.click();
await vi.waitFor(()=>expect(body(env.screen)?.textContent).toContain('resource:r1:b'));
await vi.waitFor(()=>expect(env.screen.element.querySelector('dialog')).toBeNull());
await click(env.screen,'工具'); await click(env.screen,'返回原阅读位置');
await vi.waitFor(()=>expect(body(env.screen)?.textContent).toContain('resource:r1:a'));
await vi.waitFor(()=>expect(env.screen.element.querySelector('dialog')).toBeNull());
await click(env.screen,'工具'); await click(env.screen,'笔记');
expect(env.screen.element.querySelector('.reading-note-editor')).toBeNull();
await click(env.screen,'添加当前位置书签');
await vi.waitFor(()=>expect(env.offline.notesFor(book.id)).toHaveLength(1));
expect(env.offline.notesFor(book.id)[0]?.type).toBe('bookmark');
await vi.waitFor(()=>expect(env.screen.element.querySelectorAll('.reading-note-list li')).toHaveLength(1));
await click(env.screen,'编辑');
await vi.waitFor(()=>expect(env.screen.element.querySelector('textarea[aria-label="批注"]')).not.toBeNull());
const comment=env.screen.element.querySelector<HTMLTextAreaElement>('textarea[aria-label="批注"]')!;
await act(() => { comment.value='稍后再读';comment.dispatchEvent(new Event('input',{bubbles:true})); });
await click(env.screen,'保存修改');
await vi.waitFor(()=>expect(env.offline.notesFor(book.id)[0]?.comment).toBe('稍后再读'));
await vi.waitFor(()=>expect(env.screen.element.querySelector('.reading-note-editor')).toBeNull());
await act(() => click(env.screen,'删除笔记'));
expect(env.offline.notesFor(book.id)).toHaveLength(1);
await act(() => document.querySelector<HTMLButtonElement>('dialog[role=alertdialog] .sheet-cancel')!.click());
await vi.waitFor(()=>expect(document.querySelector('dialog[role=alertdialog]')).toBeNull());
expect(env.offline.notesFor(book.id)).toHaveLength(1);
await act(() => click(env.screen,'删除笔记'));await act(() => document.querySelector<HTMLButtonElement>('dialog[role=alertdialog] .sheet-footer .primary')!.click());
await vi.waitFor(()=>expect(env.offline.notesFor(book.id)).toHaveLength(0));
});
it('keeps saying why a book did not open, and 重试 opens it once the server answers', async () => {
const env = await setup();
env.transport.json({ error: { code: 'BROKEN', message: '服务器出错了' } }, 500);
await env.screen.open(book);
await vi.waitFor(() => expect(env.screen.element.querySelector('.reader-open-failure')?.textContent).toContain('服务器出错了'));
serve(env.transport, content(['a']));
await click(env.screen, '重试');
await vi.waitFor(() => expect(body(env.screen)?.textContent ?? '').toContain('正文'));
expect(env.screen.element.querySelector('.reader-open-failure')).toBeNull();
});
it('turns pages from the keyboard without the reader having focused anything first', async () => {
const env = await setup(); serve(env.transport, content(['a', 'b']));
await env.screen.open(book);
const turn = vi.spyOn(env.screen as unknown as { turnPage(direction: string): Promise<void> }, 'turnPage').mockResolvedValue(undefined);
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', shiftKey: true, bubbles: true }));
expect(turn.mock.calls.map(call => call[0])).toEqual(['next', 'previous']);
});
it('fetches the next chapter while the current one is read, once', async () => {
const env = await setup(); serve(env.transport, content(['a', 'b', 'c']));
await env.screen.open(book);
const assets = () => env.transport.requests.filter((request) => request.url.includes('/assets?')).map((request) => new URL(request.url, 'http://test').searchParams.get('ref'));
await vi.waitFor(() => expect(assets()).toContain('resource:r1:b'));
expect(assets().filter((ref) => ref === 'resource:r1:b')).toHaveLength(1);
expect(assets()).not.toContain('resource:r1:c');
});