168 lines
7.4 KiB
TypeScript
168 lines
7.4 KiB
TypeScript
// 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([]);
|
||
});
|