From 0896b1afd176aedbab9f09b2be7066a21e83e5ec Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 7 Apr 2026 17:12:17 +0200 Subject: [PATCH] test(cycles): i18n key parity across all 5 locales Loads de/en/it/fr/es cycles locale files and asserts their flattened key paths are identical. Catches stub copies drifting silently when new keys are added to de/en and forgotten in the others. Also asserts every leaf value is a non-empty string so a missing translation can't masquerade as null or an empty string. Uses 'de' as the reference and renames vitest's 'it' to 'test' to avoid shadowing the 'it.json' import. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../lib/i18n/locales/cycles/parity.test.ts | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 apps/mana/apps/web/src/lib/i18n/locales/cycles/parity.test.ts diff --git a/apps/mana/apps/web/src/lib/i18n/locales/cycles/parity.test.ts b/apps/mana/apps/web/src/lib/i18n/locales/cycles/parity.test.ts new file mode 100644 index 000000000..bc133ac42 --- /dev/null +++ b/apps/mana/apps/web/src/lib/i18n/locales/cycles/parity.test.ts @@ -0,0 +1,72 @@ +/** + * i18n parity test for the cycles module. + * + * Ensures all 5 locale files (de/en/it/fr/es) have identical key + * structure — stub copies of en.json would otherwise drift silently + * as keys are added to de/en and forgotten in the others. + * + * The test does NOT enforce that values are different (stubs are + * allowed); it only enforces that the *shape* (nested key paths) + * matches exactly across all locales. + */ + +import { describe, expect, it as test } from 'vitest'; +import de from './de.json'; +import en from './en.json'; +import itLocale from './it.json'; +import fr from './fr.json'; +import es from './es.json'; + +type Dict = Record; + +/** Flatten an object into dot-separated leaf key paths. */ +function flattenKeys(obj: Dict, prefix = ''): string[] { + const keys: string[] = []; + for (const [k, v] of Object.entries(obj)) { + const path = prefix ? `${prefix}.${k}` : k; + if (v && typeof v === 'object' && !Array.isArray(v)) { + keys.push(...flattenKeys(v as Dict, path)); + } else { + keys.push(path); + } + } + return keys.sort(); +} + +const locales = { + de: de as Dict, + en: en as Dict, + it: itLocale as Dict, + fr: fr as Dict, + es: es as Dict, +}; + +describe('cycles i18n parity', () => { + const referenceKeys = flattenKeys(locales.de); + + test('de has a non-empty set of keys', () => { + expect(referenceKeys.length).toBeGreaterThan(0); + }); + + for (const [lang, dict] of Object.entries(locales)) { + if (lang === 'de') continue; + test(`${lang} matches de key structure`, () => { + const langKeys = flattenKeys(dict); + expect(langKeys).toEqual(referenceKeys); + }); + } + + test('no locale has empty string values', () => { + for (const [lang, dict] of Object.entries(locales)) { + const flat = flattenKeys(dict); + for (const keyPath of flat) { + const value = keyPath.split('.').reduce((acc, segment) => { + if (acc && typeof acc === 'object') return (acc as Dict)[segment]; + return undefined; + }, dict); + expect(value, `${lang}.${keyPath}`).not.toBe(''); + expect(typeof value, `${lang}.${keyPath}`).toBe('string'); + } + } + }); +});