diff --git a/src/apps/officer-web/locales/parity.test.ts b/src/apps/officer-web/locales/parity.test.ts index e83a6dba..4354ff5a 100644 --- a/src/apps/officer-web/locales/parity.test.ts +++ b/src/apps/officer-web/locales/parity.test.ts @@ -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> = 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('officer-web (app.*) 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([]); }); }); diff --git a/src/workspaces/components/locales/parity.test.ts b/src/workspaces/components/locales/parity.test.ts index ad20e630..f22a0377 100644 --- a/src/workspaces/components/locales/parity.test.ts +++ b/src/workspaces/components/locales/parity.test.ts @@ -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> = 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([]); }); }); diff --git a/src/workspaces/i18n/src/index.ts b/src/workspaces/i18n/src/index.ts index 63a6bd04..0d9a36e4 100644 --- a/src/workspaces/i18n/src/index.ts +++ b/src/workspaces/i18n/src/index.ts @@ -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'; diff --git a/src/workspaces/i18n/src/parity.ts b/src/workspaces/i18n/src/parity.ts index 286cb408..19327e02 100644 --- a/src/workspaces/i18n/src/parity.ts +++ b/src/workspaces/i18n/src/parity.ts @@ -25,3 +25,24 @@ export const findMissingKeys = (en: Catalog, locales: Record): 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): 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 })), + ); +}; diff --git a/src/workspaces/officerdev/src/locales/parity.test.ts b/src/workspaces/officerdev/src/locales/parity.test.ts index 69c21aea..e01046ce 100644 --- a/src/workspaces/officerdev/src/locales/parity.test.ts +++ b/src/workspaces/officerdev/src/locales/parity.test.ts @@ -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> = 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([]); }); }); diff --git a/src/workspaces/officerdev/src/locales/sv-SE.json b/src/workspaces/officerdev/src/locales/sv-SE.json index 597ba81f..459ee1d5 100644 --- a/src/workspaces/officerdev/src/locales/sv-SE.json +++ b/src/workspaces/officerdev/src/locales/sv-SE.json @@ -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?",