mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
ci(i18n): fail on new English-identical translations
The drift check only warned about locale values identical to English, so untranslated strings kept landing. It now fails on any such value that tools/i18n/identical-en-baseline.json does not record for that locale and key. The baseline captures today's 2,015 entries: legitimately identical values (brand and technical names, language autonyms, PIN) and the existing debt. An entry only covers the English text it recorded, so copying reworded English into a locale fails too. Baseline entries that are no longer identical are reported, not fatal. `pnpm run i18n:baseline:update` rewrites the baseline deliberately; CI runs `pnpm run i18n:validate` (node tests, then the check) and never rewrites it. `--fail-on-identical` remains as a strict audit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
23a1860119
commit
97d7541e62
7 files changed
+2637
-83
No files matched your search
@@ -390,8 +390,12 @@ jobs:
|
||||
- name: Typecheck Jest spec programs
|
||||
run: pnpm run typecheck:spec:test && pnpm run typecheck:spec
|
||||
|
||||
- name: Check i18n drift
|
||||
run: pnpm run i18n:check
|
||||
# Fails on missing or extra keys and on values identical to
|
||||
# English that tools/i18n/identical-en-baseline.json does not list.
|
||||
# The baseline only changes through a reviewed
|
||||
# `pnpm run i18n:baseline:update`; CI never rewrites it.
|
||||
- name: Check i18n drift and untranslated values
|
||||
run: pnpm run i18n:validate
|
||||
|
||||
# Node specs for the benchmark and journey harness (about ten
|
||||
# seconds). They live in the E2E app, which the unit coverage scope
|
||||
|
||||
@@ -242,14 +242,31 @@ pnpm run coverage:e2e:v8:web
|
||||
## I18n
|
||||
|
||||
```bash
|
||||
pnpm run i18n:check
|
||||
pnpm run i18n:validate # checker unit tests, then the check (CI)
|
||||
pnpm run i18n:check # the check only
|
||||
pnpm run i18n:baseline:update # deliberate: rewrite the English-identical baseline
|
||||
```
|
||||
|
||||
The i18n check is non-mutating. It compares every locale file in
|
||||
`apps/web/src/assets/i18n/` against `en.json` and fails on missing or extra keys.
|
||||
Identical English fallback values are reported as warnings by default; use
|
||||
`node tools/i18n/check-drift.mjs --fail-on-identical` for a stricter translation
|
||||
audit.
|
||||
It also fails on a locale value that is identical to English unless
|
||||
`tools/i18n/identical-en-baseline.json` records that exact English text for the
|
||||
same locale and key. The baseline holds values that are legitimately the same
|
||||
in a language (brand and technical names, language autonyms, loanwords such as
|
||||
"PIN") and the untranslated debt that existed when the guard was added, so new
|
||||
keys must ship translated. An entry stops covering its key once the English
|
||||
text changes, so copying reworded English into a locale fails as well.
|
||||
|
||||
Baseline entries that are no longer English-identical (translated, removed, or
|
||||
reworded) are reported but do not fail. `pnpm run i18n:baseline:update`
|
||||
rewrites the baseline from the current locale files, dropping those entries
|
||||
and printing every added one. Run it only after translating, or for a value
|
||||
that is legitimately identical in that language; review the diff, and never
|
||||
run it in CI. A new locale starts with no baseline entries, so it has to
|
||||
record its legitimate identical values the same way.
|
||||
`node tools/i18n/check-drift.mjs --fail-on-identical` ignores the baseline for
|
||||
a full translation audit. CI runs `pnpm run i18n:validate` in the unit test
|
||||
job.
|
||||
|
||||
## Performance
|
||||
|
||||
|
||||
@@ -79,6 +79,9 @@
|
||||
"serve:website": "nx serve website",
|
||||
"build:website": "nx build website",
|
||||
"i18n:check": "node tools/i18n/check-drift.mjs",
|
||||
"i18n:test": "node --test tools/i18n/check-drift.test.mjs",
|
||||
"i18n:validate": "pnpm run i18n:test && pnpm run i18n:check",
|
||||
"i18n:baseline:update": "node tools/i18n/check-drift.mjs --update-baseline",
|
||||
"perf:initial-bytes": "node tools/performance/measure-initial-bytes.mjs",
|
||||
"perf:initial-bytes:check": "node tools/performance/measure-initial-bytes.mjs --summary dist/performance/initial-bytes.summary.json && node tools/performance/check-journey-ratchet.mjs --summary dist/performance/initial-bytes.summary.json --only launch/renderer.initialBytes",
|
||||
"perf:journeys": "nx run electron-backend-e2e:journeys",
|
||||
|
||||
+18
-4
@@ -49,10 +49,24 @@ For a non-mutating CI/agent check, run:
|
||||
pnpm run i18n:check
|
||||
```
|
||||
|
||||
The check fails on missing or extra keys against `en.json`. It reports values
|
||||
that are still identical to English as warnings so untranslated fallback strings
|
||||
remain visible without blocking key-parity validation. For stricter translation
|
||||
audits, run:
|
||||
The check fails on missing or extra keys against `en.json`, and on values that
|
||||
are identical to English unless `identical-en-baseline.json` lists that exact
|
||||
English text for the locale and key. Do not copy English into a locale as a
|
||||
placeholder: translate it. The contract, including when an entry stops
|
||||
covering a key, is in the
|
||||
[validation map](../../docs/architecture/validation-map.md#i18n).
|
||||
|
||||
Baseline entries that are no longer identical are reported, not fatal. After
|
||||
translating baselined values, or for a value that is legitimately the same in a
|
||||
language (a brand, a technical name, a loanword), rewrite the baseline and
|
||||
review every added entry in the diff:
|
||||
|
||||
```bash
|
||||
pnpm run i18n:baseline:update
|
||||
```
|
||||
|
||||
The checker's own tests run with `pnpm run i18n:test`; CI runs both through
|
||||
`pnpm run i18n:validate`. For a full audit that ignores the baseline, run:
|
||||
|
||||
```bash
|
||||
node tools/i18n/check-drift.mjs --fail-on-identical
|
||||
|
||||
+288
-73
@@ -1,27 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
/**
|
||||
* Compares every locale in apps/web/src/assets/i18n/ with en.json.
|
||||
*
|
||||
* Missing or extra keys fail. A value identical to English fails too, unless
|
||||
* tools/i18n/identical-en-baseline.json records that exact English text for
|
||||
* that locale and key. The baseline holds values that are legitimately the
|
||||
* same in a language (brands, technical terms, loanwords such as "PIN") and
|
||||
* the untranslated debt that existed when the guard was introduced, so new
|
||||
* keys must ship translated while old debt can be paid down.
|
||||
*
|
||||
* Baseline entries that no longer match an English-identical value are only
|
||||
* reported. `--update-baseline` rewrites the baseline from the current files;
|
||||
* run it deliberately and review the diff. CI never runs it.
|
||||
* `--fail-on-identical` ignores the baseline for a strict audit.
|
||||
*/
|
||||
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = resolve(__dirname, '..', '..');
|
||||
const I18N_DIR = resolve(REPO_ROOT, 'apps/web/src/assets/i18n');
|
||||
const EN_LOCALE = 'en.json';
|
||||
export const I18N_DIR = resolve(__dirname, '../../apps/web/src/assets/i18n');
|
||||
export const BASELINE_PATH = resolve(__dirname, 'identical-en-baseline.json');
|
||||
const EN_LOCALE = 'en';
|
||||
const MAX_EXAMPLES = 5;
|
||||
const FAIL_ON_IDENTICAL = process.argv.includes('--fail-on-identical');
|
||||
const UPDATE_COMMAND = 'pnpm run i18n:baseline:update';
|
||||
const FLAGS = new Set(['--fail-on-identical', '--update-baseline', '--']);
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
function collectLeaves(value, prefix = '', leaves = new Map()) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
function isPlainObject(value) {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function collectLeaves(value, prefix = '', leaves = new Map()) {
|
||||
if (!isPlainObject(value)) {
|
||||
throw new Error(`Expected object at ${prefix || '<root>'}`);
|
||||
}
|
||||
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
|
||||
if (isPlainObject(entry)) {
|
||||
collectLeaves(entry, path, leaves);
|
||||
} else {
|
||||
leaves.set(path, entry);
|
||||
@@ -31,10 +51,87 @@ function collectLeaves(value, prefix = '', leaves = new Map()) {
|
||||
return leaves;
|
||||
}
|
||||
|
||||
function listLocaleFiles() {
|
||||
return readdirSync(I18N_DIR)
|
||||
.filter((file) => file.endsWith('.json'))
|
||||
.sort();
|
||||
/** Keys whose non-empty locale string equals the English string. */
|
||||
export function findIdenticalToEnglish(enLeaves, localeLeaves) {
|
||||
const identical = new Map();
|
||||
for (const [key, enValue] of enLeaves) {
|
||||
const localeValue = localeLeaves.get(key);
|
||||
if (
|
||||
typeof enValue === 'string' &&
|
||||
localeValue === enValue &&
|
||||
enValue.trim().length > 0
|
||||
) {
|
||||
identical.set(key, enValue);
|
||||
}
|
||||
}
|
||||
return identical;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a locale's English-identical keys into baselined and new ones. An
|
||||
* entry covers a key only while its recorded text is still the English text,
|
||||
* so rewording English and copying the new text into a locale is new debt.
|
||||
* Stale entries no longer describe an English-identical value: the key was
|
||||
* translated or removed, or its English text changed.
|
||||
*/
|
||||
export function compareWithBaseline(identical, localeBaseline = new Map()) {
|
||||
const baselined = [];
|
||||
const unbaselined = [];
|
||||
for (const [key, value] of identical) {
|
||||
(localeBaseline.get(key) === value ? baselined : unbaselined).push(key);
|
||||
}
|
||||
const stale = [...localeBaseline]
|
||||
.filter(([key, value]) => identical.get(key) !== value)
|
||||
.map(([key]) => key);
|
||||
return { baselined, unbaselined, stale };
|
||||
}
|
||||
|
||||
/** Validates the baseline JSON into Map<locale, Map<key, englishText>>. */
|
||||
export function parseBaseline(raw) {
|
||||
if (!isPlainObject(raw)) {
|
||||
throw new Error('expected an object of locales');
|
||||
}
|
||||
const baseline = new Map();
|
||||
for (const [locale, entries] of Object.entries(raw)) {
|
||||
if (!isPlainObject(entries)) {
|
||||
throw new Error(`expected an object of keys for ${locale}`);
|
||||
}
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`expected English text at ${locale} ${key}`);
|
||||
}
|
||||
}
|
||||
baseline.set(locale, new Map(Object.entries(entries)));
|
||||
}
|
||||
return baseline;
|
||||
}
|
||||
|
||||
/** Serialises Map<locale, Map<key, text>> with locales and keys sorted. */
|
||||
export function serializeBaseline(identicalByLocale) {
|
||||
const byCodeUnit = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
||||
const output = {};
|
||||
for (const locale of [...identicalByLocale.keys()].sort(byCodeUnit)) {
|
||||
const entries = identicalByLocale.get(locale);
|
||||
if (entries.size > 0) {
|
||||
output[locale] = Object.fromEntries(
|
||||
[...entries].sort(([a], [b]) => byCodeUnit(a, b))
|
||||
);
|
||||
}
|
||||
}
|
||||
return `${JSON.stringify(output, null, 4)}\n`;
|
||||
}
|
||||
|
||||
function countChanges(from, to, onChange = () => undefined) {
|
||||
let changes = 0;
|
||||
for (const [locale, entries] of from) {
|
||||
for (const [key, value] of entries) {
|
||||
if (to.get(locale)?.get(key) !== value) {
|
||||
changes += 1;
|
||||
onChange(locale, key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
function formatExamples(values) {
|
||||
@@ -50,73 +147,191 @@ function formatError(error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
let enLeaves;
|
||||
try {
|
||||
enLeaves = collectLeaves(readJson(resolve(I18N_DIR, EN_LOCALE)));
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${EN_LOCALE}: ${formatError(error)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const enKeys = [...enLeaves.keys()];
|
||||
let failed = false;
|
||||
let identicalTotal = 0;
|
||||
|
||||
for (const localeFile of listLocaleFiles()) {
|
||||
if (localeFile === EN_LOCALE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
function readBaseline(baselinePath) {
|
||||
let raw;
|
||||
try {
|
||||
const localeLeaves = collectLeaves(
|
||||
readJson(resolve(I18N_DIR, localeFile))
|
||||
);
|
||||
const localeKeys = [...localeLeaves.keys()];
|
||||
const missing = enKeys.filter((key) => !localeLeaves.has(key));
|
||||
const extra = localeKeys.filter((key) => !enLeaves.has(key));
|
||||
const identical = enKeys.filter((key) => {
|
||||
if (!localeLeaves.has(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const localeValue = localeLeaves.get(key);
|
||||
const enValue = enLeaves.get(key);
|
||||
|
||||
return (
|
||||
typeof localeValue === 'string' &&
|
||||
typeof enValue === 'string' &&
|
||||
localeValue.trim().length > 0 &&
|
||||
localeValue === enValue
|
||||
);
|
||||
});
|
||||
|
||||
identicalTotal += identical.length;
|
||||
const localeFailed =
|
||||
missing.length > 0 ||
|
||||
extra.length > 0 ||
|
||||
(FAIL_ON_IDENTICAL && identical.length > 0);
|
||||
failed = failed || localeFailed;
|
||||
|
||||
const status = localeFailed ? 'FAIL' : 'ok';
|
||||
console.log(
|
||||
`${status} ${localeFile}: missing=${missing.length}${formatExamples(
|
||||
missing
|
||||
)} extra=${extra.length}${formatExamples(extra)} identical_en=${
|
||||
identical.length
|
||||
}${formatExamples(identical)}`
|
||||
);
|
||||
raw = readJson(baselinePath);
|
||||
} catch (error) {
|
||||
failed = true;
|
||||
console.log(`FAIL ${localeFile}: ${formatError(error)}`);
|
||||
if (error?.code === 'ENOENT') {
|
||||
return new Map();
|
||||
}
|
||||
throw new Error(`${baselinePath}: ${formatError(error)}`);
|
||||
}
|
||||
try {
|
||||
return parseBaseline(raw);
|
||||
} catch (error) {
|
||||
throw new Error(`${baselinePath}: ${formatError(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (identicalTotal > 0 && !FAIL_ON_IDENTICAL) {
|
||||
console.log(
|
||||
`warn identical_en=${identicalTotal}; pass --fail-on-identical to make English fallback values fatal.`
|
||||
function listLocales(i18nDir) {
|
||||
return readdirSync(i18nDir)
|
||||
.filter((file) => file.endsWith('.json'))
|
||||
.map((file) => file.slice(0, -'.json'.length))
|
||||
.filter((locale) => locale !== EN_LOCALE)
|
||||
.sort();
|
||||
}
|
||||
|
||||
function checkLocale({ i18nDir, locale, enLeaves, baseline, mode }) {
|
||||
const localeLeaves = collectLeaves(
|
||||
readJson(resolve(i18nDir, `${locale}.json`))
|
||||
);
|
||||
const missing = [...enLeaves.keys()].filter(
|
||||
(key) => !localeLeaves.has(key)
|
||||
);
|
||||
const extra = [...localeLeaves.keys()].filter((key) => !enLeaves.has(key));
|
||||
const identical = findIdenticalToEnglish(enLeaves, localeLeaves);
|
||||
const { baselined, unbaselined, stale } = compareWithBaseline(
|
||||
identical,
|
||||
baseline.get(locale)
|
||||
);
|
||||
const rejected =
|
||||
mode === 'strict'
|
||||
? [...identical.keys()]
|
||||
: mode === 'update'
|
||||
? []
|
||||
: unbaselined;
|
||||
return { missing, extra, identical, baselined, rejected, stale };
|
||||
}
|
||||
|
||||
function reportLocale(log, file, result, mode) {
|
||||
const { missing, extra, identical, baselined, rejected, stale } = result;
|
||||
const failed = missing.length + extra.length + rejected.length > 0;
|
||||
const baselineNote =
|
||||
mode === 'baseline' ? ` baselined=${baselined.length}` : '';
|
||||
log(
|
||||
`${failed ? 'FAIL' : 'ok'} ${file}: missing=${
|
||||
missing.length
|
||||
}${formatExamples(missing)} extra=${extra.length}${formatExamples(
|
||||
extra
|
||||
)} identical_en=${identical.size}${baselineNote}`
|
||||
);
|
||||
if (mode === 'baseline' && stale.length > 0) {
|
||||
log(
|
||||
`note ${file}: ${stale.length} baseline entries are no longer English-identical${formatExamples(
|
||||
stale
|
||||
)}`
|
||||
);
|
||||
}
|
||||
return failed;
|
||||
}
|
||||
|
||||
function reportRejected(log, rejected, enLeaves, mode) {
|
||||
if (rejected.length === 0) {
|
||||
return;
|
||||
}
|
||||
const scope = mode === 'strict' ? '' : ' and are not in the baseline';
|
||||
log(`${rejected.length} value(s) equal English${scope}:`);
|
||||
for (const [file, key] of rejected) {
|
||||
log(` ${file} ${key}: ${JSON.stringify(enLeaves.get(key))}`);
|
||||
}
|
||||
if (mode === 'baseline') {
|
||||
log(
|
||||
`Translate them (see the i18n-fill skill). Only if a value is legitimately the same in that language, run \`${UPDATE_COMMAND}\` and commit the reviewed baseline.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function writeBaseline(log, baselinePath, previous, identicalByLocale) {
|
||||
const added = countChanges(
|
||||
identicalByLocale,
|
||||
previous,
|
||||
(locale, key, value) =>
|
||||
log(` + ${locale} ${key}: ${JSON.stringify(value)}`)
|
||||
);
|
||||
const removed = countChanges(previous, identicalByLocale);
|
||||
writeFileSync(baselinePath, serializeBaseline(identicalByLocale));
|
||||
log(
|
||||
`Baseline written: ${added} added, ${removed} removed. Review every added entry before committing.`
|
||||
);
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
process.exitCode = 1;
|
||||
/** Runs the check and returns the process exit code. */
|
||||
export function run({
|
||||
i18nDir = I18N_DIR,
|
||||
baselinePath = BASELINE_PATH,
|
||||
argv = [],
|
||||
log = console.log,
|
||||
} = {}) {
|
||||
const strict = argv.includes('--fail-on-identical');
|
||||
const update = argv.includes('--update-baseline');
|
||||
if (argv.some((arg) => !FLAGS.has(arg)) || (strict && update)) {
|
||||
log(
|
||||
`FAIL unsupported arguments: ${argv.join(' ')}; pass at most one of --fail-on-identical and --update-baseline.`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
const mode = strict ? 'strict' : update ? 'update' : 'baseline';
|
||||
|
||||
let enLeaves;
|
||||
let baseline;
|
||||
try {
|
||||
enLeaves = collectLeaves(
|
||||
readJson(resolve(i18nDir, `${EN_LOCALE}.json`))
|
||||
);
|
||||
baseline = readBaseline(baselinePath);
|
||||
} catch (error) {
|
||||
if (!enLeaves || !update) {
|
||||
log(`FAIL ${formatError(error)}`);
|
||||
return 1;
|
||||
}
|
||||
log(`note replacing unreadable baseline: ${formatError(error)}`);
|
||||
baseline = new Map();
|
||||
}
|
||||
|
||||
const locales = listLocales(i18nDir);
|
||||
const identicalByLocale = new Map();
|
||||
const rejected = [];
|
||||
let failed = false;
|
||||
let unreadable = false;
|
||||
let staleTotal = 0;
|
||||
|
||||
for (const locale of locales) {
|
||||
const file = `${locale}.json`;
|
||||
let result;
|
||||
try {
|
||||
result = checkLocale({ i18nDir, locale, enLeaves, baseline, mode });
|
||||
} catch (error) {
|
||||
failed = unreadable = true;
|
||||
log(`FAIL ${file}: ${formatError(error)}`);
|
||||
continue;
|
||||
}
|
||||
identicalByLocale.set(locale, result.identical);
|
||||
rejected.push(...result.rejected.map((key) => [file, key]));
|
||||
staleTotal += result.stale.length;
|
||||
failed = reportLocale(log, file, result, mode) || failed;
|
||||
}
|
||||
|
||||
if (mode === 'baseline') {
|
||||
for (const [locale, entries] of baseline) {
|
||||
if (!locales.includes(locale)) {
|
||||
staleTotal += entries.size;
|
||||
log(`note ${locale}: baseline lists a locale with no file`);
|
||||
}
|
||||
}
|
||||
if (staleTotal > 0) {
|
||||
log(
|
||||
`note ${staleTotal} stale baseline entries; run \`${UPDATE_COMMAND}\` to shrink the baseline.`
|
||||
);
|
||||
}
|
||||
}
|
||||
reportRejected(log, rejected, enLeaves, mode);
|
||||
|
||||
if (update) {
|
||||
if (unreadable) {
|
||||
log('FAIL baseline not written: a locale file could not be read.');
|
||||
return 1;
|
||||
}
|
||||
writeBaseline(log, baselinePath, baseline, identicalByLocale);
|
||||
}
|
||||
|
||||
return failed ? 1 : 0;
|
||||
}
|
||||
|
||||
const isMain =
|
||||
process.argv[1] &&
|
||||
resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
|
||||
|
||||
if (isMain) {
|
||||
process.exitCode = run({ argv: process.argv.slice(2) });
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, test } from 'node:test';
|
||||
|
||||
import { parseBaseline, run, serializeBaseline } from './check-drift.mjs';
|
||||
|
||||
const EN = {
|
||||
APP: { TITLE: 'IPTVnator', SAVE: 'Save' },
|
||||
PIN: 'PIN',
|
||||
};
|
||||
|
||||
let root;
|
||||
let dir;
|
||||
let baselinePath;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(path.join(os.tmpdir(), 'i18n-drift-'));
|
||||
dir = path.join(root, 'i18n');
|
||||
mkdirSync(dir);
|
||||
baselinePath = path.join(root, 'identical-en-baseline.json');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeLocale(locale, content) {
|
||||
writeFileSync(path.join(dir, `${locale}.json`), JSON.stringify(content));
|
||||
}
|
||||
|
||||
function writeBaseline(content) {
|
||||
writeFileSync(baselinePath, JSON.stringify(content));
|
||||
}
|
||||
|
||||
function check(...argv) {
|
||||
const lines = [];
|
||||
const code = run({
|
||||
i18nDir: dir,
|
||||
baselinePath,
|
||||
argv,
|
||||
log: (line) => lines.push(line),
|
||||
});
|
||||
return { code, output: lines.join('\n') };
|
||||
}
|
||||
|
||||
function readBaselineFile() {
|
||||
return JSON.parse(readFileSync(baselinePath, 'utf8'));
|
||||
}
|
||||
|
||||
test('fails on a new English-identical key that the baseline does not list', () => {
|
||||
writeLocale('en', { ...EN, APP: { ...EN.APP, NEW: 'New label' } });
|
||||
writeLocale('de', {
|
||||
APP: { TITLE: 'IPTVnator', SAVE: 'Speichern', NEW: 'New label' },
|
||||
PIN: 'PIN',
|
||||
});
|
||||
writeBaseline({ de: { 'APP.TITLE': 'IPTVnator', PIN: 'PIN' } });
|
||||
|
||||
const { code, output } = check();
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.match(output, /^FAIL de\.json: .*identical_en=3 baselined=2$/m);
|
||||
assert.match(output, /de\.json APP\.NEW: "New label"/);
|
||||
});
|
||||
|
||||
test('passes a translated key', () => {
|
||||
writeLocale('en', EN);
|
||||
writeLocale('de', {
|
||||
APP: { TITLE: 'IPTVnator', SAVE: 'Speichern' },
|
||||
PIN: 'PIN-Code',
|
||||
});
|
||||
writeBaseline({ de: { 'APP.TITLE': 'IPTVnator' } });
|
||||
|
||||
const { code, output } = check();
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.match(output, /^ok de\.json: .*identical_en=1 baselined=1$/m);
|
||||
});
|
||||
|
||||
test('passes an English-identical key listed in the baseline', () => {
|
||||
writeLocale('en', EN);
|
||||
writeLocale('de', {
|
||||
APP: { TITLE: 'IPTVnator', SAVE: 'Speichern' },
|
||||
PIN: 'PIN',
|
||||
});
|
||||
writeBaseline({ de: { 'APP.TITLE': 'IPTVnator', PIN: 'PIN' } });
|
||||
|
||||
assert.equal(check().code, 0);
|
||||
});
|
||||
|
||||
test('a baseline entry only covers the English text it recorded', () => {
|
||||
// English was reworded and the new text copied into the locale: that is
|
||||
// new untranslated text, not the value the baseline accepted.
|
||||
writeLocale('en', { ...EN, APP: { ...EN.APP, SAVE: 'Save changes' } });
|
||||
writeLocale('de', {
|
||||
APP: { TITLE: 'IPTVnator', SAVE: 'Save changes' },
|
||||
PIN: 'PIN-Code',
|
||||
});
|
||||
writeBaseline({ de: { 'APP.TITLE': 'IPTVnator', 'APP.SAVE': 'Save' } });
|
||||
|
||||
const { code, output } = check();
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.match(output, /de\.json APP\.SAVE: "Save changes"/);
|
||||
});
|
||||
|
||||
test('reports baseline entries that are no longer identical without failing', () => {
|
||||
writeLocale('en', EN);
|
||||
writeLocale('de', {
|
||||
APP: { TITLE: 'IPTVnator', SAVE: 'Speichern' },
|
||||
PIN: 'PIN-Code',
|
||||
});
|
||||
writeBaseline({
|
||||
de: { 'APP.TITLE': 'IPTVnator', 'APP.SAVE': 'Save', PIN: 'PIN' },
|
||||
fr: { PIN: 'PIN' },
|
||||
});
|
||||
|
||||
const { code, output } = check();
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.match(
|
||||
output,
|
||||
/^note de\.json: 2 baseline entries .*\(APP\.SAVE, PIN\)$/m
|
||||
);
|
||||
assert.match(output, /^note fr: baseline lists a locale with no file$/m);
|
||||
assert.match(
|
||||
output,
|
||||
/^note 3 stale baseline entries; run .*i18n:baseline:update/m
|
||||
);
|
||||
});
|
||||
|
||||
test('a locale without baseline entries fails on every identical value', () => {
|
||||
writeLocale('en', EN);
|
||||
writeLocale('uk', {
|
||||
APP: { TITLE: 'IPTVnator', SAVE: 'Зберегти' },
|
||||
PIN: 'PIN-код',
|
||||
});
|
||||
|
||||
const { code, output } = check();
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.match(output, /uk\.json APP\.TITLE: "IPTVnator"/);
|
||||
});
|
||||
|
||||
test('still fails on missing and extra keys', () => {
|
||||
writeLocale('en', EN);
|
||||
writeLocale('de', { APP: { TITLE: 'IPTVnator', OLD: 'Alt' }, PIN: 'PIN' });
|
||||
writeBaseline({ de: { 'APP.TITLE': 'IPTVnator', PIN: 'PIN' } });
|
||||
|
||||
const { code, output } = check();
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.match(
|
||||
output,
|
||||
/^FAIL de\.json: missing=1 \(APP\.SAVE\) extra=1 \(APP\.OLD\)/m
|
||||
);
|
||||
});
|
||||
|
||||
test('--fail-on-identical ignores the baseline', () => {
|
||||
writeLocale('en', EN);
|
||||
writeLocale('de', {
|
||||
APP: { TITLE: 'IPTVnator', SAVE: 'Speichern' },
|
||||
PIN: 'PIN',
|
||||
});
|
||||
writeBaseline({ de: { 'APP.TITLE': 'IPTVnator', PIN: 'PIN' } });
|
||||
|
||||
const { code, output } = check('--fail-on-identical');
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.match(output, /de\.json APP\.TITLE: "IPTVnator"/);
|
||||
assert.match(output, /de\.json PIN: "PIN"/);
|
||||
});
|
||||
|
||||
test('--update-baseline records current identical values and drops resolved ones', () => {
|
||||
writeLocale('en', EN);
|
||||
writeLocale('de', {
|
||||
APP: { TITLE: 'IPTVnator', SAVE: 'Speichern' },
|
||||
PIN: 'PIN',
|
||||
});
|
||||
writeLocale('fr', {
|
||||
APP: { TITLE: 'IPTVnator', SAVE: 'Enregistrer' },
|
||||
PIN: 'Code PIN',
|
||||
});
|
||||
writeBaseline({ de: { 'APP.SAVE': 'Save' }, fr: { PIN: 'PIN' } });
|
||||
|
||||
const { code, output } = check('--update-baseline');
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.match(output, /^ {2}\+ de PIN: "PIN"$/m);
|
||||
assert.match(output, /Baseline written: 3 added, 2 removed\./);
|
||||
assert.deepEqual(readBaselineFile(), {
|
||||
de: { 'APP.TITLE': 'IPTVnator', PIN: 'PIN' },
|
||||
fr: { 'APP.TITLE': 'IPTVnator' },
|
||||
});
|
||||
assert.equal(check().code, 0);
|
||||
});
|
||||
|
||||
test('--update-baseline keeps the old baseline when a locale is unreadable', () => {
|
||||
writeLocale('en', EN);
|
||||
writeFileSync(path.join(dir, 'de.json'), '{ broken');
|
||||
writeBaseline({ de: { PIN: 'PIN' } });
|
||||
|
||||
const { code, output } = check('--update-baseline');
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.match(output, /baseline not written/);
|
||||
assert.deepEqual(readBaselineFile(), { de: { PIN: 'PIN' } });
|
||||
});
|
||||
|
||||
test('rejects unknown and conflicting flags', () => {
|
||||
writeLocale('en', EN);
|
||||
|
||||
assert.equal(check('--update').code, 1);
|
||||
assert.equal(check('--fail-on-identical', '--update-baseline').code, 1);
|
||||
});
|
||||
|
||||
test('rejects a malformed baseline', () => {
|
||||
assert.throws(() => parseBaseline({ de: ['PIN'] }), /object of keys/);
|
||||
assert.throws(() => parseBaseline({ de: { PIN: 1 } }), /English text/);
|
||||
});
|
||||
|
||||
test('serialises locales and keys in a stable order', () => {
|
||||
const serialized = serializeBaseline(
|
||||
new Map([
|
||||
[
|
||||
'fr',
|
||||
new Map([
|
||||
['B', 'b'],
|
||||
['A', 'a'],
|
||||
]),
|
||||
],
|
||||
['de', new Map()],
|
||||
['ar', new Map([['Z', 'z']])],
|
||||
])
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
serialized,
|
||||
'{\n "ar": {\n "Z": "z"\n },\n "fr": {\n "A": "a",\n "B": "b"\n }\n}\n'
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user