diff --git a/frontend/scripts/requirements.txt b/frontend/scripts/requirements.txt new file mode 100644 index 00000000..74effa39 --- /dev/null +++ b/frontend/scripts/requirements.txt @@ -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 diff --git a/frontend/src/i18n/catalogue.test.ts b/frontend/src/i18n/catalogue.test.ts new file mode 100644 index 00000000..c8094920 --- /dev/null +++ b/frontend/src/i18n/catalogue.test.ts @@ -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(); + + 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(); + 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([]); +}); diff --git a/frontend/src/i18n/fill.ts b/frontend/src/i18n/fill.ts new file mode 100644 index 00000000..e7029c41 --- /dev/null +++ b/frontend/src/i18n/fill.ts @@ -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; + +/** + * 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, + ); diff --git a/frontend/src/i18n/language.ts b/frontend/src/i18n/language.ts new file mode 100644 index 00000000..50402981 --- /dev/null +++ b/frontend/src/i18n/language.ts @@ -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 = { 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()( + 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); diff --git a/frontend/src/i18n/ru.ts b/frontend/src/i18n/ru.ts new file mode 100644 index 00000000..29180e57 --- /dev/null +++ b/frontend/src/i18n/ru.ts @@ -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; diff --git a/frontend/src/i18n/text.ts b/frontend/src/i18n/text.ts new file mode 100644 index 00000000..cbb78da8 --- /dev/null +++ b/frontend/src/i18n/text.ts @@ -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({ '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>(); +const formatterFor = (locale: string) => { + let formatter = formatters.get(locale); + if (formatter === undefined) { + formatter = new LocalizedStringFormatter(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); diff --git a/frontend/src/showcase/useRunStream.test.tsx b/frontend/src/showcase/useRunStream.test.tsx new file mode 100644 index 00000000..df798ae7 --- /dev/null +++ b/frontend/src/showcase/useRunStream.test.tsx @@ -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({ 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); + }); +}); + +// Ф-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(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. +test('a resync does not restart the live stream', async () => { + const client = mount([], ); + 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(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); +}); diff --git a/frontend/src/ui/Field.module.css b/frontend/src/ui/Field.module.css new file mode 100644 index 00000000..1dc7e4d7 --- /dev/null +++ b/frontend/src/ui/Field.module.css @@ -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); +}