feat: add complete German localization

Add Deutsch across all card surfaces and backend flows, backed by the language registry introduced in #62. German loads as a fingerprint-checked page-shared locale chunk so EN/RU remain synchronous and the initial View budget stays intact. Root render gates prevent mixed-language flashes, retry once, and fail open to English. Extend parity, runtime, bundle, browser and visual coverage, plus contributor and user documentation.

Issue: #348
User-Visible: yes
This commit is contained in:
Matysh
2026-08-28 11:58:46 +03:00
parent 7a0702adea
commit 12cd8a1af3
50 changed files with 2265 additions and 524 deletions
+17 -3
View File
@@ -45,6 +45,7 @@ test('bundle manifest separates static initial graph from dynamic editor graph',
type: 'chunk', fileName: 'houseplan-card.js', code: 'entry', isEntry: true,
imports: ['shared.js'], dynamicImports: [
'houseplan-assets/editor.js', 'houseplan-assets/houseplan-onboarding-runtime-HASH.js',
'houseplan-assets/de-HASH.js',
],
},
'shared.js': {
@@ -60,14 +61,21 @@ test('bundle manifest separates static initial graph from dynamic editor graph',
code: 'onboarding', isEntry: false,
imports: ['shared.js'], dynamicImports: [],
},
'houseplan-assets/de-HASH.js': {
type: 'chunk', fileName: 'houseplan-assets/de-HASH.js',
code: 'German locale', isEntry: false, imports: [], dynamicImports: [],
modules: { '/repo/src/i18n/de.ts': {} },
},
}, 'fingerprint');
assert.deepEqual(manifest.initialViewFiles, ['houseplan-card.js', 'shared.js']);
assert.deepEqual(manifest.lazyEditorFiles, ['houseplan-assets/editor.js']);
assert.deepEqual(manifest.lazyOnboardingFiles, [
'houseplan-assets/houseplan-onboarding-runtime-HASH.js',
]);
assert.deepEqual(manifest.lazyLocaleFiles, ['houseplan-assets/de-HASH.js']);
assert.deepEqual(manifest.lazyFiles, [
'houseplan-assets/editor.js', 'houseplan-assets/houseplan-onboarding-runtime-HASH.js',
'houseplan-assets/de-HASH.js', 'houseplan-assets/editor.js',
'houseplan-assets/houseplan-onboarding-runtime-HASH.js',
]);
assert.doesNotThrow(() => assertBundleBudget(manifest, 1_000_000));
assert.throws(() => assertBundleBudget(manifest, 1), /exceeds/);
@@ -89,7 +97,8 @@ test('retry URL points at the content-hashed runtime chunk after naming', () =>
'houseplan-assets/houseplan-card.js': {
type: 'chunk', fileName: 'houseplan-assets/houseplan-card.js',
code: 'new URL("__HOUSEPLAN_EDITOR_RETRY_ASSET__", import.meta.url);'
+ 'new URL("__HOUSEPLAN_ONBOARDING_RETRY_ASSET__", import.meta.url)', modules: {},
+ 'new URL("__HOUSEPLAN_ONBOARDING_RETRY_ASSET__", import.meta.url);'
+ 'new URL("__HOUSEPLAN_DE_RETRY_ASSET__", import.meta.url)', modules: {},
},
'houseplan-assets/houseplan-editor-runtime-HASH.js': {
type: 'chunk', fileName: 'houseplan-assets/houseplan-editor-runtime-HASH.js', code: '',
@@ -99,12 +108,17 @@ test('retry URL points at the content-hashed runtime chunk after naming', () =>
type: 'chunk', fileName: 'houseplan-assets/houseplan-onboarding-runtime-HASH.js', code: '',
modules: { '/repo/src/houseplan-onboarding-runtime.ts': {} },
},
'houseplan-assets/de-HASH.js': {
type: 'chunk', fileName: 'houseplan-assets/de-HASH.js', code: '',
modules: { '/repo/src/i18n/de.ts': {} },
},
};
plugin.generateBundle({}, bundle);
assert.equal(
bundle['houseplan-assets/houseplan-card.js'].code,
'new URL("./houseplan-editor-runtime-HASH.js", import.meta.url);'
+ 'new URL("./houseplan-onboarding-runtime-HASH.js", import.meta.url)',
+ 'new URL("./houseplan-onboarding-runtime-HASH.js", import.meta.url);'
+ 'new URL("./de-HASH.js", import.meta.url)',
);
});
+3 -3
View File
@@ -368,7 +368,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
assert.ok(scenario);
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.equal(GOLDEN_MATRIX_VERSION, 48);
assert.equal(GOLDEN_MATRIX_VERSION, 49);
assert.equal(space.settings.sun_rays, true);
assert.equal(scenario.northDeg, 90,
'the sign-sensitive golden must keep a non-zero north direction');
@@ -807,8 +807,8 @@ test('editor tray golden contract covers every adaptive width in English and Rus
test('device dialog goldens expose the complete light-source controls at desktop and mobile widths', () => {
const dialogs = GOLDEN_SCENARIOS.filter((scenario) => scenario.id.startsWith('device-dialog-'));
assert.equal(dialogs.length, 2);
assert.deepEqual(new Set(dialogs.map((scenario) => scenario.language)), new Set(['en', 'ru']));
assert.equal(dialogs.length, 3);
assert.deepEqual(new Set(dialogs.map((scenario) => scenario.language)), new Set(['en', 'ru', 'de']));
for (const scenario of dialogs) {
assert.equal(scenario.deviceLightControls, true, scenario.id);
assert.equal(scenario.deviceId, 'golden-light-two', scenario.id);
+131
View File
@@ -0,0 +1,131 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
LanguageRuntime, languageRenderGate,
} from '../test-build/i18n/language-runtime.js';
const deferred = () => {
let resolve;
let reject;
const promise = new Promise((ok, fail) => { resolve = ok; reject = fail; });
return { promise, resolve, reject };
};
test('lazy locale runtime deduplicates hosts and accepts the matching build', async () => {
const load = deferred();
let calls = 0;
const runtime = new LanguageRuntime([
{ code: 'en', dictionary: { save: 'Save' } },
{ code: 'de', loadDictionary: async () => { calls++; return load.promise; } },
], 'same-build');
const first = runtime.ensure('de');
const second = runtime.ensure('de');
assert.equal(first, second);
assert.equal(calls, 1);
assert.equal(runtime.state('de'), 'pending');
load.resolve({ dictionary: { save: 'Speichern' }, fingerprint: 'same-build' });
await first;
assert.equal(runtime.state('de'), 'ready');
assert.equal(runtime.dictionary('de').save, 'Speichern');
await runtime.ensure('de');
assert.equal(calls, 1);
});
test('lazy locale runtime retries once, warns once and settles on English fallback', async () => {
const attempts = [];
const warnings = [];
const runtime = new LanguageRuntime([
{
code: 'de',
loadDictionary: async (attempt) => {
attempts.push(attempt);
throw new Error(`failure ${attempt}`);
},
},
], 'build', (message, error) => warnings.push([message, error]));
await runtime.ensure('de');
assert.deepEqual(attempts, [0, 1]);
assert.equal(runtime.state('de'), 'fallback');
assert.equal(runtime.dictionary('de'), undefined);
assert.equal(warnings.length, 1);
await runtime.ensure('de');
assert.equal(warnings.length, 1);
assert.deepEqual(attempts, [0, 1]);
});
test('lazy locale runtime rejects a stale fingerprint before accepting retry', async () => {
const runtime = new LanguageRuntime([{
code: 'de',
loadDictionary: async (attempt) => ({
dictionary: { save: attempt ? 'Speichern' : 'Stale' },
fingerprint: attempt ? 'current' : 'old',
}),
}], 'current');
await runtime.ensure('de');
assert.equal(runtime.dictionary('de').save, 'Speichern');
});
class FakeHost {
controllers = [];
inert = false;
isConnected = true;
attrs = new Map();
updates = 0;
addController(controller) { this.controllers.push(controller); }
requestUpdate() { this.updates++; }
hasAttribute(name) { return this.attrs.has(name); }
setAttribute(name, value) { this.attrs.set(name, value); }
removeAttribute(name) { this.attrs.delete(name); }
}
test('locale render gate exposes cold/warm states, inertness and ignores disconnected hosts', async () => {
const load = deferred();
const runtime = new LanguageRuntime([
{ code: 'en', dictionary: {} },
{ code: 'de', loadDictionary: async () => load.promise },
], 'build');
const host = new FakeHost();
let language = 'en';
assert.equal(languageRenderGate(host, runtime, language), 'ready');
assert.equal(host.attrs.get('lang'), 'en');
language = 'de';
assert.equal(languageRenderGate(host, runtime, language), 'warm');
assert.equal(host.inert, true);
assert.equal(host.attrs.get('aria-busy'), 'true');
host.isConnected = false;
load.resolve({ dictionary: {}, fingerprint: 'build' });
await runtime.ensure('de');
await Promise.resolve();
assert.equal(host.updates, 0);
assert.equal(languageRenderGate(host, runtime, language), 'ready');
assert.equal(host.inert, false);
assert.equal(host.hasAttribute('aria-busy'), false);
assert.equal(host.attrs.get('lang'), 'de');
const coldLoad = deferred();
const coldRuntime = new LanguageRuntime([{
code: 'de', loadDictionary: async () => coldLoad.promise,
}], 'build');
const coldHost = new FakeHost();
assert.equal(languageRenderGate(coldHost, coldRuntime, 'de'), 'cold');
coldLoad.resolve({ dictionary: {}, fingerprint: 'build' });
await coldRuntime.ensure('de');
await Promise.resolve();
assert.equal(coldHost.updates, 1);
assert.equal(languageRenderGate(coldHost, coldRuntime, 'de'), 'ready');
assert.equal(coldHost.attrs.get('lang'), 'de');
});
test('locale render gate exposes the fallback language to assistive technology', async () => {
const runtime = new LanguageRuntime([{
code: 'de', loadDictionary: async () => { throw new Error('offline'); },
}], 'build', () => {});
const host = new FakeHost();
assert.equal(languageRenderGate(host, runtime, 'de'), 'cold');
await runtime.ensure('de');
assert.equal(languageRenderGate(host, runtime, 'de'), 'ready');
assert.equal(host.attrs.get('lang'), 'en');
});
+102 -14
View File
@@ -6,6 +6,8 @@ import {
FALLBACK_LANGUAGE_CODE,
LANGUAGE_REGISTRY,
buildLanguageLookup,
dictionaryFor,
ensureLanguage,
languageEntry,
languageOptions,
normalizeLanguageTag,
@@ -14,11 +16,11 @@ import {
import { langOf } from '../test-build/i18n.js';
import { readHouseplanProductionSource } from './houseplan-source.mjs';
const dictionaries = new Map(
LANGUAGE_REGISTRY.map(({ code, dictionary }) => [code, dictionary]),
);
await Promise.all(LANGUAGE_REGISTRY.map(({ code }) => ensureLanguage(code)));
const dictionaries = new Map(LANGUAGE_REGISTRY.map(({ code }) => [code, dictionaryFor(code)]));
const en = dictionaries.get('en');
const ru = dictionaries.get('ru');
const de = dictionaries.get('de');
const cardSource = readHouseplanProductionSource();
test('i18n: registry codes and English fallback are valid', () => {
@@ -32,12 +34,15 @@ test('i18n: registry codes and English fallback are valid', () => {
normalizedCodes.length,
'registry codes must be unique after locale normalization',
);
for (const { code, nativeLabel, dictionary } of LANGUAGE_REGISTRY) {
for (const { code, nativeLabel, dictionary, loadDictionary } of LANGUAGE_REGISTRY) {
assert.equal(code, code.trim(), `${code} has surrounding whitespace`);
assert.doesNotMatch(code, /_/u, `${code} must use BCP 47 hyphens`);
assert.match(code, /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/u);
assert.ok(nativeLabel.trim(), `${code} has no native label`);
assert.ok(dictionary && typeof dictionary === 'object', `${code} has no dictionary`);
assert.ok(
(dictionary && typeof dictionary === 'object') || typeof loadDictionary === 'function',
`${code} has neither an eager dictionary nor a lazy loader`,
);
}
});
@@ -69,15 +74,48 @@ test('i18n: registry matches frontend and backend locale files', () => {
);
});
test('i18n: backend dictionaries preserve the English structure and placeholders', () => {
const flatten = (value, prefix = '', result = new Map()) => {
for (const [key, child] of Object.entries(value)) {
const path = prefix ? `${prefix}.${key}` : key;
if (child && typeof child === 'object' && !Array.isArray(child)) flatten(child, path, result);
else result.set(path, child);
}
return result;
};
const placeholders = (value) => (String(value).match(/\{\w+\}/gu) || []).sort();
const backend = new Map(LANGUAGE_REGISTRY.map(({ code }) => [
code,
flatten(JSON.parse(readFileSync(
new URL(`../custom_components/houseplan/translations/${code}.json`, import.meta.url),
'utf8',
))),
]));
const english = backend.get('en');
for (const { code } of LANGUAGE_REGISTRY) {
const dictionary = backend.get(code);
assert.deepEqual([...dictionary.keys()].sort(), [...english.keys()].sort(), `${code} backend key set differs`);
for (const [key, value] of english) {
assert.deepEqual(
placeholders(dictionary.get(key)),
placeholders(value),
`${code} backend placeholder mismatch at ${key}`,
);
}
}
});
test('i18n: every registered dictionary carries the English key set', () => {
const enKeys = Object.keys(en).sort();
for (const { code, dictionary } of LANGUAGE_REGISTRY) {
for (const { code } of LANGUAGE_REGISTRY) {
const dictionary = dictionaries.get(code);
assert.deepEqual(Object.keys(dictionary).sort(), enKeys, `${code} key set differs`);
}
});
test('i18n: no empty values', () => {
for (const { code, dictionary } of LANGUAGE_REGISTRY) {
for (const { code } of LANGUAGE_REGISTRY) {
const dictionary = dictionaries.get(code);
for (const [k, v] of Object.entries(dictionary)) {
assert.ok(typeof v === 'string' && v.length > 0, `${code}:${k} is empty`);
}
@@ -87,14 +125,15 @@ test('i18n: no empty values', () => {
test('i18n: placeholders match between languages', () => {
const ph = (s) => (s.match(/\{\w+\}/g) || []).sort().join(',');
for (const k of Object.keys(en)) {
for (const { code, dictionary } of LANGUAGE_REGISTRY) {
for (const { code } of LANGUAGE_REGISTRY) {
const dictionary = dictionaries.get(code);
assert.equal(ph(en[k]), ph(dictionary[k]), `placeholder mismatch in ${code}:${k}`);
}
}
});
test('i18n: resolver supports exact, primary, explicit and fallback paths', () => {
const supported = ['en', 'ru', 'pt-BR'];
const supported = ['en', 'ru', 'de', 'pt-BR'];
const cases = [
{ explicit: 'RU', ha: 'en-US', expected: 'ru' },
{ explicit: 'pt_BR', ha: 'ru-RU', expected: 'pt-BR' },
@@ -103,7 +142,9 @@ test('i18n: resolver supports exact, primary, explicit and fallback paths', () =
{ explicit: null, ha: 'ru-RU', expected: 'ru' },
{ explicit: 'ru-RU', ha: 'en-GB', expected: 'en' },
{ explicit: undefined, ha: 'pt-PT', expected: 'en' },
{ explicit: undefined, ha: 'de-DE', expected: 'en' },
{ explicit: undefined, ha: 'de-DE', expected: 'de' },
{ explicit: undefined, ha: 'de-AT', expected: 'de' },
{ explicit: undefined, ha: 'de-CH', expected: 'de' },
];
for (const { explicit, ha, expected } of cases) {
assert.equal(resolveLanguageCode(explicit, ha, supported, 'en'), expected);
@@ -114,7 +155,7 @@ test('i18n: langOf wires card config and both HA locale shapes to the registry',
assert.equal(langOf({ locale: { language: 'ru_RU' }, language: 'en' }), 'ru');
assert.equal(langOf({ locale: { language: 'en-GB' }, language: 'ru' }, 'RU'), 'ru');
assert.equal(langOf({ language: 'ru-RU' }, 'unknown'), 'ru');
assert.equal(langOf({ locale: { language: 'de-DE' } }, 'unknown'), 'en');
assert.equal(langOf({ locale: { language: 'de-DE' } }, 'unknown'), 'de');
});
test('i18n: editor options follow registry order and preserve unknown raw values', () => {
@@ -123,7 +164,7 @@ test('i18n: editor options follow registry order and preserve unknown raw values
...LANGUAGE_REGISTRY.map(({ code, nativeLabel }) => ({ value: code, label: nativeLabel })),
]);
assert.deepEqual(languageOptions('Auto', 'ru'), languageOptions('Auto'));
assert.deepEqual(languageOptions('Auto', 'de').at(-1), { value: 'de', label: 'de' });
assert.deepEqual(languageOptions('Auto', 'de'), languageOptions('Auto'));
assert.deepEqual(
languageOptions('Auto', ' RU ').at(-1),
{ value: ' RU ', label: 'Русский' },
@@ -242,7 +283,8 @@ test('i18n: every literal help call has body and full aria keys in both language
assert.equal(helpKeys.length, allCalls.length, 'every _help call must use one string literal ending in .help');
assert.ok(helpKeys.length > 0, 'the help affordance pilot disappeared');
for (const key of helpKeys) {
for (const { code, dictionary } of LANGUAGE_REGISTRY) {
for (const { code } of LANGUAGE_REGISTRY) {
const dictionary = dictionaries.get(code);
assert.ok(dictionary[key]?.trim(), `${code}:${key} is missing or empty`);
assert.ok(dictionary[`${key}.aria`]?.trim(), `${code}:${key}.aria is missing or empty`);
}
@@ -274,9 +316,55 @@ test('help hosts do not duplicate their explanation in a title attribute', () =>
});
test('vac toasts never mention the removed point calibration (HP-1540-06)', () => {
for (const { code: lang, dictionary: d } of LANGUAGE_REGISTRY) {
for (const { code: lang } of LANGUAGE_REGISTRY) {
const d = dictionaries.get(lang);
for (const k of ['vac.autocal_no_rooms', 'vac.autocal_no_match', 'vac.residual_message']) {
assert.ok(!/point|точк/i.test(d[k]), `${lang}:${k} still points at point calibration`);
}
}
});
test('i18n: German catalog keeps the product glossary and has no translation sentinels', () => {
assert.equal(de['btn.save'], 'Speichern');
assert.equal(de['btn.cancel'], 'Abbrechen');
assert.equal(de['btn.delete'], 'Löschen');
assert.equal(de['space.header'], 'Bereich');
assert.equal(de['physical.partition_properties'], 'Eigenschaften der Trennwand');
assert.equal(de['opening.passage'], 'Offener Durchgang');
for (const [key, value] of Object.entries(de)) {
assert.doesNotMatch(value, /ZXQPH|QXZ|⟦HP/u, `de:${key} contains a translator sentinel`);
assert.doesNotMatch(value, /[А-Яа-яЁё]/u, `de:${key} contains Cyrillic text`);
}
});
test('i18n: German values equal to English are explicitly reviewed', () => {
const allowed = new Set([
'editor.palette',
'confirm.delete_partition_openings_item',
'marker.name_ph',
'marker.pulse_a11y_alarm',
'marker.state_a11y_alarm',
'marker.link_label',
'rules.icon_ph',
'decor.ellipse',
'decor.text',
'decor.text_label',
'gs.unit_m',
'gs.unit_ft',
'wallthick.unit_cm',
'wallthick.unit_in',
'marker.value_badge_position',
'marker.value_badge_attr_current_position',
'gs.north_letter',
'gs.optimize_live_names',
'gs.optimize_details',
'furn.symbol',
'furn.sym_sofa',
'furn.sym_bidet',
'vac.diag_platform',
'vac.diag_status',
'vac.diag_position',
]);
const equalKeys = Object.keys(en).filter((key) => en[key] === de[key]);
assert.deepEqual(new Set(equalKeys), allowed);
});
+2 -1
View File
@@ -5,6 +5,7 @@ import test from 'node:test';
const cardSource = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const en = JSON.parse(readFileSync(new URL('../src/i18n/en.json', import.meta.url), 'utf8'));
const ru = JSON.parse(readFileSync(new URL('../src/i18n/ru.json', import.meta.url), 'utf8'));
const de = JSON.parse(readFileSync(new URL('../src/i18n/de.json', import.meta.url), 'utf8'));
test('runtime exposes only the unified Walls tool state', () => {
const toolType = cardSource.match(/type MarkupTool = ([^;]+);/)?.[1] || '';
@@ -18,7 +19,7 @@ test('runtime exposes only the unified Walls tool state', () => {
});
test('only old tool copy is removed while persisted partition copy remains', () => {
for (const locale of [en, ru]) {
for (const locale of [en, ru, de]) {
for (const key of [
'title.markup_partition',
'markup.hint_partition',