Files
iptvnator/tools/i18n/check-drift.test.mjs
T
4grayandClaude Opus 5.5 0768ae5ea2 ci(i18n): fail on new English-identical translations (#1793)
* 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>

* fix(i18n): keep the baseline intact on failed updates and strict audits

`--update-baseline` now writes nothing while any locale is unreadable or
has missing or extra keys, so an incomplete translation cannot reshape
the baseline. `--fail-on-identical` no longer reads the baseline it
ignores, so a damaged file cannot block a strict audit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:00:11 +02:00

279 lines
8.1 KiB
JavaScript

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('--update-baseline keeps the old baseline while keys are missing or extra', () => {
writeLocale('en', EN);
writeLocale('de', { APP: { TITLE: 'IPTVnator', OLD: 'Alt' }, PIN: 'PIN' });
writeBaseline({ de: { 'APP.SAVE': 'Save', PIN: 'PIN' } });
const { code, output } = check('--update-baseline');
assert.equal(code, 1);
assert.match(output, /baseline not written/);
assert.deepEqual(readBaselineFile(), {
de: { 'APP.SAVE': 'Save', PIN: 'PIN' },
});
});
test('--fail-on-identical audits even when the baseline is damaged', () => {
writeLocale('en', EN);
writeLocale('de', {
APP: { TITLE: 'IPTVnator', SAVE: 'Speichern' },
PIN: 'PIN-Code',
});
writeFileSync(baselinePath, '{ broken');
const { code, output } = check('--fail-on-identical');
assert.equal(code, 1);
assert.match(output, /^FAIL de\.json: missing=0 extra=0 identical_en=1$/m);
assert.match(output, /de\.json APP\.TITLE: "IPTVnator"/);
assert.equal(check().code, 1);
});
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'
);
});