Merge branch 'feat/progress-history'

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hectorqinandClaude Opus 5.5 committed 2026-10-31 03:52:14 +00:00
commit bbc0ec0ee9
13 files changed
+247 -3

No files matched your search

+8
View File
@@ -829,6 +829,14 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用**
→ `{ "progress": {...} | null }`
### `GET /sync/progress/:bookId/history`
`?limit=`(默认 30,最大 50)→ `{ "history": [ {...}, ... ] }`,字段同上,按 `updatedAt` 倒序。
阅读记录:每次有效的进度写入都会记下,**包括在「后写者胜」合并中输掉的那次**(例如离线设备的旧位置)。
同一设备在同一章、半小时内、进度相差不到 5% 的连续写入合并成一条并向前移动;换章、跳转、换设备、乱序到达的写入各成一条。
每本书保留最新 50 条,只返回当前账号自己的记录。
### `PUT /sync/progress/:bookId`
单个进度上报的便捷写法:
+1 -1
View File
@@ -17,7 +17,7 @@
## 未完成的 P0 范围
- 进度历史和用户主动恢复入口;多设备系统时间偏差下的冲突策略。
- ~~进度历史和用户主动恢复入口~~:已提供(服务端 `reading_progress_history`,阅读器「书籍信息 → 阅读记录」可回到任一记录位置)。多设备系统时间偏差下的冲突策略仍未处理;时间超前的设备仍会在合并中胜出,但被覆盖的位置可从阅读记录找回。
- iOS Safari 与 Android 真机的手势、跨章、旋转、关闭/强制退出测试。本地 jsdom 测试不能替代真实渲染与系统生命周期。
- 备份/恢复图形界面和运行中一致性快照;本批只提供需要停机的维护命令。
- 首次接入向导、旧版数据自动迁移、数据损坏诊断界面。
+1
View File
@@ -687,6 +687,7 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
- 底部:上一章、本章页数滑杆、下一章;下一行是工具(书内搜索、笔记与书签)、目录、朗读、外观、设置。朗读进行中时“朗读”按钮显示“朗读中”(按下态),因为关掉朗读面板不会停止朗读。
- 滚动模式下手动滑到章末后,从章末开始继续上拉(触屏超过 72px,或滚轮在章末继续滚动)进入下一章;在章首下拉回到上一章。手势必须从边缘开始,只是滑到章末不会翻章;翻章后有约 1 秒冷却,滚轮惯性不会连翻两章。
- 中间滑杆控制本章页数,不能变成全书进度。例外是漫画:它每“章”就是一页,本章页数恒为 1,所以滑杆改为全书页码(“第 7/200 页”),拖动时只更新标签,松手才打开那一页。
- 「书籍信息」(点顶部书名)末尾是「阅读记录」:本账号在这本书里去过的位置,最新在前,每条写章节、进度、设备与相对时间;当前位置标「当前」且不可点,点其他条目回到那里并成为新的保存位置。离线时说明无法读取,读取失败给「重试」。
- 书打不开时(服务端错误、未缓存的离线书),舞台上一直显示原因和“重试”“返回”,不只弹一条会消失的提示。直接打开阅读链接、没有上一页时,“返回”回到书架。
- 键盘:方向键与 PageUp/PageDown 翻页,空格/Shift+空格翻下/上一页,S 开始或暂停朗读,Esc 收起工具栏;右→左的漫画方向键随方向镜像。焦点在按钮上时空格和回车只按下那个按钮。
- 顶部左/右、底部左/右阅读信息可在“外观”分别选择书名、章节名、章节进度(章内页码与全书百分比)、时间或不显示。默认顶部左侧章节名,底部左侧章节进度,底部右侧时间。配置按设备保存,工具栏隐藏后仍显示;长文本省略。关闭信息仍保留固定边界,避免改变正文分页。日夜圆形快捷按钮随工具栏一起显隐。
+2
View File
@@ -84,6 +84,8 @@ EPUB 章节按需加载,长书的目录与传输窗口分开处理,跨窗口
首个账号是管理员,后续成员默认由管理员创建。每个账号的书架、进度、笔记和高亮独立。阅读界面参数属于当前设备,不随账号覆盖其他设备的设置。
阅读进度在设备间以最后一次写入为准。如果被另一台设备或误点的目录项带到了别处,点阅读器顶部的书名打开「书籍信息」,在「阅读记录」里可以看到最近去过的位置(章节、进度、设备、时间),点一条即可回去;离线时无法读取阅读记录。
离线时可使用已经缓存的内容,重新连通后同步阅读状态。在阅读器底部「工具 → 离线缓存」可下载TXT 的指定范围,或下载完整 EPUB、PDF、CBZ;自定义目录的 TXT 下载完整文件。任务支持暂停、继续和取消,关闭面板会暂停,已下载内容保留。默认设备账号配额为 256 MiB,可调整或按书清理;不自动淘汰,也不删除原书、进度、笔记。服务端缓存与当前设备缓存独立,只有本设备完成下载的内容才能保证断网读取。图片目录暂不支持整书下载。
## 进一步阅读
+19
View File
@@ -237,6 +237,25 @@ CREATE TABLE IF NOT EXISTS reading_progress (
);
CREATE INDEX IF NOT EXISTS idx_progress_updated ON reading_progress(user_id, updated_at);
-- Where the reader has been in a book, so a jump can be undone (P0: 进度历史).
-- reading_progress keeps one row and last-writer-wins, which is right for "where am
-- I" and wrong for "where was I": a stale device, a mistapped 目录 entry or a device
-- whose clock runs fast all overwrite the place for good. Every valid progress write
-- lands here too — including one that lost the merge — and a device reading on
-- through one chapter updates its latest entry instead of adding rows. A new table,
-- so older builds that share this database simply do not see it.
CREATE TABLE IF NOT EXISTS reading_progress_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
book_id TEXT NOT NULL REFERENCES books(id) ON DELETE CASCADE,
locator TEXT NOT NULL DEFAULT '',
percentage REAL NOT NULL DEFAULT 0,
chapter_title TEXT NOT NULL DEFAULT '',
device TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_progress_history ON reading_progress_history(user_id, book_id, updated_at);
-- Notes, highlights and bookmarks. deleted is a tombstone rather than a hard
-- delete so that an offline client cannot resurrect a note it removed elsewhere.
CREATE TABLE IF NOT EXISTS notes (
+7
View File
@@ -55,6 +55,13 @@ export function registerSyncRoutes(app: FastifyInstance, ctx: AppContext): void
return { progress: ctx.sync.progressFor(user.id, bookId) };
});
app.get('/api/v1/sync/progress/:bookId/history', { preHandler: auth }, async (request) => {
const user = currentUser(request);
const { bookId } = request.params as { bookId: string };
const limit = Number((request.query as { limit?: string }).limit) || 30;
return { history: ctx.sync.progressHistory(user.id, bookId, limit) };
});
app.put('/api/v1/sync/progress/:bookId', { preHandler: auth }, async (request) => {
const user = currentUser(request);
const { bookId } = request.params as { bookId: string };
+60
View File
@@ -11,6 +11,11 @@ import type { BookDto, ShelfService } from './shelf.ts';
* offline for a week can push its local changes and receive only what changed
* on the server since `since`.
*/
/** Progress history: entries kept per book, and what still counts as reading on in one place. */
const HISTORY_KEEP = 50;
const HISTORY_SESSION_MS = 30 * 60 * 1000;
const HISTORY_JUMP = 0.05;
export interface ProgressRecord {
bookId: string;
/** Opaque locator: EPUB CFI, pagination index or PDF page. Client-defined. */
@@ -123,6 +128,61 @@ export class SyncService {
userId, record.bookId, record.locator ?? '', percentage,
record.chapterTitle ?? '', record.device ?? '', record.updatedAt,
);
this.recordHistory(userId, record, percentage);
}
/**
* Adds a progress write to the book's history (schema: reading_progress_history).
*
* Reading on through one chapter on one device is one entry that moves forward,
* not a row per page: the latest entry of the same device is updated when the
* write is newer, in the same chapter, within 5% of it and within half an hour.
* Anything else — another chapter, a jump, another device, a write that arrives
* out of order — is a new entry, because those are exactly the places a reader may
* want back. The newest 50 per book are kept.
*/
private recordHistory(userId: string, record: ProgressRecord, percentage: number): void {
const device = record.device ?? '', chapter = record.chapterTitle ?? '';
const latest = this.db.get<{ id: number; chapter_title: string; percentage: number; updated_at: number }>(
`SELECT id, chapter_title, percentage, updated_at FROM reading_progress_history
WHERE user_id = ? AND book_id = ? AND device = ? ORDER BY updated_at DESC, id DESC LIMIT 1`,
userId, record.bookId, device,
);
const continues = latest
&& record.updatedAt >= latest.updated_at
&& record.updatedAt - latest.updated_at <= HISTORY_SESSION_MS
&& latest.chapter_title === chapter
&& Math.abs(latest.percentage - percentage) < HISTORY_JUMP;
if (continues) {
this.db.run(
'UPDATE reading_progress_history SET locator = ?, percentage = ?, updated_at = ? WHERE id = ?',
record.locator ?? '', percentage, record.updatedAt, latest.id,
);
return;
}
this.db.run(
`INSERT INTO reading_progress_history (user_id, book_id, locator, percentage, chapter_title, device, updated_at)
VALUES (?,?,?,?,?,?,?)`,
userId, record.bookId, record.locator ?? '', percentage, chapter, device, record.updatedAt,
);
this.db.run(
`DELETE FROM reading_progress_history WHERE user_id = ? AND book_id = ? AND id NOT IN (
SELECT id FROM reading_progress_history WHERE user_id = ? AND book_id = ?
ORDER BY updated_at DESC, id DESC LIMIT ?)`,
userId, record.bookId, userId, record.bookId, HISTORY_KEEP,
);
}
/** The book's reading history, newest first. */
progressHistory(userId: string, bookId: string, limit = 30): ProgressRecord[] {
return this.db.all<{ locator: string; percentage: number; chapter_title: string; device: string; updated_at: number }>(
`SELECT locator, percentage, chapter_title, device, updated_at FROM reading_progress_history
WHERE user_id = ? AND book_id = ? ORDER BY updated_at DESC, id DESC LIMIT ?`,
userId, bookId, Math.max(1, Math.min(HISTORY_KEEP, limit)),
).map((row) => ({
bookId, locator: row.locator, percentage: row.percentage,
chapterTitle: row.chapter_title, device: row.device, updatedAt: row.updated_at,
}));
}
private upsertNote(userId: string, record: NoteRecord): void {
+29
View File
@@ -294,6 +294,35 @@ test('a stale offline device cannot roll back newer progress', async () => {
assert.equal((view.json() as { progress: { locator: string } }).progress.locator, 'fresh');
});
test('progress history keeps the places a write replaced, one entry per stretch of reading', async () => {
const owner = await login('owner');
const member = await login('family');
const book = ctx.db.get<{ id: string }>('SELECT id FROM books')!;
const push = (items: Array<{ locator: string; percentage: number; chapterTitle: string; device: string; updatedAt: number }>, token = owner.token) =>
app.inject({ method: 'POST', url: '/api/v1/sync', headers: auth(token), payload: { progress: items.map(item => ({ bookId: book.id, ...item })) } });
const minute = 60_000, base = 1_000_000_000_000;
// Reading on through chapter 3 on the phone: one entry that moves forward.
await push([{ locator: 'c3-a', percentage: 0.30, chapterTitle: '第三章', device: 'phone', updatedAt: base }]);
await push([{ locator: 'c3-b', percentage: 0.31, chapterTitle: '第三章', device: 'phone', updatedAt: base + minute }]);
// A mistapped 目录 entry jumps to chapter 40: a new entry, and chapter 3 stays restorable.
await push([{ locator: 'c40', percentage: 0.90, chapterTitle: '第四十章', device: 'phone', updatedAt: base + 2 * minute }]);
// A stale tablet loses the merge, but where it was is history too.
await push([{ locator: 'c2', percentage: 0.20, chapterTitle: '第二章', device: 'tablet', updatedAt: base - minute }]);
const read = async (token = owner.token) => (await app.inject({ method: 'GET', url: `/api/v1/sync/progress/${book.id}/history`, headers: auth(token) })).json() as { history: Array<{ locator: string; device: string }> };
// Newest first; earlier tests in this file left older entries for the same book behind.
assert.deepEqual((await read()).history.slice(0, 3).map(item => [item.locator, item.device]), [['c40', 'phone'], ['c3-b', 'phone'], ['c2', 'tablet']]);
const current = await app.inject({ method: 'GET', url: `/api/v1/sync/progress/${book.id}`, headers: auth(owner.token) });
assert.equal((current.json() as { progress: { locator: string } }).progress.locator, 'c40');
// Another account sees none of it (only its own places).
assert.ok((await read(member.token)).history.every(item => !['c40', 'c3-b', 'c2'].includes(item.locator)));
// Kept bounded: the newest 50.
await push(Array.from({ length: 60 }, (_, index) => ({ locator: `x${index}`, percentage: index / 60, chapterTitle: `第${index}章`, device: 'phone', updatedAt: base + (10 + index) * minute })));
const all = await app.inject({ method: 'GET', url: `/api/v1/sync/progress/${book.id}/history?limit=100`, headers: auth(owner.token) });
const entries = (all.json() as { history: Array<{ locator: string }> }).history;
assert.equal(entries.length, 50);
assert.equal(entries[0]!.locator, 'x59');
});
test('deleting a note leaves a tombstone that a stale device cannot undo', async () => {
const owner = await login('owner');
const book = ctx.db.get<{ id: string }>('SELECT id FROM books')!;
+9
View File
@@ -687,6 +687,15 @@ export class ReaderApi {
* Single progress write. Cheaper than a batch and used for the periodic
* "still reading" report, where there is nothing to reconcile.
*/
/** Where this account has been in a book, newest first (server keeps the latest 50). */
async progressHistory(bookId: string, options: RequestOptions = {}): Promise<Progress[]> {
const result = await this.get<{ history: Progress[] }>(
`/api/v1/sync/progress/${encodeURIComponent(bookId)}/history`,
options,
);
return result.history;
}
async putProgress(progress: ProgressInput, options: RequestOptions = {}): Promise<Progress | null> {
const result = await this.call<{ progress: Progress | null }>(
`/api/v1/sync/progress/${encodeURIComponent(progress.bookId)}`,
+9
View File
@@ -3880,6 +3880,15 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti
.book-detail-author { color: var(--reader-muted); }
.book-detail dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: .6rem 1rem; padding-block: .8rem; border-block: 1px solid var(--reader-line); }
.book-detail dt { color: var(--reader-muted); }
/* 阅读记录 in 书籍信息: one row per place, the one on screen marked and not pressable. */
.reading-history ul { display: grid; gap: var(--space-1); margin: 0; padding: 0; list-style: none; }
.reading-history button { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 0 var(--space-2); width: 100%; min-height: 44px; padding: var(--space-1) var(--space-2); border: 1px solid var(--reader-line); border-radius: var(--radius-sm); background: transparent; color: var(--reader-ink); font: inherit; text-align: start; cursor: pointer; }
.reading-history button strong { grid-column: 1; font-weight: 500; overflow-wrap: anywhere; }
.reading-history button small { grid-column: 1; color: var(--reader-muted); font-size: var(--text-meta); }
.reading-history button:disabled { cursor: default; border-color: transparent; background: var(--color-brand-soft); }
.reading-history button:focus-visible { outline: var(--focus-ring); outline-offset: var(--focus-offset); }
.reading-history-current { grid-column: 2; grid-row: 1 / span 2; color: var(--reader-accent); font-size: var(--text-meta); }
@media (hover: hover) and (pointer: fine) { .reading-history button:not(:disabled):hover { border-color: var(--reader-accent); } }
.book-detail dd { margin: 0; overflow-wrap: anywhere; }
.book-detail-description { white-space: pre-wrap; line-height: 1.8; overflow-wrap: anywhere; }
+39 -1
View File
@@ -24,6 +24,7 @@ import { FloatingNotice } from './floating-notice.tsx';
import type { AppSettings } from '../store/settings.ts';
import { DEFAULT_APP_SETTINGS, READOUT_FIELDS, READOUT_OPTIONS, type ReadoutMode } from '../store/settings.ts';
import { formatDate } from '../lib/format.ts';
import { ReaderIndicators } from './reader-indicators.tsx';
import type { SpeechEngineKind } from '../render/speech.ts';
import { type ReactNode, type JSX, useState } from 'react';
@@ -69,6 +70,11 @@ export interface ChromeState {
statusState: string;
/** Whether the server offers AI 章节总结; unknown (undefined) draws no button. */
aiSummaryAvailable?: boolean;
/** 阅读记录 in 书籍信息: where this account has been in the book, newest first. */
historyState?: 'loading' | 'ready' | 'offline' | 'error';
history?: Array<{ locator: string; percentage: number; chapterTitle: string; device: string; updatedAt: number }>;
/** The locator on screen when the history was read, so that entry is marked 当前. */
historyCurrent?: string;
/** Whether the page on screen carries a bookmark; drives the top bar's 书签 toggle. */
bookmarked?: boolean;
/** Set when the book itself could not be opened: the stage is empty, so say why and offer a way on. */
@@ -155,6 +161,9 @@ export interface ChromeHandlers {
onTools?(): void;
onBack(): void;
onRetryOpen?(): void;
/** Goes back to a place from 阅读记录. */
onRestoreHistory?(locator: string): void;
onRetryHistory?(): void;
/** Adds a bookmark at the page on screen, or removes the one there. */
onToggleBookmark?(): void;
toggleToc(): void;
@@ -253,7 +262,7 @@ export function ReaderChrome({ state, stage, handlers, tools }: ReaderChromeProp
</div>
{(state.sourceName || state.chapterUrl) && <div className="reader-source-info"><span>{state.sourceName || '当前书源'}</span>{state.chapterUrl && <a href={state.chapterUrl} target="_blank" rel="noopener noreferrer" title={state.chapterUrl}>{state.chapterUrl}</a>}</div>}
</div>
{state.bookInfoOpen && <Sheet title="书籍信息" onClose={() => handlers.onBookInfo?.()}><article className="book-detail"><h3>{state.title}</h3><p className="book-detail-author">{state.author || '作者未知'}</p><dl><dt>书源</dt><dd>{state.sourceName || (state.canRefresh ? '远程书源' : '本地书籍')}</dd><dt>当前章节</dt><dd>{state.chapterLabel || '暂无信息'}</dd></dl><h4>内容简介</h4><p className="book-detail-description">{state.description || '暂无简介'}</p></article></Sheet>}
{state.bookInfoOpen && <Sheet title="书籍信息" onClose={() => handlers.onBookInfo?.()}><article className="book-detail"><h3>{state.title}</h3><p className="book-detail-author">{state.author || '作者未知'}</p><dl><dt>书源</dt><dd>{state.sourceName || (state.canRefresh ? '远程书源' : '本地书籍')}</dd><dt>当前章节</dt><dd>{state.chapterLabel || '暂无信息'}</dd></dl><h4>内容简介</h4><p className="book-detail-description">{state.description || '暂无简介'}</p><ReadingHistory state={state} handlers={handlers} /></article></Sheet>}
<FloatingNotice message={state.aiSummaryBusy ? '正在生成章节总结…' : ''} busy />
{state.aiSummary && <Sheet title="AI 章节总结" onClose={() => handlers.onAiSummary?.()}><article className="book-detail"><p className="book-detail-description">{state.aiSummary}</p></article></Sheet>}
<StageHost stage={stage} />
@@ -384,6 +393,35 @@ export function ReaderChrome({ state, stage, handlers, tools }: ReaderChromeProp
* *where* the stage sits (between the status line and the footer, which is where
* its stacking context comes from) and the screen owns its contents.
*/
/**
* 阅读记录: the places this account has been in the book, on any device, newest first.
* A jump — a mistapped 目录 entry, a stale device, a clock that ran ahead — overwrote the
* one saved position for good; here the place before it is one press away.
*/
function ReadingHistory({ state, handlers }: { state: ChromeState; handlers: ChromeHandlers }): JSX.Element | null {
if (!state.historyState) return null;
return (
<section className="reading-history" aria-label="阅读记录">
<h4>阅读记录</h4>
{state.historyState === 'loading' && <p className="muted" role="status">正在读取阅读记录…</p>}
{state.historyState === 'offline' && <p className="muted">离线时无法读取阅读记录,连上服务端后再试。</p>}
{state.historyState === 'error' && <p className="muted" role="alert">阅读记录读取失败。 <Button onClick={() => handlers.onRetryHistory?.()}>重试</Button></p>}
{state.historyState === 'ready' && (state.history?.length
? <ul>{state.history.map((entry, index) => {
const current = entry.locator === state.historyCurrent;
return <li key={`${entry.updatedAt}-${index}`}>
<button type="button" disabled={current} aria-current={current ? 'location' : undefined} onClick={() => handlers.onRestoreHistory?.(entry.locator)}>
<strong>{entry.chapterTitle || '未命名章节'}</strong>
<small>{Math.round(Math.max(0, Math.min(1, entry.percentage)) * 100)}% · {entry.device || '未知设备'} · {formatDate(entry.updatedAt)}</small>
{current && <span className="reading-history-current">当前</span>}
</button>
</li>;
})}</ul>
: <p className="muted">还没有阅读记录。</p>)}
</section>
);
}
/**
* Opens the 目录 at the chapter being read: in a long serial the reader is in
* chapter 800, and a list that opens at chapter 1 hides both where they are and
+37 -1
View File
@@ -296,7 +296,9 @@ export class ReaderScreen {
onSwitchEngine: (kind) => this.switchSpeechEngine(kind),
onTocEntry: (ref) => void this.goToChapterRef(ref),
onChapter: (delta) => void this.goToChapter(delta),
onBookInfo: () => this.patch({ bookInfoOpen: !this.chrome.bookInfoOpen }),
onBookInfo: () => { const open = !this.chrome.bookInfoOpen; this.patch({ bookInfoOpen: open }); if (open) void this.loadHistory(); },
onRetryHistory: () => void this.loadHistory(),
onRestoreHistory: (locator) => void this.restoreHistory(locator),
onAiSummary: () => void this.generateAiSummary(),
onRefresh: () => void this.refreshPublication(),
onRefreshChapter: () => void this.refreshChapter(),
@@ -1347,6 +1349,40 @@ export class ReaderScreen {
* and the *write* is debounced below, because a scroll is a gesture in progress
* rather than a sequence of decisions to persist.
*/
/**
* Reads 阅读记录 for 书籍信息. The position on screen is pushed first, so the list
* already holds where the reader is now and that entry can be marked 当前.
*/
private async loadHistory(): Promise<void> {
const book = this.book;
if (!book || typeof this.options.api.progressHistory !== 'function') return;
this.patch({ historyState: 'loading' });
try {
await this.flushProgress().catch(() => undefined);
if (typeof this.options.sync.flush === 'function') await this.options.sync.flush();
const history = await this.options.api.progressHistory(book.id);
if (this.disposed || this.book !== book) return;
this.patch({ historyState: 'ready', history, historyCurrent: this.view?.position()?.locator ?? '' });
} catch (error) {
if (this.disposed || this.book !== book) return;
if (error instanceof ApiError && error.isAuthFailure) { this.options.onSignedOut(); return; }
this.patch({ historyState: error instanceof ApiError && error.isConnectivity ? 'offline' : 'error' });
}
}
/** Goes back to a place from 阅读记录; that place then becomes the saved position. */
private async restoreHistory(locator: string): Promise<void> {
this.patch({ bookInfoOpen: false });
try {
await this.navigateTool(locator);
this.onPosition(this.view?.position() ?? null);
await this.flushProgress();
this.flashStatus('已回到这个位置', 1800);
} catch (error) {
this.setStatus('error', error instanceof Error ? error.message : '无法回到这个位置');
}
}
/** Bookmarks at this exact reading position — the same test 阅读工具 uses for 已加书签. */
private bookmarksHere(locator: string | undefined): Note[] {
if (!this.book || !locator) return [];
+26
View File
@@ -500,3 +500,29 @@ it('in scroll mode, pulling on past the end of a chapter opens the next one, and
expect(turn).toHaveBeenLastCalledWith('next');
clock.mockRestore();
});
it('lists 阅读记录 in 书籍信息 and goes back to a place from it', async () => {
const env = await setup(); serve(env.transport, content(['a', 'b']));
await env.screen.open(book);
const places = [
{ bookId: book.id, locator: 'r1:0.0000:chapter:b', percentage: 0.9, chapterTitle: '章节 b', device: 'phone', updatedAt: Date.now() - 60_000 },
{ bookId: book.id, locator: 'r1:0.5000:chapter:a', percentage: 0.25, chapterTitle: '章节 a', device: 'tablet', updatedAt: Date.now() - 3_600_000 },
];
vi.spyOn(env.api, 'progressHistory').mockResolvedValue(places);
const navigate = vi.spyOn(env.screen as unknown as { navigateTool(target: string): Promise<void> }, 'navigateTool').mockResolvedValue(undefined);
await click(env.screen, '书籍信息');
await vi.waitFor(() => expect(env.screen.element.querySelectorAll('.reading-history li')).toHaveLength(2));
expect(env.screen.element.querySelector('.reading-history li small')?.textContent).toContain('90% · phone');
const older = [...env.screen.element.querySelectorAll<HTMLButtonElement>('.reading-history button')].find(button => button.textContent?.includes('tablet'))!;
older.click();
await vi.waitFor(() => expect(navigate).toHaveBeenCalledWith('r1:0.5000:chapter:a'));
expect(env.screen.element.querySelector('.reading-history')).toBeNull();
});
it('says 阅读记录 cannot be read offline instead of showing an empty list', async () => {
const env = await setup(); serve(env.transport, content(['a']));
await env.screen.open(book);
vi.spyOn(env.api, 'progressHistory').mockRejectedValue(new ApiError('offline', 'offline'));
await click(env.screen, '书籍信息');
await vi.waitFor(() => expect(env.screen.element.querySelector('.reading-history')?.textContent).toContain('离线时无法读取阅读记录'));
});