Files
reader/web/test/reading-time.test.ts
hectorqinandClaude Opus 5.5 3b1f8b46dc feat: 时长统计 — reading, watching and listening time by day, month, year and work
Reading time is counted by the reader: only while the page is visible and the
reader has turned, scrolled or tapped within three minutes, or while 朗读 plays.
It is kept per book per 15-minute UTC slot and per device install, saved
locally until sent, and stored as MAX(stored, reported) in the new
reading_time table, so a replayed report never counts twice.

Media time is derived on the server from the progress saves the web player and
Android Media3 already send: the smaller of the wall-clock gap and the position
advance between two saves of one session, gaps over 30s ignored. It is stored
in media.db (media_watch_time), so neither client changes.

我的 → 时长统计 shows a stacked bar per day (one month), month (one year) or
year, with a legend that filters by kind, a table view and the works ranked for
the range or the bar picked. Administrators also get every account's totals.
Chart colours are new mode-level tokens (--color-chart-1..4), validated for
colour-vision separation on both surfaces. The React UI review now covers the
page and names the overflowing elements when a screen overflows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 10:55:52 +00:00

86 lines
3.4 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ReadingTimeTracker, SLOT_MS } from '../src/features/stats/reading-time.ts';
import type { ReadingTimeEntry } from '../src/api/client.ts';
function memoryKv() {
const map = new Map<string, string>();
return {
map,
get: async (key: string) => map.get(key) ?? null,
set: async (key: string, value: string) => { map.set(key, value); },
remove: async (key: string) => { map.delete(key); },
};
}
const START = Date.UTC(2026, 9, 31, 2, 0); // on a slot boundary
describe('ReadingTimeTracker', () => {
let clock = START, visible = true;
let sent: ReadingTimeEntry[][] = [];
let fail = false;
const sink = { recordReadingTime: async (_id: string, entries: ReadingTimeEntry[]) => { if (fail) throw new Error('offline'); sent.push(entries); } };
beforeEach(() => { vi.useFakeTimers(); clock = START; visible = true; sent = []; fail = false; });
afterEach(() => { vi.useRealTimers(); });
const advance = (ms: number, tracker: ReadingTimeTracker) => {
for (let left = ms; left > 0; left -= 15_000) { clock += Math.min(15_000, left); tracker.tick(); }
};
const total = (entries: ReadingTimeEntry[] | undefined) => (entries ?? []).reduce((sum, entry) => sum + entry.seconds, 0);
it('counts active reading and stops three minutes after the last activity', async () => {
const tracker = new ReadingTimeTracker(sink, memoryKv(), () => clock, () => visible);
tracker.start('b1');
advance(10 * 60_000, tracker);
await tracker.stop();
expect(total(sent.at(-1))).toBe(180);
});
it('keeps counting while 朗读 speaks, even hidden', async () => {
const tracker = new ReadingTimeTracker(sink, memoryKv(), () => clock, () => visible);
tracker.start('b1');
tracker.setSpeaking(true);
visible = false;
advance(10 * 60_000, tracker);
await tracker.stop();
expect(total(sent.at(-1))).toBe(600);
});
it('does not count a hidden page, nor the idle time before the reader comes back', async () => {
const tracker = new ReadingTimeTracker(sink, memoryKv(), () => clock, () => visible);
tracker.start('b1');
visible = false;
advance(5 * 60_000, tracker);
visible = true;
advance(5 * 60_000, tracker); // idle since start: nothing
tracker.activity();
advance(60_000, tracker);
await tracker.stop();
expect(total(sent.at(-1))).toBe(60);
});
it('splits time across slots and keeps unsent time across restarts', async () => {
const kv = memoryKv();
fail = true;
const first = new ReadingTimeTracker(sink, kv, () => clock, () => visible);
clock = START + SLOT_MS - 60_000;
first.start('b1');
first.activity();
advance(120_000, first);
await first.stop();
expect(sent).toEqual([]);
fail = false;
const second = new ReadingTimeTracker(sink, kv, () => clock, () => visible);
second.start('b2');
await second.stop();
const entries = sent.at(-1)!;
expect(entries.filter(entry => entry.bookId === 'b1').map(entry => [entry.slot, entry.seconds]))
.toEqual([[START, 60], [START + SLOT_MS, 60]]);
// The finished slot is forgotten once sent; the current one is kept to keep growing.
const stored = JSON.parse(kv.map.get('reader.readingTime.v1')!) as { entries: ReadingTimeEntry[] };
expect(stored.entries.map(entry => entry.slot)).toEqual([START + SLOT_MS]);
expect(kv.map.get('reader.clientId.v1')).toMatch(/^[0-9a-f-]{36}$/);
});
});