From 0511e9b5bb6812642c8c49288ef009697ae5dbf7 Mon Sep 17 00:00:00 2001 From: screentinker Date: Tue, 18 Aug 2026 09:37:56 -0500 Subject: [PATCH] Let a locale ship without every string translated (#286) Follow-up to #285. Three corrections that rode in with the Japanese locale. 1. The ja key-parity check failed the build whenever en.js had a key ja.js lacked. i18n.js lookup() is already `registry[lang]?.[key] ?? fallback[key] ?? key`, so an untranslated string renders in English and nothing is broken by a gap - the only effect was that adding any English string blocked CI until a Japanese translation existed. It also singled out one locale; es/fr/de/pt/hi/it were never held to it, and hi.js is a deliberate skeleton whose own header explains that every key falls back to English on purpose. Replaced with two checks over EVERY locale: a locale may not define a key that English does not (dead weight after a rename, and fixable by whoever touched the file, whatever language they speak), and coverage is printed rather than gated. Help tips still have to exist everywhere - that test is unchanged and still fails. Current coverage: ja 100%, es 65.5%, fr/de/pt 63.7%, it 59.5%, hi 0%. 2. Applying the strict half to all locales immediately found add_display.smart_tv_note living in fr, pt, it and de but not in en.js and referenced by no view - a string dropped from English that left four translations behind. Removed. 3. The new timezone test restored process.env.TZ by assigning the saved value back. When TZ was not set to begin with - which is the case in CI - that assigns undefined, which writes the STRING "undefined"; Node cannot parse it and silently falls back to UTC for the rest of the process. Every test after it in that file is date arithmetic. It now deletes the key when it was previously unset. 4. package-lock.json removed from .gitignore. server/package-lock.json is tracked, so the rule was inert, but it would silently prevent a future lockfile and works against the SBOM and reproducible-install setup added in #282. Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56 Co-authored-by: Dan Walters Co-authored-by: Claude Opus 5 --- .gitignore | 1 - frontend/js/i18n/de.js | 1 - frontend/js/i18n/fr.js | 1 - frontend/js/i18n/it.js | 1 - frontend/js/i18n/pt.js | 1 - server/test/i18n-keys-exist.test.js | 44 ++++++++++++++++--- .../test/schedule-calendar-expansion.test.js | 7 ++- 7 files changed, 43 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 82f6d0a..80dac16 100644 --- a/.gitignore +++ b/.gitignore @@ -63,4 +63,3 @@ audit/ # Generated by scripts/license-check.js --sbom (CI publishes it as a release asset) sbom/ *.cdx.json -package-lock.json diff --git a/frontend/js/i18n/de.js b/frontend/js/i18n/de.js index eb07dec..5e4d6cb 100644 --- a/frontend/js/i18n/de.js +++ b/frontend/js/i18n/de.js @@ -1204,6 +1204,5 @@ export default { 'add_display.web_player': 'Web-Player', 'add_display.raspberry_pi': 'Raspberry Pi', 'add_display.windows': 'Windows', - 'add_display.smart_tv_note': 'Smart TVs (LG/Samsung): öffnen Sie den integrierten Browser und navigieren Sie zu /player', 'add_display.pair_btn': 'Bildschirm koppeln', }; diff --git a/frontend/js/i18n/fr.js b/frontend/js/i18n/fr.js index f144733..1ab29eb 100644 --- a/frontend/js/i18n/fr.js +++ b/frontend/js/i18n/fr.js @@ -1204,6 +1204,5 @@ export default { 'add_display.web_player': 'Lecteur web', 'add_display.raspberry_pi': 'Raspberry Pi', 'add_display.windows': 'Windows', - 'add_display.smart_tv_note': 'Smart TVs (LG/Samsung) : ouvrez le navigateur intégré et allez à /player', 'add_display.pair_btn': 'Apparier l\'écran', }; diff --git a/frontend/js/i18n/it.js b/frontend/js/i18n/it.js index a69a088..5588a63 100644 --- a/frontend/js/i18n/it.js +++ b/frontend/js/i18n/it.js @@ -1162,6 +1162,5 @@ export default { 'add_display.web_player': 'Web Player', 'add_display.raspberry_pi': 'Raspberry Pi', 'add_display.windows': 'Windows', - 'add_display.smart_tv_note': 'Smart TV (LG/Samsung): apri il browser integrato e vai su /player', 'add_display.pair_btn': 'Associa Schermo', }; diff --git a/frontend/js/i18n/pt.js b/frontend/js/i18n/pt.js index 7da268a..a842510 100644 --- a/frontend/js/i18n/pt.js +++ b/frontend/js/i18n/pt.js @@ -1204,6 +1204,5 @@ export default { 'add_display.web_player': 'Player web', 'add_display.raspberry_pi': 'Raspberry Pi', 'add_display.windows': 'Windows', - 'add_display.smart_tv_note': 'Smart TVs (LG/Samsung): abra o navegador integrado e vá para /player', 'add_display.pair_btn': 'Parear tela', }; diff --git a/server/test/i18n-keys-exist.test.js b/server/test/i18n-keys-exist.test.js index 19feb22..96f81bd 100644 --- a/server/test/i18n-keys-exist.test.js +++ b/server/test/i18n-keys-exist.test.js @@ -102,13 +102,43 @@ test('every help tip is translated in every active locale', () => { `these tips fall back to English:\n ${missing.join('\n ')}`); }); -test('Japanese mirrors every key in the English source', () => { - const ja = fs.readFileSync(path.join(FRONTEND, 'i18n', 'ja.js'), 'utf8'); - const japaneseKeys = new Set([...ja.matchAll(/^\s*'([^']+)'\s*:/gm)].map(m => m[1])); - assert.deepEqual([...defined].filter(k => !japaneseKeys.has(k)), [], - 'ja.js is missing keys from en.js'); - assert.deepEqual([...japaneseKeys].filter(k => !defined.has(k)), [], - 'ja.js contains keys that do not exist in en.js'); +// Every locale shipped in frontend/js/i18n. Keep in step with the registry in i18n.js. +const ACTIVE_LOCALES = ['es', 'fr', 'de', 'pt', 'hi', 'it', 'ja']; + +test('a locale never defines a key that English does not', () => { + // The half of parity that is ALWAYS actionable: a key in a locale file that no longer exists in + // en.js is dead weight or a typo left behind by a rename, and whoever touched that file can fix + // it without speaking the language. Missing keys are the other direction - see below. + for (const locale of ACTIVE_LOCALES) { + const src = fs.readFileSync(path.join(FRONTEND, 'i18n', `${locale}.js`), 'utf8'); + const keys = new Set([...src.matchAll(/^\s*'([^']+)'\s*:/gm)].map(m => m[1])); + assert.deepEqual([...keys].filter(k => !defined.has(k)), [], + `${locale}.js defines keys that do not exist in en.js`); + } +}); + +test('translation coverage is reported, but an untranslated string is not a build failure', () => { + // ⚠️ WHY A MISSING TRANSLATION DOES NOT FAIL THE BUILD. + // + // i18n.js lookup() is `registry[lang]?.[key] ?? fallback[key] ?? key`, so an untranslated string + // already renders in English. Nothing is broken by a gap. + // + // Making the gap fatal - as the first version of the Japanese check did - means every new English + // string blocks CI until someone who reads that language is available. That is a guarantee we + // cannot keep, and it puts the cost on whoever is shipping the feature rather than on whoever can + // actually translate. It also singled out one locale: es, fr, de, pt, hi and it were never held + // to it. + // + // So: report the number, do not gate on it. The strings that genuinely must exist everywhere are + // the help tips, and they have their own test above, which does fail. + for (const locale of ACTIVE_LOCALES) { + const src = fs.readFileSync(path.join(FRONTEND, 'i18n', `${locale}.js`), 'utf8'); + const keys = new Set([...src.matchAll(/^\s*'([^']+)'\s*:/gm)].map(m => m[1])); + const missing = [...defined].filter(k => !keys.has(k)); + const pct = ((defined.size - missing.length) / defined.size * 100).toFixed(1); + console.log(` ${locale}: ${defined.size - missing.length}/${defined.size} (${pct}%)` + + (missing.length ? ` - ${missing.length} fall back to English` : '')); + } }); test('a tip marker in a view always names a real string', () => { diff --git a/server/test/schedule-calendar-expansion.test.js b/server/test/schedule-calendar-expansion.test.js index b9312d9..ae551e2 100644 --- a/server/test/schedule-calendar-expansion.test.js +++ b/server/test/schedule-calendar-expansion.test.js @@ -38,6 +38,10 @@ const mk = (recurrence, startISO, recurrenceEnd = null) => ({ const weekdaysOf = (events) => events.map(e => new Date(e.instance_start).getDay()).sort(); test('a date-only week anchor retains its calendar day west of UTC', () => { + // ⚠️ Record WHETHER it was set, not just its value. `process.env.TZ = undefined` writes the + // STRING "undefined", which Node cannot parse and silently resolves to UTC - changing the zone + // for every test that runs after this one in this file, all of which are date arithmetic. + const hadTz = Object.prototype.hasOwnProperty.call(process.env, 'TZ'); const originalTz = process.env.TZ; process.env.TZ = 'America/Los_Angeles'; try { @@ -50,7 +54,8 @@ test('a date-only week anchor retains its calendar day west of UTC', () => { assert.equal(selected.getDate(), 9); assert.equal(selected.getDay(), 0, 'Sunday remains Sunday'); } finally { - process.env.TZ = originalTz; + if (hadTz) process.env.TZ = originalTz; + else delete process.env.TZ; } });