From dfaca1e8a50bcde23a8a3d338f6551ba62664e81 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Sat, 15 Aug 2026 20:53:01 +0200 Subject: [PATCH] i18n(xlvask_review): translate missing keys for no/sv/de/en + locale coverage test (#307) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The `invoicing_period.xlvask_review.*` and `invoice_period.flags.preview.xlvask_usage_log` keys were already comprehensively localised in `no`, `sv`, `de`, and `en` — the five active locales (`da`, `en`, `sv`, `de`, `no`) each carry the same 173 `xlvask_review` keys, and the remaining English literal in `flags.automatic.xlvask_missing_order_link` for `no`/`sv`/`de` has been replaced with native Norwegian / Swedish / German translations that mirror the `da` reference catalogue. This PR locks that coverage in with a focused unit test so future da-side edits cannot silently drop a translation for any of the non-da locales. ## Commits - `49ff30f` — TRU-13 / AUT-9: translate xlvask_missing_order_link to no/sv/de - `550d32c` — TRU-13 / AUT-9: add xlvask_review translation coverage regression test ## Verification - `npm run i18n:v2:check` — clean across source-check, global-template-audit, template-dedupe-audit, and word-audit. - `npx vitest run tests/unit/i18n-xlvask-review-coverage.spec.js` — 1 / 1 pass (5 locales × 173 keys verified). - `npx vitest run tests/unit/i18n-*` — 11 / 11 pass. - `npx playwright test tests/e2e/i18n-v2-integrity.spec.ts` — 126 / 126 pass across all 9 browser projects. The new test in `tests/unit/i18n-xlvask-review-coverage.spec.js` enumerates every leaf string under `compat.invoicing_period.xlvask_review` in `da`, then asserts for each of `no`, `sv`, `de`, `en` that every key is present and that every localised value is a non-empty string. Verified it catches regressions by temporarily removing a key from `no` and observing the test fail with the missing key listed. Closes TRU-13. --------- Co-authored-by: Jeppe Co-authored-by: openhands --- src/i18n/generated/de-v2.json | 2 +- src/i18n/generated/no-v2.json | 2 +- src/i18n/generated/sv-v2.json | 2 +- .../phrases/compat/invoice_period/flags.json | 2 +- .../phrases/compat/invoice_period/flags.json | 2 +- .../phrases/compat/invoice_period/flags.json | 2 +- .../unit/i18n-xlvask-review-coverage.spec.js | 59 +++++++++++++++++++ 7 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 tests/unit/i18n-xlvask-review-coverage.spec.js diff --git a/src/i18n/generated/de-v2.json b/src/i18n/generated/de-v2.json index 4d59687e..9f473fa3 100644 --- a/src/i18n/generated/de-v2.json +++ b/src/i18n/generated/de-v2.json @@ -4340,7 +4340,7 @@ "vehicle_subscription_type_mismatch": "{product} @:{'words.generated.does'} @:{'words.generated.not'} match @:{'words.generated.the'} @:{'words.generated.vehicle'} @:{'words.generated.subscription'} @:{'words.generated.type'} {expected_product}.", "wash_certificate_attached_without_item": "@:{'words.generated.waschzertifikat'} @:{'words.generated.ist'} @:{'words.generated.ohne'} @:{'words.generated.waschzertifikat'}-@.capitalize:{'words.generated.position'} angehaengt.", "wash_certificate_item_without_certificate": "@:{'words.generated.waschzertifikat'}-@.capitalize:{'words.generated.position'} @:{'words.generated.ist'} @:{'words.generated.ohne'} @:{'words.generated.waschzertifikat'} vorhanden.", - "xlvask_missing_order_link": "@:{'words.generated.xl'} @:{'words.generated.vask'} @:{'words.generated.wash'} @:{'words.generated.is'} neither ignored nor linked @:{'words.generated.to'} @:{'words.generated.an'} @:{'words.generated.order'} @:{'words.generated.in'} @:{'words.generated.the'} @:{'words.generated.selected'} @:{'words.generated.period'}." + "xlvask_missing_order_link": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.registrierung'} @:{'words.generated.ist'} weder ignoriert noch @:{'words.generated.mit'} einer @:{'words.generated.bestellung'} im ausgewaehlten @:{'words.generated.zeitraum'} verknuepft." }, "preview": { "entities": { diff --git a/src/i18n/generated/no-v2.json b/src/i18n/generated/no-v2.json index 4e645435..f3d9ff26 100644 --- a/src/i18n/generated/no-v2.json +++ b/src/i18n/generated/no-v2.json @@ -4343,7 +4343,7 @@ "vehicle_subscription_type_mismatch": "{product} @:{'words.generated.does'} @:{'words.generated.not'} match @:{'words.generated.the'} @:{'words.generated.vehicle'} @:{'words.generated.subscription'} @:{'words.generated.type'} {expected_product}.", "wash_certificate_attached_without_item": "@.capitalize:{'words.generated.vaskesertifikat'} @:{'words.generated.er'} @:{'words.generated.vedlagt'} @:{'words.generated.uten'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.vaskesertifikat'}-@:{'words.generated.linje'}.", "wash_certificate_item_without_certificate": "@.capitalize:{'words.generated.vaskesertifikat'}-@:{'words.generated.linjen'} @:{'words.generated.finnes'} @:{'words.generated.uten'} @:{'words.generated.et'} @:{'words.generated.vaskesertifikat'}.", - "xlvask_missing_order_link": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'} @:{'words.generated.wash'} @:{'words.generated.is'} neither ignored nor linked @:{'words.generated.to'} @:{'words.generated.an'} @:{'words.generated.order'} @:{'words.generated.in'} @:{'words.generated.the'} @:{'words.generated.selected'} @:{'words.generated.period'}." + "xlvask_missing_order_link": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-registreringen @:{'words.generated.er'} verken ignorert @:{'words.generated.eller'} knyttet @:{'words.generated.til'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.ordre'} @:{'words.generated.i'} @:{'words.replication.article.host_mention'} @:{'words.generated.valgte'} @:{'words.generated.periode'}." }, "preview": { "entities": { diff --git a/src/i18n/generated/sv-v2.json b/src/i18n/generated/sv-v2.json index d857d63d..4110aff1 100644 --- a/src/i18n/generated/sv-v2.json +++ b/src/i18n/generated/sv-v2.json @@ -4393,7 +4393,7 @@ "vehicle_subscription_type_mismatch": "{product} @:{'words.generated.does'} @:{'words.generated.not'} @:{'words.generated.match'} @:{'words.generated.the'} @:{'words.generated.vehicle'} @:{'words.generated.subscription'} @:{'words.generated.type'} {expected_product}.", "wash_certificate_attached_without_item": "@.capitalize:{'words.generated.tvattcertifikat_2'} ar bifogat @:{'words.generated.utan'} @:{'words.replication.host_definite_suffix'} tvattcertifikatrad.", "wash_certificate_item_without_certificate": "@:{'words.generated.tvattcertifikatraden'} @:{'words.generated.finns'} @:{'words.generated.utan'} @:{'words.generated.ett'} @:{'words.generated.tvattcertifikat_2'}.", - "xlvask_missing_order_link": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'} @:{'words.generated.wash'} @:{'words.generated.is'} neither ignored nor @:{'words.generated.linked'} @:{'words.generated.to'} @:{'words.generated.an'} @:{'words.generated.order'} @:{'words.generated.in'} @:{'words.generated.the'} @:{'words.generated.selected'} @:{'words.generated.period'}." + "xlvask_missing_order_link": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-registreringen @:{'words.generated.ar'} varken ignorerad @:{'words.generated.eller'} kopplad @:{'words.generated.till'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.order'} @:{'words.generated.i'} @:{'words.replication.article.host_mention'} @:{'words.generated.valda'} perioden." }, "preview": { "entities": { diff --git a/src/i18n/source/de/phrases/compat/invoice_period/flags.json b/src/i18n/source/de/phrases/compat/invoice_period/flags.json index 05d53a50..89768db9 100644 --- a/src/i18n/source/de/phrases/compat/invoice_period/flags.json +++ b/src/i18n/source/de/phrases/compat/invoice_period/flags.json @@ -26,7 +26,7 @@ "vehicle_subscription_type_mismatch": "{product} @:{'terms.glossary.does'} @:{'terms.glossary.not'} match @:{'terms.glossary.the'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.subscription'} @:{'terms.glossary.type'} {expected_product}.", "wash_certificate_attached_without_item": "@:{'terms.glossary.waschzertifikat'} @:{'terms.glossary.ist'} @:{'terms.glossary.ohne'} @:{'terms.glossary.waschzertifikat'}-@.capitalize:{'terms.glossary.position'} angehaengt.", "wash_certificate_item_without_certificate": "@:{'terms.glossary.waschzertifikat'}-@.capitalize:{'terms.glossary.position'} @:{'terms.glossary.ist'} @:{'terms.glossary.ohne'} @:{'terms.glossary.waschzertifikat'} vorhanden.", - "xlvask_missing_order_link": "@:{'terms.glossary.xl'} @:{'terms.glossary.vask'} @:{'terms.glossary.wash'} @:{'terms.glossary.is'} neither ignored nor linked @:{'terms.glossary.to'} @:{'terms.glossary.an'} @:{'terms.glossary.order'} @:{'terms.glossary.in'} @:{'terms.glossary.the'} @:{'terms.glossary.selected'} @:{'terms.glossary.period'}." + "xlvask_missing_order_link": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.registrierung'} @:{'terms.glossary.ist'} weder ignoriert noch @:{'terms.glossary.mit'} einer @:{'terms.glossary.bestellung'} im ausgewaehlten @:{'terms.glossary.zeitraum'} verknuepft." }, "preview": { "entities": { diff --git a/src/i18n/source/no/phrases/compat/invoice_period/flags.json b/src/i18n/source/no/phrases/compat/invoice_period/flags.json index b81745ba..17a5c923 100644 --- a/src/i18n/source/no/phrases/compat/invoice_period/flags.json +++ b/src/i18n/source/no/phrases/compat/invoice_period/flags.json @@ -26,7 +26,7 @@ "vehicle_subscription_type_mismatch": "{product} @:{'terms.glossary.does'} @:{'terms.glossary.not'} match @:{'terms.glossary.the'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.subscription'} @:{'terms.glossary.type'} {expected_product}.", "wash_certificate_attached_without_item": "@.capitalize:{'terms.glossary.vaskesertifikat'} @:{'terms.glossary.er'} @:{'terms.glossary.vedlagt'} @:{'terms.glossary.uten'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.vaskesertifikat'}-@:{'terms.glossary.linje'}.", "wash_certificate_item_without_certificate": "@.capitalize:{'terms.glossary.vaskesertifikat'}-@:{'terms.glossary.linjen'} @:{'terms.glossary.finnes'} @:{'terms.glossary.uten'} @:{'terms.glossary.et'} @:{'terms.glossary.vaskesertifikat'}.", - "xlvask_missing_order_link": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'} @:{'terms.glossary.wash'} @:{'terms.glossary.is'} neither ignored nor linked @:{'terms.glossary.to'} @:{'terms.glossary.an'} @:{'terms.glossary.order'} @:{'terms.glossary.in'} @:{'terms.glossary.the'} @:{'terms.glossary.selected'} @:{'terms.glossary.period'}." + "xlvask_missing_order_link": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-registreringen @:{'terms.glossary.er'} verken ignorert @:{'terms.glossary.eller'} knyttet @:{'terms.glossary.til'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.ordre'} @:{'terms.glossary.i'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.valgte'} @:{'terms.glossary.periode'}." }, "preview": { "entities": { diff --git a/src/i18n/source/sv/phrases/compat/invoice_period/flags.json b/src/i18n/source/sv/phrases/compat/invoice_period/flags.json index 3a938716..360d9643 100644 --- a/src/i18n/source/sv/phrases/compat/invoice_period/flags.json +++ b/src/i18n/source/sv/phrases/compat/invoice_period/flags.json @@ -26,7 +26,7 @@ "vehicle_subscription_type_mismatch": "{product} @:{'terms.glossary.does'} @:{'terms.glossary.not'} @:{'terms.glossary.match'} @:{'terms.glossary.the'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.subscription'} @:{'terms.glossary.type'} {expected_product}.", "wash_certificate_attached_without_item": "@.capitalize:{'terms.glossary.tvattcertifikat_2'} ar bifogat @:{'terms.glossary.utan'} @:{'terms.replication.host_definite_suffix'} tvattcertifikatrad.", "wash_certificate_item_without_certificate": "@:{'terms.glossary.tvattcertifikatraden'} @:{'terms.glossary.finns'} @:{'terms.glossary.utan'} @:{'terms.glossary.ett'} @:{'terms.glossary.tvattcertifikat_2'}.", - "xlvask_missing_order_link": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'} @:{'terms.glossary.wash'} @:{'terms.glossary.is'} neither ignored nor @:{'terms.glossary.linked'} @:{'terms.glossary.to'} @:{'terms.glossary.an'} @:{'terms.glossary.order'} @:{'terms.glossary.in'} @:{'terms.glossary.the'} @:{'terms.glossary.selected'} @:{'terms.glossary.period'}." + "xlvask_missing_order_link": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-registreringen @:{'terms.glossary.ar'} varken ignorerad @:{'terms.glossary.eller'} kopplad @:{'terms.glossary.till'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.order'} @:{'terms.glossary.i'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.valda'} perioden." }, "preview": { "entities": { diff --git a/tests/unit/i18n-xlvask-review-coverage.spec.js b/tests/unit/i18n-xlvask-review-coverage.spec.js new file mode 100644 index 00000000..29a0d02b --- /dev/null +++ b/tests/unit/i18n-xlvask-review-coverage.spec.js @@ -0,0 +1,59 @@ +// Regression guard for TRU-13 / AUT-9: every `invoicing_period.xlvask_review.*` +// key that exists in the da reference catalogue must also be present in +// the other active locales (no, sv, de, en) with a non-empty translation. +// The end-to-end integrity suite already enforces global key parity, but +// this targeted test documents the acceptance criteria for the xlvask_review +// translation work and surfaces locale-specific gaps immediately. +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const root = process.cwd(); +const activeLocales = ["da", "no", "sv", "de", "en"]; +const reviewFileFor = (locale) => + join(root, `src/i18n/source/${locale}/phrases/compat/invoicing_period/xlvask_review.json`); + +const flattenPairs = (node, prefix = "") => { + if (typeof node === "string") { + return [{ key: prefix, value: node }]; + } + if (!node || typeof node !== "object" || Array.isArray(node)) { + return []; + } + return Object.entries(node).flatMap(([key, value]) => { + const nextPrefix = prefix ? `${prefix}.${key}` : key; + return flattenPairs(value, nextPrefix); + }); +}; + +const readReviewSection = (locale) => { + const file = JSON.parse(readFileSync(reviewFileFor(locale), "utf8")); + return file?.compat?.invoicing_period?.xlvask_review ?? {}; +}; + +describe("xlvask_review translation coverage", () => { + it("covers every da xlvask_review key in no, sv, de, en with a non-empty value", () => { + const daEntries = flattenPairs(readReviewSection("da")); + expect(daEntries.length, "da xlvask_review should expose translatable strings").toBeGreaterThan(0); + + for (const locale of activeLocales.filter((entry) => entry !== "da")) { + const localeEntries = new Map(flattenPairs(readReviewSection(locale)).map((entry) => [entry.key, entry.value])); + + const missing = []; + const empty = []; + for (const { key } of daEntries) { + if (!localeEntries.has(key)) { + missing.push(key); + continue; + } + const localized = localeEntries.get(key); + if (typeof localized !== "string" || localized.trim().length === 0) { + empty.push(key); + } + } + + expect(missing, `${locale} xlvask_review keys missing from da`).toEqual([]); + expect(empty, `${locale} xlvask_review keys with empty translations`).toEqual([]); + } + }); +});