// @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 'preact/test-utils'; 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(() => { 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 正文`) }; } 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 { 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('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')?.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')?.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 = '

插图章节

含有强调的正文。

插图'; 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((resolve) => { release = resolve; }); const bodyStarted = new Promise((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); serve(first.transport, content(['a', 'b'])); await first.screen.open(book); expect(body(first.screen)?.textContent).toContain(''); 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(2); await click(second.screen, '2章节 b'); await vi.waitFor(() => expect(noticeText()).toContain('这一章尚未缓存')); expect(body(second.screen)?.textContent).toContain('resource:r1:a'); expect(body(second.screen)?.textContent).not.toContain('resource:r1:b'); }); 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('input[maxlength="200"]')!; input.value = '正文'; input.dispatchEvent(new Event('input',{bubbles:true})); await vi.waitFor(()=>expect(env.screen.element.querySelector('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('.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('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(() => click(env.screen,'取消')); expect(env.offline.notesFor(book.id)).toHaveLength(1); await act(() => click(env.screen,'删除笔记'));await click(env.screen,'确认删除'); await vi.waitFor(()=>expect(env.offline.notesFor(book.id)).toHaveLength(0)); });