Files
gods-eye-view/src/i18n/catalog.test.mjs
T
Meir Michanie bde1e430ef feat(i18n): locale core + English catalogs + parity gates
Add the i18n foundation: src/i18n/locale.js (locale resolution with a
configurable GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE pair, guarded
preference storage, one-shot ?lang= override, <html lang>/<html dir>
metadata) and src/i18n/index.js (catalog registry, t(), Intl formatters,
applyDocumentTranslations, persistLocaleAndReload).

English is the only shipped locale in this PR: the four en catalogs
(shell/cockpit/layers/setup) are the reference and the always-shipped
fallback, the built-in pair degenerates to en-only, and English output
is byte-identical to the strings these replace.

Parity gates land with the core (catalog key/placeholder/plural-shape
parity, markup coverage of every data-i18n* attribute, repair-pass
anchors pinning en byte-identity); additional locales ship as stacked
follow-up PRs through the recipe in docs/TRANSLATORS.md.

Rebased onto current main: the client defines live in build/vite.js /
server/standalone/vite.config.js as before.
2026-10-01 12:26:30 +02:00

199 lines
9.3 KiB
JavaScript

// Catalog parity gates: every shipped non-English locale (the es follow-up,
// then one PR per further locale) is checked against en — it may never LEAD en
// (no extra keys), every shared key must keep identical placeholder names,
// and plural entries must carry every en variant plus only categories
// Intl.PluralRules reports valid for that locale (see assertVariantSuperset
// below); the strict gate demands exact key-set equality so a forgotten
// translation cannot ship silently behind the English fallback. While English
// is the only shipped locale these loops are vacuous — the gate machinery
// stays so each locale PR meets it from day one.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { getCatalog, mergeNamespace } from './index.js';
import { CATALOG_LOCALES } from './locale.js';
/*
* PARITY FLIP — how the strict gate is previewed or relaxed.
* ─────────────────────────────────────────────────────────────────────────────
* REQUIRE_FULL_PARITY is on by default: every shipped locale must hold the
* exact en key set. While a NEW catalog is still an untranslated seed that
* lags en, run CI with GEV_I18N_REQUIRE_FULL_LOCALE_PARITY=0 to drop back to
* the subset rules (a locale may be a subset of en, never a superset). The
* pre-generalization name GEV_I18N_REQUIRE_FULL_ES_PARITY is still honored as
* an alias. Record every flip in docs/TRANSLATORS.md.
*/
const PARITY_OPT_OUT = process.env.GEV_I18N_REQUIRE_FULL_LOCALE_PARITY
?? process.env.GEV_I18N_REQUIRE_FULL_ES_PARITY;
const REQUIRE_FULL_PARITY = PARITY_OPT_OUT !== '0';
const PLACEHOLDER_PATTERN = /\{([A-Za-z0-9_]+)\}/g;
/** Placeholder names used by one catalog entry (string or plural variants). */
function placeholdersOf(entry) {
const patterns = typeof entry === 'string' ? [entry] : Object.values(entry || {});
const names = new Set();
for (const pattern of patterns) {
if (typeof pattern !== 'string') continue;
for (const match of pattern.matchAll(PLACEHOLDER_PATTERN)) names.add(match[1]);
}
return names;
}
/*
* PLURAL-SHAPE PARITY, GENERALIZED FOR MULTI-CATEGORY LOCALES
* ─────────────────────────────────────────────────────────────────────────────
* en plural entries carry { one, other } — the only cardinal categories
* Intl.PluralRules reports for English. Slavic locales such as ru/uk select
* one/few/many/other, so exact-shape equality with en would forbid their
* grammars. The rule is now, per locale and shared key:
* (a) the entry must carry EVERY variant en has (they are mandatory — the
* runtime reads entry.<category> ?? entry.other, so dropping an en
* variant strands the fallback), and
* (b) every variant added beyond en's must be a cardinal category
* Intl.PluralRules reports VALID for that locale
* (resolvedOptions().validCategories, spelled pluralCategories on
* older runtimes; e.g. 'few'/'many' are valid for ru/uk but not en).
* en itself can never grow a variant (English's valid set is exactly
* one/other), and a typo'd variant like 'xother' fails loudly for any
* locale. The runtime degrades gracefully the other way: an entry lacking a
* category its locale would select renders entry.other (selectPluralPattern
* in src/i18n/index.js; pinned in i18n.test.mjs).
*/
/** Cardinal plural categories Intl may select for a locale. */
function validPluralCategories(locale) {
const { validCategories, pluralCategories } = new Intl.PluralRules(locale).resolvedOptions();
// The property was renamed pluralCategories → validCategories in newer
// ECMAScript drafts; read whichever the running ICU exposes.
const categories = validCategories ?? pluralCategories;
assert.ok(Array.isArray(categories), `Intl.PluralRules(${locale}) exposes no category set`);
return new Set(categories);
}
/** Assert the plural-shape rule for one locale entry against its en twin. */
function assertVariantSuperset(localeEntry, enEntry, locale, key) {
assert.equal(
typeof localeEntry,
typeof enEntry,
`variant-shape drift on ${locale}:${key}: string ↔ plural-object mismatch`,
);
if (typeof enEntry === 'string') return;
const enVariants = Object.keys(enEntry);
const variants = Object.keys(localeEntry);
const missing = enVariants.filter((variant) => !variants.includes(variant));
assert.deepEqual(
missing,
[],
`${locale}:${key} must keep every en variant (${enVariants.join(',')})`,
);
const valid = validPluralCategories(locale);
const invalid = variants.filter((variant) => !enVariants.includes(variant) && !valid.has(variant));
assert.deepEqual(
invalid,
[],
`${locale}:${key} adds categories Intl.PluralRules(${locale}) never selects: ${invalid.join(',')}`,
);
}
const enKeys = Object.keys(getCatalog('en')).sort();
// Every shipped non-en locale with its sorted key list.
const localeKeys = new Map(
CATALOG_LOCALES
.filter((locale) => locale !== 'en')
.map((locale) => [locale, Object.keys(getCatalog(locale)).sort()]),
);
test('every shipped locale ships a merged catalog', () => {
// English only in the foundation state; a locale PR appends its code here
// alongside its catalog files (docs/TRANSLATORS.md).
assert.deepEqual([...CATALOG_LOCALES].sort(), ['en']);
for (const locale of CATALOG_LOCALES) {
assert.ok(getCatalog(locale), `merged catalog for ${locale}`);
}
});
test('no locale carries keys that en does not have', () => {
for (const [locale, keys] of localeKeys) {
const extras = keys.filter((key) => !enKeys.includes(key));
assert.deepEqual(
extras,
[],
`extra ${locale} keys leak untranslated-only surfaces: ${extras.join(', ')}`,
);
}
});
test('placeholder names match en; plural variants form an en-superset of Intl-valid categories', () => {
const enCatalog = getCatalog('en');
for (const [locale, keys] of localeKeys) {
const catalog = getCatalog(locale);
for (const key of keys) {
assert.deepEqual(
[...placeholdersOf(catalog[key])].sort(),
[...placeholdersOf(enCatalog[key])].sort(),
`placeholder drift on ${locale}:${key}: a renamed {name} would break interpolation at runtime`,
);
assertVariantSuperset(catalog[key], enCatalog[key], locale, key);
}
}
});
test('plural-shape rule: ru accepts its full Intl category set and rejects categories it never selects', () => {
const enEntry = { one: 'Cleared {count} layer', other: 'Cleared {count} layers' };
// ru selects one/few/many/other, so the full four-variant translation of
// an en { one, other } twin is a legal superset (adds only valid ru
// categories) — exactly what stage-B translators must produce…
assert.doesNotThrow(() => assertVariantSuperset(
{ one: '…', few: '…', many: '…', other: '…' },
enEntry,
'ru',
'layers.clear.toast.cleared',
));
// …while a variant Intl never selects for ru (a typo like 'xother') fails.
assert.throws(
() => assertVariantSuperset({ one: '…', other: '…', xother: '…' }, enEntry, 'ru', 'demo.key'),
/xother/,
);
// Dropping a mandatory en variant fails even when all additions are valid.
assert.throws(
() => assertVariantSuperset({ few: '…', many: '…', other: '…' }, enEntry, 'ru', 'demo.key'),
/every en variant/,
);
// String ↔ plural-object drift still fails exactly as before.
assert.throws(
() => assertVariantSuperset('plain string', enEntry, 'ru', 'demo.key'),
/string ↔ plural-object/,
);
// The gate stays honest about what the runtime can select: validCategories
// is read from Intl, not hardcoded per locale.
assert.deepEqual([...validPluralCategories('en')].sort(), ['one', 'other']);
assert.deepEqual([...validPluralCategories('ru')].sort(), ['few', 'many', 'one', 'other']);
});
test('exact key parity for every shipped locale once translation is complete (parity flip)', () => {
if (!REQUIRE_FULL_PARITY) {
// Subset mode: every locale key resolves; en-only keys fall back silently.
for (const [locale, keys] of localeKeys) {
for (const key of keys) assert.ok(enKeys.includes(key), `${locale}:${key}`);
}
return;
}
for (const [locale, keys] of localeKeys) {
assert.deepEqual(keys, enKeys, `strict gate is on: ${locale} must be key-complete`);
}
});
test('mergeNamespace prefixes relative keys and rejects malformed namespaces', () => {
const merged = mergeNamespace({ NAMESPACE: 'demo', default: { 'a.b': 'x', plain: 'y' } });
assert.deepEqual({ ...merged }, { 'demo.a.b': 'x', 'demo.plain': 'y' });
assert.throws(() => mergeNamespace({}), /must export a lowercase NAMESPACE/);
assert.throws(() => mergeNamespace({ NAMESPACE: 'Nope' }), /must export a lowercase NAMESPACE/);
assert.throws(() => mergeNamespace({ NAMESPACE: '' }), /must export a lowercase NAMESPACE/);
assert.deepEqual({ ...mergeNamespace({ NAMESPACE: 'empty' }) }, {});
});
test('registered namespaces produce the documented dot prefixes', () => {
const prefixes = new Set(enKeys.map((key) => key.split('.')[0]));
assert.deepEqual([...prefixes].sort(), ['cockpit', 'layers', 'setup', 'shell']);
});