Add the S3.7 files left untracked by the pathspec landing: i18n catalogue, data-layer tests, shared field module, pinned measure requirements

This commit is contained in:
heaven 2026-08-13 22:01:26 +03:00
parent 2c699c00f4
commit b3405ff926
8 changed files with 909 additions and 0 deletions

View file

@ -0,0 +1,11 @@
# The environment of the zone's python scripts (one of them today: measure.py). A pin and not
# "install Pillow": measuring the surfaces is a norm of the acceptance, and it is bound to reproduce
# from version to version.
#
# python3 -m venv .tooling/py && .tooling/py/bin/pip install -r scripts/requirements.txt
# .tooling/py/bin/python scripts/measure.py references/fleet_2.png
#
# .tooling/ is in .gitignore already — the environment lives next to Chromium and does not travel
# into the repository. The system python on the stand comes without Pillow and with PEP 668
# (externally-managed), hence a venv and not `pip install --user`: checked by execution 10.08.
pillow==12.3.0

View file

@ -0,0 +1,160 @@
// The gate on the language of the zone, in one place, because the two halves of it are enforced by
// different tools and a rule that lives in two tools drifts.
//
// ESLint catches an interface literal in TS/TSX where it is written (`no-restricted-syntax`). It
// cannot see a COMMENT, a CSS file or a python script — none of them are its AST — so the sweep is
// here: source of the zone is written in English, and the only Russian in it is DATA, in the two
// places named below.
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { extname, join, relative } from 'node:path';
import { expect, test } from 'vitest';
import { ru } from './ru';
import { text, type MessageKey } from './text';
const root = process.cwd();
/**
* TWO Cyrillic letters in a row, which is a WORD. One letter is not: a backlog row (`Ф-49`) and an
* owner question (`В-3`) are identifiers spelled the same in every document of the project, and a
* comment that names one is still an English comment. The same rule stands in the ESLint half
* (eslint.config.js), so the two halves cannot drift apart.
*
* Named honestly: a line whose only Russian is a one-letter word would pass. There is no such line
* in prose the shortest real sentence carries a longer word with it.
*/
const cyrillicWord = /[А-Яа-яЁё]{2,}/;
// The two homes of Russian, and both hold DATA rather than code: the message catalogue, which a
// translator edits, and the fixtures, which stand in for a book until the platform serves one.
const dataFiles = ['src/i18n/ru.ts', 'src/i18n/catalogue.test.ts'];
const dataDirectories = ['src/mock'];
const scanned = ['.ts', '.tsx', '.css', '.mjs', '.js', '.py'];
function sources(directory: string): string[] {
return readdirSync(join(root, directory)).flatMap((entry) => {
const path = join(directory, entry);
if (statSync(join(root, path)).isDirectory()) return sources(path);
return scanned.includes(extname(entry)) ? [path] : [];
});
}
// The roots as well, and by name: `index.html` and `tsconfig.json` carry no extension the sweep
// walks for, and a rule about the language of the zone that stopped at `src/` would have let the
// four files this very session translated by hand drift back.
const roots = [
'eslint.config.js',
'stylelint.config.js',
'vite.config.ts',
'index.html',
'tsconfig.json',
'.npmrc',
'.gitignore',
'.spectral.yaml',
'.prettierignore',
];
const files = [...sources('src'), ...sources('scripts'), ...roots]
.map((path) => relative('', path))
.filter((path) => !dataFiles.includes(path))
.filter((path) => !dataDirectories.some((directory) => path.startsWith(`${directory}/`)));
test.each(files)('%s is written in English', (path) => {
const offending = readFileSync(join(root, path), 'utf8')
.split('\n')
.map((line, index) => ({ line: index + 1, text: line.trim() }))
.filter((line) => cyrillicWord.test(line.text));
expect(
offending.map((line) => `${String(line.line)}: ${line.text}`),
'interface wording belongs in src/i18n/ru.ts; comments, logs and errors are English',
).toEqual([]);
});
// The defect this locks reached the SCREEN: the library puts nothing into a plain string, so the
// foot of the bank printed "показано {shown} из {total}" verbatim. A scene caught it; nothing in the
// unit battery would have.
test('a place in a line is filled with the variable given for it', () => {
expect(text('bank.shown', { shown: 1, total: 60 })).toBe('показано 1 из 60');
});
test('a place left without a variable stays visible rather than blank', () => {
expect(text('bank.shown', { shown: 1 })).toContain('{total}');
});
/**
* The CALL SITES, not the catalogue against itself. The first edition of this test filled every
* place with a value it had just read out of the same line, so it could only fail if `fill` itself
* broke an unfilled `{count}` on a real screen went straight past it (found by the adversarial
* review). What matters is that the variables a caller passes are the places its line has.
*
* Read out of the source rather than executed: a call inside a component is reachable only by
* rendering every screen, and a gate that needs a browser is a gate nobody runs.
*/
test('every call passes exactly the variables its line has places for', () => {
const calls = /\btext\(\s*'([\w.]+)'\s*,\s*\{([^{}]*)\}/g;
const seen: string[] = [];
for (const path of files) {
if (!['.ts', '.tsx'].includes(extname(path))) continue;
const source = readFileSync(join(root, path), 'utf8');
for (const [, key, body] of source.matchAll(calls)) {
const line = ru[key as MessageKey] as string | undefined;
if (line === undefined) continue; // not a catalogue call; the typecheck owns that
const places = new Set([...line.matchAll(/\{(\w+)\}/g)].map((place) => place[1] as string));
const given = new Set(
[...(body ?? '').matchAll(/(\w+)\s*:/g)].map((variable) => variable[1] as string),
);
const missing = [...places].filter((name) => !given.has(name));
const spare = [...given].filter((name) => !places.has(name));
if (missing.length > 0 || spare.length > 0) {
seen.push(
`${path}: ${key} misses ${missing.join(',') || '—'}, passes spare ${spare.join(',') || '—'}`,
);
}
}
}
expect(seen).toEqual([]);
});
// A line with places nobody ever fills is the same defect one step earlier: it can only reach the
// screen with its braces showing.
test('every line with a place is called with variables somewhere', () => {
const withPlaces = Object.entries(ru)
.filter(([, line]) => line.includes('{'))
.map(([key]) => key);
const filled = new Set<string>();
for (const path of files) {
if (!['.ts', '.tsx'].includes(extname(path))) continue;
const source = readFileSync(join(root, path), 'utf8');
for (const [, key] of source.matchAll(/\btext\(\s*'([\w.]+)'\s*,\s*\{/g))
filled.add(key as string);
}
expect(withPlaces.filter((key) => !filled.has(key))).toEqual([]);
});
test('every catalogue entry is a non-empty string', () => {
const empty = Object.entries(ru).filter(([, value]) => value.trim() === '');
expect(empty).toEqual([]);
});
// A key nobody asks for is a phrase nobody reads: it survives a screen being rewritten and then
// stands in the way of the translator, who has no way to tell it from a live one.
test('every catalogue key is used by the code', () => {
const used = new Set<string>();
for (const path of files) {
if (!['.ts', '.tsx'].includes(extname(path))) continue;
for (const match of readFileSync(join(root, path), 'utf8').matchAll(/'([\w]+\.[\w.]+)'/g)) {
used.add(match[1] as string);
}
}
const orphans = Object.keys(ru).filter((key) => !used.has(key));
expect(orphans, 'key declared in the catalogue but asked for nowhere').toEqual([]);
});

23
frontend/src/i18n/fill.ts Normal file
View file

@ -0,0 +1,23 @@
// Filling the `{name}` places of a line. Its own module for one reason: the scene harness reads the
// same catalogue and has to build the same phrase to look for on screen, and two fillers would be
// two answers to "what does this line say".
//
// Ours, and it has to be: `@internationalized/string` substitutes nothing into a plain string —
// `LocalizedStringFormatter.format` returns a string value verbatim and calls only a FUNCTION value
// with the variables (read in its source, not assumed). Those functions are what
// `@internationalized/string-compiler` produces out of ICU at BUILD time, and a build step is what
// this stack rejected when it weighed lingui (STACK_DECISIONS §1).
/** Variables of a string: `{count}` in the catalogue, a number or a word here. */
export type Variables = Record<string, string | number>;
/**
* A place with no variable given is left as it is written rather than blanked: a visible `{count}`
* says "a variable was forgotten", an empty space says nothing.
*/
export const fill = (line: string, variables: Variables | undefined): string =>
variables === undefined
? line
: line.replace(/\{(\w+)\}/g, (place, name: string) =>
Object.hasOwn(variables, name) ? String(variables[name]) : place,
);

View file

@ -0,0 +1,52 @@
// The interface language: one value, one store, one place that turns it into a BCP-47 tag.
//
// Kept apart from the catalogue on purpose. The catalogue is DATA a translator edits; this is the
// choice a user makes, and it is the only thing a second language has to reach — adding one is a
// translation file plus a line here, with no component touched.
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
/**
* Interface languages this build ships. One today, and that is honest rather than temporary: WHICH
* languages the product speaks is the owner's decision (BACKLOG Ф-35), and inventing translations
* is not this session's business. The mechanism is what had to exist first.
*/
export const languages = ['ru'] as const;
export type Language = (typeof languages)[number];
// Intl and the primitives library speak tags; the catalogue speaks languages. The map is the seam.
const locales: Record<Language, string> = { ru: 'ru-RU' };
interface LanguageState {
language: Language;
choose: (language: Language) => void;
}
// Persisted like the panel layout: a choice that survives a reload, and nothing else.
export const useLanguage = create<LanguageState>()(
persist(
(set) => ({
language: 'ru',
choose: (language) => {
set({ language });
},
}),
{ name: 'textmachine:language' },
),
);
/**
* The tag of a language, and of the default one for anything else. The choice is PERSISTED, so a
* value written by another version of this build outlives it: without the fallback such a value
* reaches `Intl` as `undefined` and the first render throws, with no way out but clearing the
* browser's storage by hand (found by the adversarial review).
*/
export const localeOf = (language: Language): string => locales[language] ?? locales[languages[0]];
/**
* The locale outside React plain `Intl` formatting in functions that are not components. The same
* store, read rather than subscribed, so there is no second source of the answer.
*/
export const currentLocale = (): string => localeOf(useLanguage.getState().language);

195
frontend/src/i18n/ru.ts Normal file
View file

@ -0,0 +1,195 @@
// THE catalogue of Russian interface strings. The only file in `src/` where interface wording
// lives, and the only one the Cyrillic gate lets through (eslint.config.js, `src/i18n.test.ts`).
//
// What belongs here: every word the user reads — labels, hints, empty states, accessible names,
// tooltips. What does NOT: the prose of the fixtures (`src/mock/`), which is DATA standing in for a
// book, and the phrases the platform sends in `problem+json`, which are the server's to word.
//
// Keys read `area.thing`. A key that is not here fails the typecheck at its call site, because
// `MessageKey` is derived from this object.
//
// A second language is a second file of the same shape plus a line in `language.ts` — no component
// is touched. WHICH languages the product speaks is the owner's decision (BACKLOG Ф-35), so no
// translation is invented here.
export const ru = {
'shell.collapseLeft': 'Свернуть левую панель',
'shell.collapseRight': 'Свернуть правую панель',
'shell.documentsPanel': 'Открытые документы',
'shell.expandLeft': 'Развернуть левую панель',
'shell.expandRight': 'Развернуть правую панель',
'shell.gotoAction': 'Перейти к разделу или термину',
'shell.settingsAction': 'Настройки',
'library.addBook': 'Добавить книгу',
'library.booksTab': 'Книги',
'library.chapterProgress': 'переведено {done} из {total}',
'library.chaptersFailed': 'разделы не загрузились',
'library.chaptersLoading': 'загрузка разделов…',
'library.emptyCentreDescription': 'Добавьте книгу — её разделы откроются здесь вкладками.',
'library.emptyCentreTitle': 'Библиотека пуста',
'library.emptyDescription':
'Добавьте книгу — она разберётся на разделы и станет доступна к переводу.',
'library.emptyTitle': 'Ни одной книги',
'library.failedDescription': 'Список книг не загрузился. Обновите страницу — ничего не потеряно.',
'library.failedTitle': 'Библиотека недоступна',
'library.notesFew': 'замечания',
'library.notesMany': 'замечаний',
'library.notesOne': 'замечание',
'library.notesOther': 'замечаний',
'library.notesTwo': 'замечания',
'library.notesZero': 'замечаний',
'library.panel': 'Библиотека',
'library.treeLabel': 'Книги и разделы',
'document.draftNote': 'черновой вариант, может быть перегенерирован',
'document.draftTooltip':
'Прогон над книгой ещё идёт: текст, названия разделов и их число могут измениться при следующем проходе',
'document.nothingOpenDescription': 'Выберите раздел книги слева — он откроется здесь вкладкой.',
'document.nothingOpenTitle': 'Ничего не открыто',
'reader.emptyDescription': 'В этом разделе нет текста для сравнения.',
'reader.emptyTitle': 'Раздел пуст',
'reader.missingTarget': 'перевод не получен',
'reader.waiting': 'Раздел',
'bank.awaitingSignature': 'банк ждёт подписи',
'bank.columnKind': 'Тип',
'bank.columnSense': 'Смысл',
'bank.columnTerm': 'Термин',
'bank.columnTranslation': 'Перевод',
'bank.empty': 'Ничего не нашлось',
'bank.emptyDescription': 'Термины книги появятся после первого прохода перевода.',
'bank.emptyTitle': 'Банк пуст',
'bank.hideSenses': 'Скрыть смыслы терминов',
'bank.kindAll': 'все',
'bank.kindFilter': 'Тип термина',
'bank.noTranslation': 'не предложен',
'bank.search': 'Поиск по банку',
'bank.showSenses': 'Показать смыслы терминов',
'bank.shown': 'показано {shown} из {total}',
'bank.tab': 'Банк',
'bank.tableLabel': 'Термины банка',
'bank.termsFew': 'термина',
'bank.termsMany': 'терминов',
'bank.termsOne': 'термин',
'bank.termsOther': 'терминов',
'bank.termsTwo': 'термина',
'bank.termsZero': 'терминов',
'bank.waiting': 'Банк памяти',
'bank.windowSince': 'с {chapter}-го',
'bank.windowUntil': 'по {chapter}-й',
'notes.empty': 'Замечаний нет',
'notes.emptyDescription': 'В этой книге нет мест, требующих внимания.',
'notes.emptyTitle': 'Замечаний нет',
'notes.listLabel': 'Замечания книги',
'notes.tab': 'Замечания',
'about.added': 'Добавлена',
'about.blocks': 'Блоков',
'about.ceiling': 'Потолок прогона',
'about.chaptersFew': 'раздела',
'about.chaptersMany': 'разделов',
'about.chaptersOne': 'раздел',
'about.chaptersOther': 'разделов',
'about.chaptersTwo': 'раздела',
'about.chaptersZero': 'разделов',
'about.chapters': 'Разделов',
'about.characters': 'Знаков',
'about.genre': 'Жанр',
'about.languages': 'Языки',
'about.no': 'нет',
'about.noBooksDescription': 'Добавьте книгу — сведения о ней появятся здесь.',
'about.noBooksTitle': 'Книг пока нет',
'about.notes': 'Замечаний',
'about.panel': 'О читаемой книге',
'about.runFinished': 'Прогон завершён',
'about.runStarted': 'Прогон начат',
'about.status': 'Состояние',
'about.tab': 'О книге',
'about.title': 'Название',
'about.translated': 'Переведено',
'about.verifyBank': 'Остановка на подписи',
'about.yes': 'да',
'goto.chapter': 'раздел',
'goto.empty': 'Ничего не нашлось',
'goto.field': 'Название раздела или термин',
'goto.results': 'Результаты перехода',
'goto.term': 'термин',
'goto.termRow': '{src} — {dst}',
'goto.termWithWindow': '{src} — {dst} · с {chapter}-го',
'goto.title': 'Перейти',
'goto.truncated': 'Показаны первые {count} совпадений.',
'addBook.cancel': 'Отмена',
'addBook.chooseFile': 'Выбрать файл',
'addBook.fileLabel': 'Файл книги',
'addBook.settingsLabel': 'Настройки книги',
'addBook.settingsSlot': 'Пара языков, жанр и параметры запуска появятся здесь.',
'addBook.submit': 'Добавить',
'addBook.title': 'Добавить книгу',
'addBook.titleHintAuto':
'Поле пустое: название определит разбор файла — его можно будет поправить позже.',
'addBook.titleHintManual': 'Название задано вручную: разбор его не перепишет.',
'addBook.titleLabel': 'Название',
'addBook.titlePlaceholder': 'по умолчанию — из файла',
'settings.defaultsAbout':
'Что подставлять в форму запуска: пара языков, потолок, остановка на подписи.',
'settings.defaultsName': 'Перевод по умолчанию',
'settings.languageAbout': 'Сейчас интерфейс только русский.',
'settings.languageName': 'Язык интерфейса',
'settings.note': 'Разделы наполняются отдельным пакетом работ.',
'settings.profileAbout': 'Учётная запись, вход, завершение сеансов.',
'settings.profileName': 'Профиль и доступ',
'settings.themeAbout': 'Сейчас доступна только тёмная.',
'settings.themeName': 'Светлая тема',
'settings.title': 'Настройки',
'settings.usageAbout': 'Сколько израсходовано и что будет при исчерпании лимита.',
'settings.usageName': 'Использование',
'status.awaitingBank': 'нужна подпись',
'status.failed': 'ошибка',
'status.finalizing': 'финал',
'status.liveProgressLost': 'живой прогресс недоступен',
'status.notStarted': 'в очереди',
'status.parsing': 'разбор',
'status.paused': 'остановлена: лимиты',
'status.ready': 'готова',
'status.rejected': 'не разобрана',
'status.remaining': 'осталось {time}',
'status.stopped': 'остановлена',
'status.translated': 'переведено {percent}%',
'status.translating': 'перевод',
'status.unknown': 'неизвестное состояние',
'status.uploading': 'загрузка',
'paused.creditExhausted': 'перевод остановлен: лимиты исчерпаны',
'paused.unknown': 'перевод остановлен',
'kind.name': 'имя',
'kind.nickname': 'прозвище',
'kind.place': 'место',
'kind.term': 'термин',
'kind.title': 'титул',
'kind.undecided': 'тип не определён',
'loaded.failedTitle': 'Не удалось загрузить',
'loaded.noBookDescription': 'Выберите книгу слева — сведения о ней появятся здесь.',
'loaded.noBookTitle': 'Книга не выбрана',
'loaded.serverRefused': 'Сервер ответил отказом.',
'loaded.serverUnreachable': 'Сервер недоступен. Попробуйте ещё раз.',
'loaded.unknownError': 'Неизвестная ошибка.',
'blank.loading': 'Идёт загрузка.',
'modal.close': 'Закрыть',
'time.hours': '{count} ч',
'time.minutes': '{count} мин',
'time.underMinute': 'меньше минуты',
} as const;
export type MessageKey = keyof typeof ru;

54
frontend/src/i18n/text.ts Normal file
View file

@ -0,0 +1,54 @@
// Interface strings, by key. `@internationalized/string` and not a homemade lookup, and not
// react-intl either — the reasoning is in FRONTEND_PLAN.md §8; in one line: it is Adobe's shipping
// i18n layer, it is ALREADY in the tree under react-aria-components (measured: same package, one
// copy, deduped), so the catalogue costs the bundle nothing but the words themselves.
//
// A key that is not in the catalogue fails the typecheck: `MessageKey` is derived FROM the
// catalogue, so a typo cannot reach the screen as an empty string.
import { LocalizedStringDictionary, LocalizedStringFormatter } from '@internationalized/string';
import { useMemo } from 'react';
import { fill, type Variables } from './fill';
import { currentLocale, localeOf, useLanguage } from './language';
import { ru, type MessageKey } from './ru';
export type { MessageKey };
export type { Variables };
export type Text = (key: MessageKey, variables?: Variables) => string;
// One catalogue per locale tag; the dictionary negotiates ("ru" for "ru-RU") and falls back to the
// default rather than to an empty string.
const dictionary = new LocalizedStringDictionary<MessageKey, string>({ 'ru-RU': ru }, 'ru-RU');
// One formatter per locale, not per call: it holds `Intl` objects, and building those per render is
// the classic cost of an i18n layer.
const formatters = new Map<string, LocalizedStringFormatter<MessageKey, string>>();
const formatterFor = (locale: string) => {
let formatter = formatters.get(locale);
if (formatter === undefined) {
formatter = new LocalizedStringFormatter<MessageKey, string>(locale, dictionary);
formatters.set(locale, formatter);
}
return formatter;
};
/**
* The string for a key, in the language a component is rendered in. A hook rather than a bare
* function so that choosing another language re-renders what is on screen.
*/
export function useText(): Text {
const language = useLanguage((state) => state.language);
return useMemo(() => {
const formatter = formatterFor(localeOf(language));
return (key, variables) => fill(formatter.format(key), variables);
}, [language]);
}
/**
* The same lookup outside React for plain functions that build a phrase (`showcase/format.ts`).
* Reads the language rather than subscribing to it: a function has nothing to re-render.
*/
export const text: Text = (key, variables) =>
fill(formatterFor(currentLocale()).format(key), variables);

View file

@ -0,0 +1,385 @@
// 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, 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);
});
});
// Ф-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);
});
// Ф-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.
test('a resync does not restart the live stream', async () => {
const client = mount([], <WiredScreen />);
await settled(client);
expect(FakeEventSource.opened).toBe(1);
hello();
emit('resync_required', { reason: 'the book was rebuilt' }, 12);
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);
});

View file

@ -0,0 +1,29 @@
/* The look of an input, in one place. The two primitives that wear it stay separate a search
field clears on Escape and needs no label, a form field requires one but their input is the
same object, and it stood here as two byte-equal copies (BACKLOG Ф-40).
Both classes live in ONE module on purpose: order between two CSS modules is the order of their
imports, and the modifier below has to come after the base. */
.input {
width: 100%;
height: var(--row-height);
border: 1px solid transparent;
padding-inline: var(--space-4);
border-radius: var(--radius-control);
color: var(--color-text);
}
.input::placeholder {
color: var(--color-text-secondary);
}
/* The only patch of colour in a panel is the border of the focused field (accent, prompt §1). */
.input:focus {
border-color: var(--color-accent);
outline: none;
}
/* A field of a FORM shows its edge at rest; a filter stays quiet until it is focused. */
.framed {
border-color: var(--color-border);
}