mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-05 22:29:05 +00:00
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:
@@ -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)',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user