HEL-880: preserve inbox pagination gaps until reach-outs are delivered

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-10-26 22:28:54 +08:00
co-authored by multica-agent
parent 1ae243f286
commit bf234570c0
6 changed files with 210 additions and 30 deletions
+62
View File
@@ -0,0 +1,62 @@
{
"baseline_version": "1.11",
"read_commit": "1ae243f286b14789289e0bdca77eec818074c879",
"task": "HEL-880 返工(父卡 HEL-875):补齐长时间离开页面后的主动留言续读——有界分页读取未覆盖旧游标时不再把游标推进到最新行,保留缺口并在后续读取中续读,所有主动留言按 id 顺序且仅一次进入;只改前端逻辑与测试/交接,不改界面、CSS、后端、已读政策,不增加推送",
"requirement_ids": [
"CG-008",
"CG-011",
"CG-018",
"CG-031",
"VIS-001",
"VIS-002",
"VIS-003",
"VIS-004",
"VIS-005",
"VIS-006",
"VIS-007",
"VIS-008",
"VIS-009",
"VIS-010",
"VIS-011",
"VIS-012",
"VIS-013",
"UX-001",
"UX-002",
"UX-005",
"UX-006",
"AM-001",
"GOV-001",
"GOV-002",
"GOV-003",
"GOV-004",
"GOV-005",
"GOV-006",
"DEL-001"
],
"deliverable": "自候选 hel-875-proactive-wiring @ 1ae243f 起的前端补丁(仅 web/ 与本交接):1) 缺陷:web/src/adapters/inbox.ts 的 fetchAfter 每次最多读 5 页×50 条,未读到旧游标就把本次见到的最新 id 作为 newest 返回,RealApp 随即推进游标,缺口内较早的主动留言永远不再读取(总管 fixture:页面持有 id=1,id=2 为主动留言,普通记录至 id=301,arrivals 为空)。另:after=0 时只读首页即停,超过 50 条同样漏读。2) 修复:游标改为 InboxCursor { through, gap }——through 以下全部已读并已交出;有界读取停在中途时 gap 记住下一次从哪一行(before)往下续读、已读到哪一行(top),以及途中读到的她的主动留言(held,只留主动行,内存有界)。下一次调用先沿 gap 往下续读,碰到 through 或记录开头即一次性按 id 顺序交出 held,through 推进到 top;若这个缺口是之前留下的,同一次调用再读一次最新页取新到的记录。读取失败或中止时整体抛出,不改调用方游标,下次从同一位置重来;不以扩大上限掩盖。较新的留言只等待尚欠的读取(每次 250 条,缺口未补齐时 RealApp 1 秒后续读,而非 20 秒),不会因最旧记录永久卡住。3) RealApp.tsx:游标存 InboxCursor,成功才提交;缺口存在时 1 秒后续读,失败退避与隐藏时停读不变。去重、排序(arrivals 不变,交出顺序随 through 单调递增)、交谈中排队不抢断、草稿、未读规则 cg.real.seen、演示入口隔离均未改。ChatPanel、CSS、后端、服务、档案均未改;构建 CSS 产物仍为 index-DdDYzR5S.css。4) 测试:web/tests/inbox.test.ts 改用新游标并新增 6 项(总管原 fixture 精确复现、1200 条散布 13 句主动留言、after=0 超过 50 条、分页第 3 页失败后恢复、追读期间又有新消息、重复读取/epoch 变化/页面已持有不重复);web/tests/hel875_mock.mjs 增 /__bulk/<n>,hel875_shots.mjs 增 8b 长时间离开步骤(2 项)。",
"acceptance": [
"总管 fixture(持有 id=1;id=2 主动留言;普通记录至 301):首次读取不交出、游标保留缺口 before=52/top=301;第二次读取交出 id=2,游标推进至 301(inbox.test「HEL-875 review fixture」)",
"超过 250 条:1200 条中散布的 13 句主动留言全部按 id 顺序各进入一次,追平后再读不新增(inbox.test)",
"after=0 且超过 50 条:读过首页继续续读,两句主动留言全部进入(inbox.test)",
"分页中途失败:调用抛出、页面游标与失败前完全一致;恢复后补齐全部留言(inbox.test)",
"分页途中又有新消息:先交出缺口内较早的,再交出新到的,各一次(inbox.test)",
"重复读取同一游标结果一致、epoch 变化不影响、页面已持有的不再加入(inbox.test)",
"无头 Edge:长时间离开(中间 320 条普通记录)回到页面后此刻留言卡显示「2 句还没看」,记录里各一句、较早者在前并定位到较早一句(浏览器检查 8b);原 25 项检查全部仍通过",
"工程:tsc 0 错;eslint src 0 错(1 条既有警告,非本卡);vitest 6 文件 55/55(原 49 + 新 6);vite build 通过"
],
"deferred": [
"原版 Python 需求检查未在本运行时执行(无 Python、无 git);须总管/总工在有 Python 的环境对 origin/hel-870-assembly、origin/main 与原产品目标 agent/agent/de8874a3803b 含/不含本交接复跑",
"真机手机与 Safari 未测;手机为 390×844 等效视口(UX-001)",
"缺口追读期间较新的主动留言要等缺口补齐才出现(每秒续读 250 条;例如离开期间 1 万条约需 40 秒);若需即时到达须后端推送或按 id 正向分页(after 参数),属后端合同变更,本卡未做",
"页面若在缺口补齐前关闭或刷新,重开时按完整记录加载,不丢失;但 held 不跨页面保存",
"HEL-875 既有承接项不变:未读标记只存本浏览器、20 秒轮询、等待回复期间的留言顺序说明、日子页 .reach 位置等,见 handoff-HEL-875.json",
"R5 语音、R6 天气/素材/备份、R7 有条件视觉观察仍由主卡 HEL-797 承接;用户整体沉浸体验验收由白栖知决定"
],
"requirement_changes": [],
"verification": [
"实际读取提交 1ae243f286b14789289e0bdca77eec818074c879(gitea 归档下载 zip SHA256 E55C5E95B7DF575D56E305961E29A85B46CCBE963818271754208641C1FCE87C);已读 AGENTS.md、docs/requirements/README.md、current.md v1.11、coverage.md、changes.md、sources/integration-plan-v1.md、handoff-HEL-875.json 及后端 GET /api/messages(api/app.py:id < before 倒序、limit+1 探测、next_before 为页内最旧 id)",
"web:tsc --noEmit 0;eslint src 0 错;vitest run 55/55;vite build 成功(node_modules 自 HEL-875 同一 package-lock 树离线复制,无新增依赖、无下载)",
"浏览器:node tests/hel875_mock.mjs 8880 + node tests/hel875_shots.mjs <msedge.exe> <out> 8880,27/27 通过(含新增 8b 两项)",
"需求结构检查:仅用 HEL-875 留下的 Node 移植版作辅助参考(基准文件经 gitea 只读取得)——origin/main、agent/agent/de8874a3803b、hel-870-assembly@31970c4(本候选实际基点)各含/不含本交接均通过;对 hel-870-assembly 现头 e978e878 失败,原因是该分支在候选之后合入 HEL-874、追加了来源 comment-evidence-full-experience-20261026.json,候选尚未包含,与本补丁无关,合并前需先同步该分支。不代表原 Python 脚本通过"
]
}
+24 -11
View File
@@ -10,21 +10,34 @@ export const serverId = (m: Message) => /^[1-9]\d{0,18}$/u.test(m.id) ? Number(m
export const newestId = (messages: Message[]) => messages.reduce((top, m) => Math.max(top, serverId(m)), 0);
const newestProactive = (messages: Message[]) => messages.reduce((top, m) => m.proactive ? Math.max(top, serverId(m)) : top, 0);
// Rows newer than `after`, oldest first. Pages back only as far as needed (a long absence on a sleeping tab), bounded.
export async function fetchAfter(after: number, signal: AbortSignal, pages = 5): Promise<{ rows: Message[]; newest: number }> {
// Where reading stands. Every row up to `through` has been read and handed over. The server only pages backwards from
// the newest row, so after a long absence one bounded read may stop short: then `gap` remembers it. Rows from `before`
// up to `top` are read, and her reach-outs among them are `held`; rows between `through` and `before` are not read yet.
export interface InboxCursor { through: number; gap: { before: number; top: number; held: Message[] } | null }
export const startAt = (through: number): InboxCursor => ({ through, gap: null });
// Her reach-outs newer than the cursor, oldest first, and where the next read starts. At most `pages` pages per call.
// A read that stops short hands over nothing yet: the next call goes on down from where it stopped (before looking for
// anything newer), and when it meets `through` everything held is handed over at once, in id order, exactly once. So an
// older reach-out is never skipped, and newer ones wait only for the reads still owed, never for good. A failed or
// aborted read throws and leaves the cursor it was given untouched; the same call simply runs again.
export async function fetchAfter(cursor: InboxCursor, signal: AbortSignal, pages = 5): Promise<{ rows: Message[]; cursor: InboxCursor }> {
const rows: Message[] = [];
let before: number | null = null, newest = after;
let through = cursor.through, gap = cursor.gap && { ...cursor.gap, held: [...cursor.gap.held] }, carried = !!gap;
for (let i = 0; i < pages; i++) {
const page: MessagePage = await api(`messages?limit=50${before ? `&before=${before}` : ''}`, { signal: within(signal, 10000) });
const page: MessagePage = await api(`messages?limit=50${gap ? `&before=${gap.before}` : ''}`, { signal: within(signal, 10000) });
const list = Array.isArray(page.messages) ? page.messages.filter(m => Number.isSafeInteger(m?.id) && m.id > 0 && typeof m.text === 'string') : [];
for (const m of list) newest = Math.max(newest, m.id);
const fresh = list.filter(m => m.id > after);
rows.unshift(...fresh.map(toMessage));
// Reached what the page already holds (or the start of the record): stop.
if (fresh.length < list.length || !page.next_before || !after) break;
before = page.next_before;
const fresh = list.filter(m => m.id > through), opened = !gap;
gap ??= { before: 0, top: list.reduce((top, m) => Math.max(top, m.id), through), held: [] };
gap.held.unshift(...fresh.filter(m => m.proactive === true).map(toMessage));
// Not yet down to what was read before, and more of the record below: keep the gap for the next page or call.
if (fresh.length === list.length && page.next_before && Number.isSafeInteger(page.next_before)) { gap.before = page.next_before; continue; }
rows.push(...gap.held); through = gap.top; gap = null;
// A gap left by an earlier call is closed; look once for anything newer. Otherwise the newest rows were read.
if (opened || !carried) break;
carried = false;
}
return { rows, newest };
return { rows, cursor: { through, gap } };
}
// What may be added from a poll: her own reach-out rows the page does not hold yet. Your own turns (and her replies to
+9 -8
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api, lifeStatus, loadReal, memoryNote, type Health } from '../adapters/real';
import { arrivals, fetchAfter, initialSeen, newestId, seenThrough, storeSeen, unreadOf } from '../adapters/inbox';
import { arrivals, fetchAfter, initialSeen, newestId, seenThrough, startAt, storeSeen, unreadOf } from '../adapters/inbox';
import type { DemoState, Message, Theme } from '../domain/model';
import { Presenter } from '../stage/Presenter';
import { CreationWizard } from '../manage/CreationWizard';
@@ -17,14 +17,14 @@ export function RealApp() {
const [theme, setTheme] = useState<Theme>(() => { try { return localStorage.getItem('cg.theme') === 'fog' ? 'fog' : 'moon'; } catch { return 'moon'; } });
const [herFocus, setHerFocus] = useState(0);
const latest = useRef(state); latest.current = state;
// ── Her reach-outs while the page is open (inbox.ts). `cursor` is the newest server row this page has read;
// ── Her reach-outs while the page is open (inbox.ts). `cursor` is how far this page has read the server record;
// `incoming` holds what arrived during a conversation until the stage lets it in; `seen` is the unread mark. ──
const cursor = useRef(0), [incoming, setIncoming] = useState<Message[]>([]), held = useRef(incoming); held.current = incoming;
const cursor = useRef(startAt(0)), [incoming, setIncoming] = useState<Message[]>([]), held = useRef(incoming); held.current = incoming;
const [seen, setSeen] = useState(0), [fresh, setFresh] = useState<Message | null>(null), [visible, setVisible] = useState(!document.hidden);
const areaNow = useRef(area); areaNow.current = area;
const initialize = useCallback(async () => {
setError(''); setReady(false);
try { setHealth(await api<Health>('health')); const next = await loadReal(); cursor.current = next ? newestId(next.messages) : 0; setSeen(next ? initialSeen(next.messages) : 0); setState(next); setCreating(!next); setReady(true); }
try { setHealth(await api<Health>('health')); const next = await loadReal(); cursor.current = startAt(next ? newestId(next.messages) : 0); setSeen(next ? initialSeen(next.messages) : 0); setState(next); setCreating(!next); setReady(true); }
catch (e) { setError((e as Error).message || '暂时连不上真实服务。'); }
}, []);
useEffect(() => { void initialize(); }, [initialize]);
@@ -35,7 +35,8 @@ export function RealApp() {
useEffect(() => { document.documentElement.classList.toggle('reduced', !!state?.settings.reduced); }, [state?.settings.reduced]);
const changeMessages = useCallback((messages: Message[]) => { if (latest.current) { const next = { ...latest.current, messages }; latest.current = next; setState(next); } }, []);
// Read the newest rows every 20 s while the page is shown, at once on return or reconnect. A failed read is quiet
// (the next one simply tries again, slower); nothing on screen changes until a new row actually exists.
// (the next one simply tries again, slower); nothing on screen changes until a new row actually exists. A read that
// stopped short of what was read before goes on after a second, until it has caught up.
const live = !!state && !creating && ready;
useEffect(() => {
if (!live) return;
@@ -44,16 +45,16 @@ export function RealApp() {
if (running || stopped || document.hidden) return;
running = true; clearTimeout(timer); controller = new AbortController();
try {
const { rows, newest } = await fetchAfter(cursor.current, controller.signal);
const { rows, cursor: next } = await fetchAfter(cursor.current, controller.signal);
if (stopped) return;
cursor.current = Math.max(cursor.current, newest); failures = 0;
cursor.current = next; failures = 0;
const add = latest.current ? arrivals([...latest.current.messages, ...held.current], rows) : [];
if (add.length && latest.current) {
if (areaNow.current === 'talk') setIncoming(list => [...list, ...add]);
else { changeMessages([...latest.current.messages, ...add]); setFresh(add[add.length - 1]); }
}
} catch { failures++; }
finally { running = false; if (!stopped) timer = setTimeout(() => void poll(), failures ? Math.min(120000, 20000 * 2 ** failures) : 20000); }
finally { running = false; if (!stopped) timer = setTimeout(() => void poll(), failures ? Math.min(120000, 20000 * 2 ** failures) : cursor.current.gap ? 1000 : 20000); }
};
const wake = () => { setVisible(!document.hidden); if (!document.hidden) void poll(); };
document.addEventListener('visibilitychange', wake); addEventListener('online', wake);
+3 -1
View File
@@ -2,7 +2,7 @@
// contract of the HEL-870 backend: GET /api/messages pages newest-first by row id (limit+1 probe, rows oldest-first,
// next_before) and marks only her reach-out rows proactive:true (api/app.py, life/public.py). Synthetic data only:
// no model, no embeddings, no network.
// Usage: node tests/hel875_mock.mjs <port>. Control: POST-free GETs /__reach?text=, /__epoch, /__down/<n>, /__log.
// Usage: node tests/hel875_mock.mjs <port>. Control: POST-free GETs /__reach?text=, /__epoch, /__down/<n>, /__bulk/<n>, /__log.
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
@@ -31,6 +31,8 @@ createServer(async (req, res) => {
if (p === '/__log') return send(res, 200, log.splice(0));
if (p === '/__rows') return send(res, 200, rows.map(dto));
if (p === '/__reach') { const id = add({ role: 'companion', text: url.searchParams.get('text') || '窗外的雨停了,想跟你说一声。', turn_id: `reach-${rows.length + 1}`, proactive: true }); return send(res, 200, { id }); }
// HEL-880: a long absence elsewhere — n plain turns (no reach-outs) land at once.
if (p.startsWith('/__bulk/')) { const n = Number(p.slice(8)); for (let i = 0; i < n; i++) add({ role: i % 2 ? 'companion' : 'user', text: `别处的第${i + 1}句`, turn_id: `bulk-${rows.length + 1}` }); return send(res, 200, { top: rows.length }); }
if (p === '/__epoch') { epoch++; return send(res, 200, { epoch }); }
if (p.startsWith('/__down/')) { down = Number(p.slice(8)); return send(res, 200, { down }); }
if (p === '/api/health') return send(res, 200, { ok: true, version: 'R4-assembly', epoch, llm: { ok: true, model: 'mock' }, backend: { life: true, persona: true, receipt: true, web_connected: true, mood: true, reach_out: true, semantic: false, semantic_backend: null }, life_runtime: { state: 'running', timezone: 'Asia/Shanghai', budget_warning: false, budget_stopped: false, last: null }, capabilities: { text: true, proactive: true, life: false, voice: false, vision: false, weather: false, materials: false, backup: false, memory: true, semantic_memory: null } });
+13
View File
@@ -119,6 +119,19 @@ await click('.home .letter'); await sleep(1300);
check('several unread open the record at the first', await js(`!!document.querySelector('.log-panel:not([hidden]) .message.hl')`) && (await text('.log-panel .message.hl')).includes('书签'));
await click('.log-panel .btn-round'); await sleep(500); await click('.nav-item', '此刻'); await sleep(900);
check('all seen after opening the record', !(await exists('.nav-item.unread')));
// ── 8b. HEL-880: back after a long absence — more rows than one bounded read (5 × 50). The older reach-out is not
// skipped; both arrive once, in order, after the follow-up reads. ──
await ctl('/__reach?text=' + encodeURIComponent('你走之后我把灯调暗了一点。')); await ctl('/__bulk/320');
await ctl('/__reach?text=' + encodeURIComponent('回来了就好。')); await ctl('/__log');
await read(); await sleep(2500);
const caught = await text('.home .letter'), reads = (await ctl('/__log')).filter(l => l.includes('/api/messages')).length;
check('long absence: the older reach-out is not skipped; both counted once', caught.includes('2 句还没看') && caught.includes('回来了就好'), `${caught.replace(/\n/g, ' ')} | ${reads} reads`);
await click('.home .letter'); await sleep(1300);
const absent = await text('.log-panel .scroll');
check('long absence: record holds each once, older one first and opened at', absent.split('灯调暗').length === 2 && absent.split('回来了就好').length === 2 && absent.indexOf('灯调暗') < absent.indexOf('回来了就好') && (await text('.log-panel .message.hl')).includes('灯调暗'));
await shot('08b-moon-desktop-long-absence');
await click('.log-panel .btn-round'); await sleep(500); await click('.nav-item', '此刻'); await sleep(900);
check('no microphone, camera or notification request', await js('window.__asked') === 0);
// ── 9. Evidence: both themes, desktop and phone, with a fresh reach-out on home and in the days pane. ──
+99 -10
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { arrivals, fetchAfter, initialSeen, newestId, seenThrough, storeSeen, storedSeen, unreadOf } from '../src/adapters/inbox';
import { arrivals, fetchAfter, initialSeen, newestId, seenThrough, startAt, storeSeen, storedSeen, unreadOf, type InboxCursor } from '../src/adapters/inbox';
import { loadMessages, type ServerMessage } from '../src/adapters/real';
import type { Message } from '../src/domain/model';
@@ -20,20 +20,29 @@ class Archive {
}
function storage() { const data = new Map<string, string>(); vi.stubGlobal('localStorage', { getItem: (k: string) => data.has(k) ? data.get(k)! : null, setItem: (k: string, v: string) => void data.set(k, v) }); return data; }
const signal = () => new AbortController().signal;
// One poll the way RealApp runs it: read after the cursor, keep only what the page does not hold.
async function poll(page: { messages: Message[]; cursor: number }) {
const { rows, newest } = await fetchAfter(page.cursor, signal());
page.cursor = Math.max(page.cursor, newest);
// One poll the way RealApp runs it: read after the cursor, commit it only when the read succeeded, keep only what the
// page does not hold.
type Page = { messages: Message[]; cursor: InboxCursor };
async function poll(page: Page) {
const { rows, cursor } = await fetchAfter(page.cursor, signal());
page.cursor = cursor;
const add = arrivals(page.messages, rows); page.messages = [...page.messages, ...add];
return add;
}
// Polls again while a gap is open, as RealApp does a second later; returns everything that arrived, in order.
async function catchUp(page: Page, most = 50) {
const all: Message[] = [];
for (let i = 0; i < most; i++) { all.push(...await poll(page)); if (!page.cursor.gap) return all; }
throw new Error('never caught up');
}
const opened = (messages: Message[]): Page => ({ messages, cursor: startAt(newestId(messages)) });
afterEach(() => vi.unstubAllGlobals());
describe('reach-outs reaching an open page', () => {
it('adds a new reach-out once; repeated reads, a restarted epoch and a reconnect add nothing more', async () => {
const server = new Archive(); vi.stubGlobal('fetch', server.fetch);
server.add({ role: 'user', turn_id: 'a' }); server.add({ role: 'companion', turn_id: 'a' });
const page = { messages: await loadMessages(), cursor: 0 }; page.cursor = newestId(page.messages);
const page = opened(await loadMessages());
expect(await poll(page)).toEqual([]);
const id = server.reach();
const first = await poll(page);
@@ -48,17 +57,17 @@ describe('reach-outs reaching an open page', () => {
it('never re-adds your own turn or her reply to it, and ignores rows still being written', async () => {
const server = new Archive(); vi.stubGlobal('fetch', server.fetch);
// The page holds your message and her streamed reply under local ids; the server has them under row ids.
const page: { messages: Message[]; cursor: number } = { messages: [{ id: 'local-1', role: 'user', text: '在吗', lifeIds: [], turnId: 'turn-x', status: 'sent' }, { id: 'local-2', role: 'companion', text: '在', lifeIds: [], turnId: 'turn-x' }], cursor: 0 };
const page = opened([{ id: 'local-1', role: 'user', text: '在吗', lifeIds: [], turnId: 'turn-x', status: 'sent' }, { id: 'local-2', role: 'companion', text: '在', lifeIds: [], turnId: 'turn-x' }]);
server.add({ role: 'user', turn_id: 'turn-x', text: '在吗' }); server.add({ role: 'companion', turn_id: 'turn-x', text: '在' });
server.add({ role: 'user', turn_id: 'turn-y', status: 'pending' });
server.add({ role: 'companion', proactive: true, turn_id: 'turn-x' });
expect(await poll(page)).toEqual([]);
expect(page.cursor).toBe(4);
expect(page.cursor).toEqual(startAt(4));
});
it('pages back after a long absence and keeps the order of what arrived', async () => {
const server = new Archive(); vi.stubGlobal('fetch', server.fetch);
server.add({ role: 'user' });
const page = { messages: await loadMessages(), cursor: 1 };
const page = opened(await loadMessages());
const ids = [server.reach('一')]; for (let i = 0; i < 70; i++) server.add({ role: 'companion' }); ids.push(server.reach('二'));
const added = await poll(page);
expect(added.map(m => m.text)).toEqual(['一', '二']);
@@ -67,13 +76,93 @@ describe('reach-outs reaching an open page', () => {
});
it('reads from an empty record without paging the whole history', async () => {
const server = new Archive(); vi.stubGlobal('fetch', server.fetch);
const page = { messages: [] as Message[], cursor: 0 };
const page = opened([]);
server.reach();
expect(await poll(page)).toHaveLength(1);
expect(server.calls).toHaveLength(1);
});
});
// HEL-880: a sleeping tab can come back to more rows than one bounded read covers (5 pages × 50).
describe('catching up after a long absence', () => {
it('keeps the oldest reach-out when more than five pages arrived (HEL-875 review fixture)', async () => {
// Exactly the reviewer's case: the page holds id 1; id 2 is her reach-out; plain rows follow up to id 301.
const server = new Archive(); vi.stubGlobal('fetch', server.fetch);
server.add({ role: 'user' });
const page = opened(await loadMessages());
server.reach('最早的那句'); while (server.rows.length < 301) server.add({ role: 'companion' });
const first = await fetchAfter(page.cursor, signal());
expect(first.rows).toEqual([]);
expect(first.cursor).toMatchObject({ through: 1, gap: { before: 52, top: 301 } });
const second = await fetchAfter(first.cursor, signal());
expect(arrivals(page.messages, [...first.rows, ...second.rows]).map(m => m.id)).toEqual(['2']);
expect(second.cursor).toEqual(startAt(301));
});
it('more than 250 rows with reach-outs spread through them: each arrives once, in order, nothing skipped', async () => {
const server = new Archive(); vi.stubGlobal('fetch', server.fetch);
server.add({ role: 'user' });
const page = opened(await loadMessages()), ids: number[] = [];
for (let i = 0; i < 1200; i++) { if (i % 97 === 3) ids.push(server.reach(`第${i}句`)); else server.add({ role: i % 2 ? 'companion' : 'user' }); }
const added = await catchUp(page);
expect(added.map(m => m.id)).toEqual(ids.map(String));
expect(page.cursor).toEqual(startAt(server.rows.length));
expect(await poll(page)).toEqual([]);
expect(page.messages.filter(m => m.proactive)).toHaveLength(ids.length);
});
it('an empty page (cursor 0) facing more than 50 rows reads past the first page', async () => {
const server = new Archive(); vi.stubGlobal('fetch', server.fetch);
const page = opened([]);
const ids = [server.reach('一')]; for (let i = 0; i < 120; i++) server.add({ role: 'user' }); ids.push(server.reach('二'));
expect((await catchUp(page)).map(m => m.id)).toEqual(ids.map(String));
expect(server.calls).toHaveLength(3);
});
it('a read that fails mid-way commits nothing; the next one resumes from the same place', async () => {
const server = new Archive(); vi.stubGlobal('fetch', server.fetch);
server.add({ role: 'user' });
const page = opened(await loadMessages());
const ids = [server.reach('一')]; for (let i = 0; i < 400; i++) server.add({ role: 'user' }); ids.push(server.reach('二'));
expect(await poll(page)).toEqual([]);
const kept = structuredClone(page.cursor);
// Two pages succeed, the third fails: the call throws and the page keeps the cursor it had.
let left = 2; const ok = server.fetch.getMockImplementation()!;
server.fetch.mockImplementation(async (url: string) => { if (left-- <= 0) throw new TypeError('Failed to fetch'); return ok(url); });
await expect(poll(page)).rejects.toThrow();
expect(page.cursor).toEqual(kept);
server.fetch.mockImplementation(ok);
expect((await catchUp(page)).map(m => m.id)).toEqual(ids.map(String));
});
it('rows that arrive while it is catching up are read too, after the older ones, once', async () => {
const server = new Archive(); vi.stubGlobal('fetch', server.fetch);
server.add({ role: 'user' });
const page = opened(await loadMessages());
const ids = [server.reach('久别前')]; for (let i = 0; i < 600; i++) server.add({ role: 'companion' });
expect(await poll(page)).toEqual([]);
ids.push(server.reach('追读时新来的')); for (let i = 0; i < 300; i++) server.add({ role: 'user' }); ids.push(server.reach('最新的'));
const added = await catchUp(page);
expect(added.map(m => m.id)).toEqual(ids.map(String));
expect(page.cursor).toEqual(startAt(server.rows.length));
});
it('repeated reads and a restarted epoch during catch-up add nothing twice', async () => {
const server = new Archive(); vi.stubGlobal('fetch', server.fetch);
server.add({ role: 'user' });
const page = opened(await loadMessages());
const ids = [server.reach()]; for (let i = 0; i < 300; i++) server.add({ role: 'user' }); ids.push(server.reach());
const first = await fetchAfter(page.cursor, signal());
// The same cursor read twice (a retried poll) gives the same answer; the epoch changing does not matter.
server.epoch = 2;
const again = await fetchAfter(page.cursor, signal());
expect(again.cursor).toEqual(first.cursor);
page.cursor = first.cursor;
const added = await catchUp(page);
expect(arrivals(added, added)).toEqual([]);
expect(added.map(m => m.id)).toEqual(ids.map(String));
expect(await poll(page)).toEqual([]);
// A reach-out the page already holds (it came in through a reload) is not added again.
const record = await loadMessages(), page2: Page = { messages: [record[0], record.find(m => m.id === String(ids[1]))!], cursor: startAt(1) };
expect((await catchUp(page2)).map(m => m.id)).toEqual([String(ids[0])]);
});
});
describe('unread', () => {
it('first visit from this browser counts the old record as seen; later arrivals stay unread until seen', () => {
const data = storage();