diff --git a/docs/requirements/handoff-HEL-880.json b/docs/requirements/handoff-HEL-880.json new file mode 100644 index 0000000..1fe85e3 --- /dev/null +++ b/docs/requirements/handoff-HEL-880.json @@ -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/,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 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 脚本通过" + ] +} diff --git a/web/src/adapters/inbox.ts b/web/src/adapters/inbox.ts index 98e9638..627f3c7 100644 --- a/web/src/adapters/inbox.ts +++ b/web/src/adapters/inbox.ts @@ -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 diff --git a/web/src/app/RealApp.tsx b/web/src/app/RealApp.tsx index 544af64..25758f0 100644 --- a/web/src/app/RealApp.tsx +++ b/web/src/app/RealApp.tsx @@ -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(() => { 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([]), held = useRef(incoming); held.current = incoming; + const cursor = useRef(startAt(0)), [incoming, setIncoming] = useState([]), held = useRef(incoming); held.current = incoming; const [seen, setSeen] = useState(0), [fresh, setFresh] = useState(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')); 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')); 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); diff --git a/web/tests/hel875_mock.mjs b/web/tests/hel875_mock.mjs index 3b062c5..e812bb4 100644 --- a/web/tests/hel875_mock.mjs +++ b/web/tests/hel875_mock.mjs @@ -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 . Control: POST-free GETs /__reach?text=, /__epoch, /__down/, /__log. +// Usage: node tests/hel875_mock.mjs . Control: POST-free GETs /__reach?text=, /__epoch, /__down/, /__bulk/, /__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 } }); diff --git a/web/tests/hel875_shots.mjs b/web/tests/hel875_shots.mjs index 90deb8c..6c2c2a0 100644 --- a/web/tests/hel875_shots.mjs +++ b/web/tests/hel875_shots.mjs @@ -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. ── diff --git a/web/tests/inbox.test.ts b/web/tests/inbox.test.ts index 568d306..27091d8 100644 --- a/web/tests/inbox.test.ts +++ b/web/tests/inbox.test.ts @@ -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(); 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();