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:
parent
2c699c00f4
commit
b3405ff926
8 changed files with 909 additions and 0 deletions
11
frontend/scripts/requirements.txt
Normal file
11
frontend/scripts/requirements.txt
Normal 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
|
||||
160
frontend/src/i18n/catalogue.test.ts
Normal file
160
frontend/src/i18n/catalogue.test.ts
Normal 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
23
frontend/src/i18n/fill.ts
Normal 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,
|
||||
);
|
||||
52
frontend/src/i18n/language.ts
Normal file
52
frontend/src/i18n/language.ts
Normal 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
195
frontend/src/i18n/ru.ts
Normal 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
54
frontend/src/i18n/text.ts
Normal 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);
|
||||
385
frontend/src/showcase/useRunStream.test.tsx
Normal file
385
frontend/src/showcase/useRunStream.test.tsx
Normal 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);
|
||||
});
|
||||
29
frontend/src/ui/Field.module.css
Normal file
29
frontend/src/ui/Field.module.css
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue