textmachine/frontend/src/showcase/useRunStream.test.tsx

508 lines
18 KiB
TypeScript

// 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<Schemas['Library']>({ revision, next_cursor: null, books: [book] }),
),
http.get('*/v0/books/:bookId', () =>
HttpResponse.json<Schemas['BookDetail']>({ revision, book, run: { ...run, revision } }),
),
http.get('*/v0/books/:bookId/chapters', () =>
HttpResponse.json<Schemas['ChapterList']>({
revision,
next_cursor: null,
chapters: [chapter('ch_1'), chapter('ch_2')],
}),
),
http.get('*/v0/books/:bookId/chapters/:chapterId/units', () =>
HttpResponse.json<Schemas['UnitList']>({ revision, next_cursor: null, units: [unit] }),
),
http.get('*/v0/books/:bookId/bank', () =>
HttpResponse.json<Schemas['Bank']>({
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<Schemas['Usage']>({
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<string, ((message: MessageEvent<string>) => void)[]>();
constructor() {
FakeEventSource.latest = this;
FakeEventSource.opened += 1;
}
addEventListener(name: string, listener: (message: MessageEvent<string>) => 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<string>;
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) => (
<Chapter key={chapterId} chapterId={chapterId} />
))}
</>
);
}
function Chapter({ chapterId }: { chapterId: string }) {
const units = useQuery(unitsQuery(bookId, chapterId));
return <p>{units.data?.units.length ?? 0}</p>;
}
/**
* 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 = <Screen chapters={open} />) {
// 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(<QueryClientProvider client={client}>{screen}</QueryClientProvider>);
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<ChapterList>(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<Schemas['Bank']>({
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<Schemas['Bank']>({
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<BookDetail>(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<BookDetail>(keys.book(bookId))?.revision).toBe(12);
expect(client.getQueryData<UnitList>(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<Schemas['BookDetail']>({
revision,
book,
run: { ...run, revision },
});
}),
);
const client = mount([], <WiredScreen />);
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<BookDetail>(keys.book(bookId))).toBeUndefined();
});
expect(FakeEventSource.opened).toBe(1);
await settled(client);
await waitFor(() => {
expect(client.getQueryData<BookDetail>(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<Schemas['Usage']>(keys.usage(), {
state: 'low',
remaining_percent: 3,
paused_reason: null,
});
const other: ChapterList = { revision: 7, next_cursor: null, chapters: [] };
client.setQueryData<ChapterList>(keys.chapters('bk_other'), other);
hello();
emit('resync_required', { reason: 'bank rebuilt' }, 12);
await settled(client);
expect(client.getQueryData<Schemas['Usage']>(keys.usage())).toMatchObject({ state: 'low' });
expect(client.getQueryData<ChapterList>(keys.chapters('bk_other'))).toBe(other);
});