// The stream against the query cache: a mock network answers the reads, a stand-in transport // delivers the frames. Every case here is a defect that is invisible from either side alone — the // frame handler looks correct next to the reads, and the reads look correct next to the frames. // // The world is local rather than taken from `src/mock/`: these tests turn on WHEN a read is made // and at which revision it answers, and a shared fixture would decide both. import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'; import { render, waitFor } from '@testing-library/react'; import { HttpResponse, delay, http } from 'msw'; import { setupServer } from 'msw/node'; import { afterAll, afterEach, beforeAll, beforeEach, expect, test } from 'vitest'; import { bankQuery, bookQuery, chaptersQuery, keys, libraryQuery, unitsQuery, usageQuery, } from '../api'; import type { BookDetail, ChapterList, UnitList } from '../api'; import type { components } from '../api/schema'; import { useRunStream } from './useRunStream'; type Schemas = components['schemas']; const bookId = 'bk_1'; const runId = 'run_1'; /** The book's revision, moved by hand: a frame and a read are ordered against each other by it. */ let revision = 1841; const asked: string[] = []; const askedFor = (path: string) => asked.filter((seen) => seen === path).length; const book: Schemas['Book'] = { id: bookId, title: 'Gu Zhenren', source_lang: 'zh', target_lang: 'ru', chapter_count: 2, added_at: '2026-08-01T00:00:00Z', status: 'translating', progress: { draft: { done: 3, total: 10 }, edit: { done: 0, total: 10 } }, note_count: 0, }; const chapter = (id: string): Schemas['Chapter'] => ({ id, number: 1, heading: id, units_total: 2, units_done: 0, note_count: 0, }); const run: Schemas['Run'] = { id: runId, revision: 1841, status: 'translating', verify_bank: true, ceiling_chapters: 60, paused_reason: null, started_at: '2026-08-01T00:00:00Z', }; const unit: Schemas['Unit'] = { id: 'u_1', source: '第一节', target: 'First', state: 'translated', }; const server = setupServer( http.get('*/v0/books', () => HttpResponse.json({ revision, next_cursor: null, books: [book] }), ), http.get('*/v0/books/:bookId', () => HttpResponse.json({ revision, book, run: { ...run, revision } }), ), http.get('*/v0/books/:bookId/chapters', () => HttpResponse.json({ revision, next_cursor: null, chapters: [chapter('ch_1'), chapter('ch_2')], }), ), http.get('*/v0/books/:bookId/chapters/:chapterId/units', () => HttpResponse.json({ revision, next_cursor: null, units: [unit] }), ), http.get('*/v0/books/:bookId/bank', () => HttpResponse.json({ revision, next_cursor: null, total: 1, signed: 0, terms: [ { id: 't_1', src: '方源', dst: 'Fang Yuan', kind: 'name', status: 'approved', origin: 'seed', sense: '', since_chapter: 0, until_chapter: 0, }, ], }), ), http.get('*/v0/usage', () => HttpResponse.json({ state: 'ok', remaining_percent: 62, paused_reason: null, }), ), ); beforeAll(() => { server.listen({ onUnhandledRequest: 'error' }); server.events.on('request:start', ({ request }) => asked.push(new URL(request.url).pathname)); Reflect.set(globalThis, 'EventSource', FakeEventSource); }); afterAll(() => { server.close(); Reflect.deleteProperty(globalThis, 'EventSource'); }); beforeEach(() => { revision = 1841; asked.length = 0; FakeEventSource.opened = 0; }); afterEach(() => { // Unmounting closes the subscription, and closing it forgets the stream's high-water mark — // a mark left behind would make the next test's frames look stale. for (const close of mounted.splice(0)) close(); server.resetHandlers(); }); /** The transport, replaced: the test DOM has no `EventSource` at all (measured — no global). */ class FakeEventSource { static latest: FakeEventSource | null = null; static opened = 0; readonly CONNECTING = 0; readonly OPEN = 1; readonly CLOSED = 2; readyState = 0; onerror: (() => void) | null = null; private readonly listeners = new Map) => void)[]>(); constructor() { FakeEventSource.latest = this; FakeEventSource.opened += 1; } addEventListener(name: string, listener: (message: MessageEvent) => void): void { this.listeners.set(name, [...(this.listeners.get(name) ?? []), listener]); } close(): void { this.readyState = this.CLOSED; } emit(name: string, data: unknown, id: number): void { const message = { data: JSON.stringify(data), lastEventId: String(id), } as MessageEvent; for (const listener of this.listeners.get(name) ?? []) listener(message); } } /** Emits a frame at a revision the server has already moved to — the order a real run has. */ function emit(name: string, data: unknown, at = revision): void { revision = at; (FakeEventSource.latest as FakeEventSource).emit(name, data, at); } const hello = () => { emit('hello', { contract: '0.2.0', run_id: runId, revision }, revision); }; /** * The screen as the stream sees it: a live subscription plus the reads that are open at the same * time. Two chapters, because "re-read the chapter the frame names" and "re-read everything" are * indistinguishable with one. */ function Screen({ chapters: open }: { chapters: string[] }) { useRunStream(bookId, runId); useQuery(bookQuery(bookId)); useQuery(libraryQuery()); useQuery(chaptersQuery(bookId)); useQuery(bankQuery(bookId)); useQuery(usageQuery()); return ( <> {open.map((chapterId) => ( ))} ); } function Chapter({ chapterId }: { chapterId: string }) { const units = useQuery(unitsQuery(bookId, chapterId)); return

{units.data?.units.length ?? 0}

; } /** * The same screen, wired the way the real one is: the run id arrives INSIDE the book detail. That * wiring is what makes emptying the detail a question about the STREAM and not only about the data, * and it is the only shape in which the restart below can happen at all. */ function WiredScreen() { const detail = useQuery(bookQuery(bookId)); useRunStream(bookId, detail.data?.run?.id); useQuery(chaptersQuery(bookId)); return null; } const mounted: (() => void)[] = []; function mount(open: string[] = [], screen = ) { // Retries off: a failing read would otherwise be answered three times and the counts below would // measure the retry policy instead of the invalidation. const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const view = render({screen}); mounted.push(() => { view.unmount(); client.clear(); }); return client; } const settled = (client: QueryClient) => waitFor(() => { expect(client.isFetching()).toBe(0); }); const unitsOf = (id: string) => `/v0/books/${bookId}/chapters/${id}/units`; // Ф-49. The open chapter sits on a keep-alive tab: it never unmounts, so none of the three triggers // of `staleTime` (focus, reconnect, mount) ever fires and nothing else asks for the text again. test('a chapter frame re-reads THAT chapter and leaves the others alone', async () => { const client = mount(['ch_1', 'ch_2']); await settled(client); expect(askedFor(unitsOf('ch_1'))).toBe(1); hello(); emit('chapter', { chapter_id: 'ch_1', units_done: 2, note_count: 1 }, 1850); await waitFor(() => { expect(askedFor(unitsOf('ch_1'))).toBe(2); }); await settled(client); expect(askedFor(unitsOf('ch_2'))).toBe(1); // The counters of the frame land on the chapter list at the frame's revision, as before. const list = client.getQueryData(keys.chapters(bookId)); expect(list).toMatchObject({ revision: 1850 }); expect(list?.chapters[0]).toMatchObject({ units_done: 2, note_count: 1 }); }); // Ф-49, second half. While `units_done` stays a single counter (contract, K-10) a whole draft wave // can pass without one chapter frame, and the text is written at the stage boundary — which arrives // as a status frame and as nothing else. test('a status frame re-reads the text of every open chapter', async () => { const client = mount(['ch_1', 'ch_2']); await settled(client); hello(); emit('status', { status: 'awaiting_bank', paused_reason: null }, 1850); await waitFor(() => { expect(askedFor(unitsOf('ch_1'))).toBe(2); expect(askedFor(unitsOf('ch_2'))).toBe(2); }); }); // S4. The same status stands in TWO answers — the book's card and the row of the library — and the // tree's badge is the one the eye reads first. Patching only the card left the badge saying "queued" // under a run that had already halted (seen on the frame of a ceiling stop). // Acceptance of S3.7, finding 8: the counters of every chapter move at a stage boundary too, and // the frame that would patch a single row may not arrive at all while `units_done` is one counter. test('a status frame re-reads the chapter list, whose counters move at the same boundary', async () => { const client = mount(); await settled(client); expect(askedFor(`/v0/books/${bookId}/chapters`)).toBe(1); hello(); emit('status', { status: 'awaiting_bank', paused_reason: null }, 1850); await waitFor(() => { expect(askedFor(`/v0/books/${bookId}/chapters`)).toBe(2); }); }); test('a status frame re-reads the library, where the same status is a row', async () => { const client = mount(); await settled(client); expect(askedFor('/v0/books')).toBe(1); hello(); emit('status', { status: 'paused', paused_reason: 'credit_exhausted' }, 1850); await waitFor(() => { expect(askedFor('/v0/books')).toBe(2); }); }); // Ф-50. The window is every screen start and every resync: the frame arrives while the first read // is still in flight, so there is nothing in the cache to patch — and the stream has already moved // its high-water mark, so the answer prepared BEFORE the frame is taken as fresh and the frame is // gone without a trace. test('a frame that finds no snapshot yet leaves a trace instead of vanishing', async () => { const client = mount(); hello(); emit('status', { status: 'paused', paused_reason: 'credit_exhausted' }, 1850); emit('chapter', { chapter_id: 'ch_1', units_done: 2, note_count: 1 }, 1851); await settled(client); // Two reads of each, not one: the first answer was prepared before the frame and cannot carry // it, so the frame's own re-read is what puts the new state on the screen. expect(askedFor(`/v0/books/${bookId}`)).toBe(2); expect(askedFor(`/v0/books/${bookId}/chapters`)).toBe(2); }); // The same window, the frames that only SAY "this changed": a note, a bank rebuild, a ceiling halt. // They were left on `invalidateQueries` by the first pass of this fix and vanished exactly as the // patched ones did — found by the adversarial review, not by me. test('a frame that only says "this changed" leaves the same trace', async () => { const client = mount(); hello(); emit('bank', { total: 60, signed: 12, pending_decisions: 48 }, 1850); emit('ceiling', { halted: true }, 1851); await settled(client); expect(askedFor(`/v0/books/${bookId}/bank`)).toBe(2); expect(askedFor(`/v0/books/${bookId}`)).toBe(2); }); // The cost of that trace, measured rather than assumed: the server MAY coalesce, so frames arrive // in bursts, and a burst landing on the same empty entry must not restart its read once per frame. test('a burst of frames on an empty cache costs one re-read, not one per frame', async () => { const client = mount(); hello(); for (let step = 0; step < 5; step += 1) { emit('progress', { progress: book.progress }, 1850 + step); } await settled(client); expect(askedFor(`/v0/books/${bookId}`)).toBe(2); }); // Acceptance of S3.7, finding 2: the mark says "a re-read of this key is on its way", and it was // never taken off. A read that FAILS leaves the entry empty, so from then on every frame for that // key found the mark and returned — the key was silenced for the rest of the run, on a screen whose // reads had recovered long ago. test('a key whose re-read failed is not silenced for the rest of the run', async () => { let refuse = true; server.use( http.get('*/v0/books/:bookId/bank', () => { if (refuse) return HttpResponse.json({ title: 'no' }, { status: 503 }); return HttpResponse.json({ revision, next_cursor: null, total: 1, signed: 1, terms: [], }); }), ); const client = mount(); await settled(client); const before = askedFor(`/v0/books/${bookId}/bank`); hello(); emit('bank', { total: 60, signed: 12, pending_decisions: 48 }, 1850); await settled(client); refuse = false; // The second frame has to reach the network. On the pre-fix code the mark from the first one was // still there, and this read never happened. emit('bank', { total: 60, signed: 13, pending_decisions: 47 }, 1851); await waitFor(() => { expect(askedFor(`/v0/books/${bookId}/bank`)).toBeGreaterThan(before + 1); }); }); // Acceptance of S3.7, finding 3: the marks belong to an epoch. A resync throws the snapshots away, // and a mark left from before it eats the first frame of the new epoch — exactly in the window // where the client has nothing else to go on. test('a resync clears the marks with the snapshots', async () => { // The read is SLOW on purpose: the window this guards is the one where the resync's own re-read // has not landed yet, so the cache is empty and the mark is the only thing deciding. With a fast // read the data arrives first and the mark is bypassed — the test would pass on the defect. server.use( http.get('*/v0/books/:bookId/bank', async () => { await delay(120); return HttpResponse.json({ revision, next_cursor: null, total: 1, signed: 1, terms: [], }); }), ); const client = mount(); hello(); // NOT settled first, and that is the point: the first read is still in flight, so this frame // finds no snapshot to patch, leaves a mark and restarts the read. emit('bank', { total: 60, signed: 12, pending_decisions: 48 }, 1850); emit('resync_required', { reason: 'the book was rebuilt' }, 12); const before = askedFor(`/v0/books/${bookId}/bank`); // Inside the window: the re-read is still in flight, the cache is empty, and this frame must // still reach the network. On the pre-fix code the mark of the dead epoch ate it. emit('bank', { total: 60, signed: 13, pending_decisions: 47 }, 13); await waitFor(() => { expect(askedFor(`/v0/books/${bookId}/bank`)).toBeGreaterThan(before); }); await settled(client); }); // Ф-51. A resync follows a FULL replacement, where the revision is not promised to keep growing — // so the snapshot's revision has to go with the snapshot. test('a resync drops the book from the cache, marks and all', async () => { const client = mount(['ch_1']); await settled(client); client.setQueryData(keys.book(bookId), (held) => held === undefined ? held : { ...held, revision: 9000 }, ); hello(); // The new epoch numbers lower than what was held. A keyless invalidation kept the 9000, and the // read guard then dropped every answer of the new epoch as stale. emit('resync_required', { reason: 'book rebuilt' }, 12); await settled(client); expect(client.getQueryData(keys.book(bookId))?.revision).toBe(12); expect(client.getQueryData(keys.units(bookId, 'ch_1'))?.units).toHaveLength(1); }); // The cost the resync must NOT pay: the run id travels inside the book detail, so emptying that // detail took the subscription down with it and opened a second connection. Found by the // adversarial review of this very fix, not by the fix's own tests. // // ⚠ The read is SLOW here, and that is the acceptance of S3.7 talking (finding 3): with an instant // answer the observer may never render between the reset and the new data, so the test would be // green on a stream that does restart. The window is opened on purpose and measured inside it. test('a resync does not restart the live stream, even while the re-read is in flight', async () => { server.use( http.get('*/v0/books/:bookId', async () => { await delay(120); return HttpResponse.json({ revision, book, run: { ...run, revision }, }); }), ); const client = mount([], ); await settled(client); expect(FakeEventSource.opened).toBe(1); hello(); emit('resync_required', { reason: 'the book was rebuilt' }, 12); // Inside the window: the card is empty and the answer has not come back yet. await waitFor(() => { expect(client.getQueryData(keys.book(bookId))).toBeUndefined(); }); expect(FakeEventSource.opened).toBe(1); await settled(client); await waitFor(() => { expect(client.getQueryData(keys.book(bookId))?.revision).toBe(12); }); expect(FakeEventSource.opened).toBe(1); }); test('a resync of one book does not touch the library, the usage or another book', async () => { const client = mount(); await settled(client); client.setQueryData(keys.usage(), { state: 'low', remaining_percent: 3, paused_reason: null, }); const other: ChapterList = { revision: 7, next_cursor: null, chapters: [] }; client.setQueryData(keys.chapters('bk_other'), other); hello(); emit('resync_required', { reason: 'bank rebuilt' }, 12); await settled(client); expect(client.getQueryData(keys.usage())).toMatchObject({ state: 'low' }); expect(client.getQueryData(keys.chapters('bk_other'))).toBe(other); });