i18n: parity checks itself, over the locales actually on disk
master was out of parity and every test was green. sv-SE named commandHq.referencePath and referencePathHint; en-GB named workingDirectory and cwdHint. Two reasons neither was caught. The three parity tests LISTED their locales literally, so adding a sixth added a file none of them looked at. A guard that has to be maintained to keep guarding is the thing it exists to prevent — the same shape as the bug that made three locales ship dead last night. They now read the directory. And findMissingKeys is one-directional: it asks whether every English key is present, so a rename reports half of itself. The new key is missing and caught; the stale one the translation still carries is invisible. findExtraKeys is the other half, and both are asserted. Realigns sv-SE onto the committed key names. It was translated from a working tree where that rename existed and was not yet committed — the commander directory work, still unreviewed — so on master it named keys English did not have. The rename will land across all six locales together when that work does. Also asserts there IS at least one locale besides en-GB, because otherwise every assertion passes over an empty set and reports nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -1,15 +1,37 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { findMissingKeys } from 'i18n';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { findMissingKeys, findExtraKeys } from 'i18n';
|
||||
import en from './en-GB.json';
|
||||
import ptPT from './pt-PT.json';
|
||||
import frFR from './fr-FR.json';
|
||||
import deDE from './de-DE.json';
|
||||
import esES from './es-ES.json';
|
||||
|
||||
// D2: every shipped locale ships a COMPLETE catalog. A key en-GB has and another locale lacks is a red
|
||||
// build, not a warning — there is no regional fallback to paper over the gap.
|
||||
/**
|
||||
* Parity, over whatever locales are actually ON DISK — not a list written here.
|
||||
*
|
||||
* This used to name its locales literally, and that made it a check you had to remember to update. Adding
|
||||
* `sv-SE` added a file no parity test looked at, so it shipped out of step and every test stayed green.
|
||||
* A guard that needs maintaining to keep guarding is the thing it was written to prevent.
|
||||
*
|
||||
* Both directions, too. `findMissingKeys` alone reports half of a rename: the new key is missing from the
|
||||
* translation and caught, while the stale one it still carries is invisible. That is exactly how sv-SE
|
||||
* ended up naming two keys en-GB did not have.
|
||||
*/
|
||||
const LOCALES: Record<string, Record<string, unknown>> = Object.fromEntries(
|
||||
readdirSync(__dirname)
|
||||
.filter((f) => f.endsWith('.json') && f !== 'en-GB.json')
|
||||
.map((f) => [f.replace(/\.json$/, ''), JSON.parse(readFileSync(join(__dirname, f), 'utf8'))]),
|
||||
);
|
||||
|
||||
describe('shared components (ui.*) locale catalogs', () => {
|
||||
test('every shipped locale has every key en-GB has', () => {
|
||||
expect(findMissingKeys(en, { 'pt-PT': ptPT, 'fr-FR': frFR, 'de-DE': deDE, 'es-ES': esES })).toEqual([]);
|
||||
test('there is at least one locale besides en-GB', () => {
|
||||
// Otherwise every assertion below passes over an empty set and says nothing.
|
||||
expect(Object.keys(LOCALES).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('every locale has every key en-GB has', () => {
|
||||
expect(findMissingKeys(en, LOCALES)).toEqual([]);
|
||||
});
|
||||
|
||||
test('no locale has a key en-GB does not', () => {
|
||||
expect(findExtraKeys(en, LOCALES)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ export { I18nProvider, I18nContext } from './I18nProvider';
|
||||
export { useTranslation } from './useTranslation';
|
||||
export { useFormat } from './useFormat';
|
||||
export { resolveKey, interpolate, isPluralForms, localeDirection } from './resolve';
|
||||
export { findMissingKeys } from './parity';
|
||||
export { findMissingKeys, findExtraKeys } from './parity';
|
||||
// Deliberately NOT re-exporting translateStandalone — see standalone.ts on why it is awkward to reach.
|
||||
export { activeLocale } from './standalone';
|
||||
export type { MissingKey } from './parity';
|
||||
|
||||
@@ -25,3 +25,24 @@ export const findMissingKeys = (en: Catalog, locales: Record<string, Catalog>):
|
||||
return enKeys.filter((key) => !ownKeys.has(key)).map((key) => ({ locale, key }));
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Keys a locale has that `en-GB` does NOT — the other direction, and it is not symmetry for its own sake.
|
||||
*
|
||||
* A locale written against a source that has since changed keeps the old names, and `findMissingKeys`
|
||||
* cannot see that: it only asks whether every English key is present. Rename a key and the translated
|
||||
* catalogue holds both the new one (missing, caught) and the stale one (extra, invisible), so half the
|
||||
* problem is reported and the file quietly grows dead entries.
|
||||
*
|
||||
* It happened the day sv-SE landed. It was translated from a working tree where two keys had been renamed
|
||||
* and that rename was not yet committed, so on `master` the Swedish catalogue named two keys English did
|
||||
* not have — and every parity test passed, because none of them looked in this direction.
|
||||
*/
|
||||
export const findExtraKeys = (en: Catalog, locales: Record<string, Catalog>): MissingKey[] => {
|
||||
const enKeys = new Set(flattenKeys(en));
|
||||
return Object.entries(locales).flatMap(([locale, catalog]) =>
|
||||
flattenKeys(catalog)
|
||||
.filter((key) => !enKeys.has(key))
|
||||
.map((key) => ({ locale, key })),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,37 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { findMissingKeys } from 'i18n';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { findMissingKeys, findExtraKeys } from 'i18n';
|
||||
import en from './en-GB.json';
|
||||
import ptPT from './pt-PT.json';
|
||||
import frFR from './fr-FR.json';
|
||||
import deDE from './de-DE.json';
|
||||
import esES from './es-ES.json';
|
||||
|
||||
// D2: every shipped locale ships a COMPLETE catalog. A key en-GB has and another locale lacks is a red
|
||||
// build, not a warning — there is no regional fallback to paper over the gap.
|
||||
/**
|
||||
* Parity, over whatever locales are actually ON DISK — not a list written here.
|
||||
*
|
||||
* This used to name its locales literally, and that made it a check you had to remember to update. Adding
|
||||
* `sv-SE` added a file no parity test looked at, so it shipped out of step and every test stayed green.
|
||||
* A guard that needs maintaining to keep guarding is the thing it was written to prevent.
|
||||
*
|
||||
* Both directions, too. `findMissingKeys` alone reports half of a rename: the new key is missing from the
|
||||
* translation and caught, while the stale one it still carries is invisible. That is exactly how sv-SE
|
||||
* ended up naming two keys en-GB did not have.
|
||||
*/
|
||||
const LOCALES: Record<string, Record<string, unknown>> = Object.fromEntries(
|
||||
readdirSync(__dirname)
|
||||
.filter((f) => f.endsWith('.json') && f !== 'en-GB.json')
|
||||
.map((f) => [f.replace(/\.json$/, ''), JSON.parse(readFileSync(join(__dirname, f), 'utf8'))]),
|
||||
);
|
||||
|
||||
describe('officerdev (panel.*) locale catalogs', () => {
|
||||
test('every shipped locale has every key en-GB has', () => {
|
||||
expect(findMissingKeys(en, { 'pt-PT': ptPT, 'fr-FR': frFR, 'de-DE': deDE, 'es-ES': esES })).toEqual([]);
|
||||
test('there is at least one locale besides en-GB', () => {
|
||||
// Otherwise every assertion below passes over an empty set and says nothing.
|
||||
expect(Object.keys(LOCALES).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('every locale has every key en-GB has', () => {
|
||||
expect(findMissingKeys(en, LOCALES)).toEqual([]);
|
||||
});
|
||||
|
||||
test('no locale has a key en-GB does not', () => {
|
||||
expect(findExtraKeys(en, LOCALES)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -207,8 +207,8 @@
|
||||
"startConversation": "Starta konversationen",
|
||||
"name": "Namn",
|
||||
"nameHint": "Används för konversationen från start, i stället för att harnesset döper den efter ditt första meddelande.",
|
||||
"referencePath": "Referenssökväg",
|
||||
"referencePathHint": "Vad denna commander bevakar eller arbetar med — inte var den bor. Den kan lägga till fler av dessa själv när den väl körs.",
|
||||
"workingDirectory": "Arbetskatalog",
|
||||
"cwdHint": "Varifrån varje kommando den kör utgår, och var dess historik sparas. Den kan inte flyttas efteråt.",
|
||||
"new": "Ny commander",
|
||||
"spawned": "Startade",
|
||||
"deleteQ": "Ta bort?",
|
||||
|
||||
Reference in New Issue
Block a user