textmachine/frontend/src/i18n/catalogue.test.ts

168 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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',
'package.json',
'.npmrc',
'.gitignore',
'.spectral.yaml',
'.prettierignore',
// Extensionless, and therefore invisible to the sweep by extension above — while it is a script
// of this zone like any other (acceptance of S3.7, minor).
'scripts/githooks/pre-commit',
];
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', () => {
// ANY call of the shape `f('a.key', { … })`, not only one made through a local variable named
// `text`: the rule is about the KEY and its places, and a gate that hung on the caller's name
// stopped seeing a screen that named it anything else (acceptance of S3.7, minor). A false match
// is impossible by construction — a key that is not in the catalogue is skipped below.
const calls = /\b\w+\(\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(/\b\w+\(\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([]);
});