perf(i18n): словари settings/support/topology — ru/de/fr ленивыми чанками; потолок онбординга (#627)

- ru/de/fr трёх словарей пространств — девять ленивых чанков (по одному на
  пару «пространство × язык»), английский статически как слой отката;
  рантайм — тот же LanguageRuntime (две попытки, отпечаток сборки, fallback,
  общий тост отказа), src/i18n/namespace-language.ts.
- Загрузчики рантаймов онбординга и редактора отдают поверхность в рендер
  только после того, как словарь текущего языка осел; гейт хоста учитывает
  словари загруженной на нём поверхности (смена языка на лету — ветка warm).
  Оверлей Zigbee ждёт topology до первой отрисовки.
- bundle-manifest: граф lazyNamespaceLocaleFiles, девять retry-токенов со
  строгим счётом «ровно один». bundle-budget: потолок онбординга 28 500
  (замер 27 470), редактор 245 400 → 228 900 (замер 227 869), проверки
  ленивости и владения по содержимому.
- Смок smoke_lazy_admin_locale (AC4–AC7); регресс-смоки, переключавшие язык
  синхронно, ждут гейт вместо ослабления проверок; пять мутантов.
- monolith-baseline bundleBytes 2 500 387 → 2 508 676: +10 чанков (rollup intro
  и export на каждый) и записи манифеста; gzip каждой поверхности при этом
  уменьшился.

Issue: #627
User-Visible: no
This commit is contained in:
Claude
2026-09-24 14:51:18 +03:00
parent 0f234f769d
commit 82a48ee0ce
38 changed files with 1340 additions and 65 deletions
+11
View File
@@ -38,6 +38,17 @@ an eager import. Extend the runtime, manifest, file/key/placeholder parity and
regional-locale tests together; never bypass the registry by importing a locale
directly in a component.
Administrator-only copy lives in three lazy namespace dictionaries —
`src/i18n/settings/`, `src/i18n/support/` and `src/i18n/topology/` (#627). Their
English file is static (the synchronous fallback); every other language is one
lazy chunk per namespace × language: a two-line loader module
`src/i18n/<namespace>/<namespace>-<code>.ts`, one `import()` with a
content-hashed retry token in `src/i18n/<namespace>.ts`, and one entry in
`NAMESPACE_LOCALE_CHUNKS` in `scripts/bundle-manifest.mjs`. The lazy surfaces
(onboarding, editor runtime, Zigbee overlay) wait for their dictionaries before
painting; never import a non-English namespace JSON statically — the budget
gate and `test/i18n-lazy-namespaces.test.mjs` refuse it.
The current `subst()` helper does not implement plural rules. Phrase strings so
their grammar does not depend on the numeric value (for example, use a neutral
label followed by `{n}` rather than an English singular/plural pair).
+4
View File
@@ -183,6 +183,10 @@ const result = await page.evaluate(async () => {
card._config = { ...card._config, language: 'ru' };
card.requestUpdate();
await card.updateComplete;
// #627: editor-dialog ru strings are a lazy chunk; the live switch holds the
// previous frame (inert + aria-busy) until it settles — wait for the gate.
for (let i = 0; i < 250 && card.hasAttribute('aria-busy'); i++) await new Promise((r) => setTimeout(r, 20));
await card.updateComplete;
await picker.updateComplete;
out.cardLanguageOwnsCopy = surface()?.getAttribute('aria-label') === 'Выбор цвета'
&& surface()?.querySelector('input[type="text"]')?.getAttribute('aria-label') === 'Цвет HEX'
+24 -4
View File
@@ -6,15 +6,31 @@
// (строк) не больше числа слов — однословная подпись всегда в одну строку,
// многословная переносится только по пробелам; кнопка при этом не выходит за
// границы своего сегмента.
import { readFileSync } from 'node:fs';
import { launch, checkAll, finish } from './serve.mjs';
// #627: строки диалогов на ru/de/fr — ленивые чанки словаря `settings`. Смена
// языка обязана дождаться гейта хоста, а проверка — убедиться, что диалог
// действительно на этом языке, а не на английском откате: иначе смок мерил бы
// переносы английских подписей под именем четырёх локалей.
const settingsCopy = Object.fromEntries(['en', 'ru', 'de', 'fr'].map((code) => [code,
JSON.parse(readFileSync(`src/i18n/settings/${code}.json`, 'utf8'))]));
const translatedSettings = Object.fromEntries(['ru', 'de', 'fr'].map((code) => [code,
Object.keys(settingsCopy[code]).filter((key) => settingsCopy[code][key] !== settingsCopy.en[key]
&& !/[{}]/.test(settingsCopy[code][key]) && settingsCopy[code][key].length >= 4)
.map((key) => settingsCopy[code][key])]));
const { page, browser } = await launch({ width: 1280, height: 900 });
const out = await page.evaluate(async () => {
const out = await page.evaluate(async (translated) => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const upd = async () => { c.requestUpdate(); await c.updateComplete; await new Promise((r) => setTimeout(r, 40)); };
const settle = async () => {
for (let i = 0; i < 250 && c.hasAttribute('aria-busy'); i++) await new Promise((r) => setTimeout(r, 20));
await upd();
};
const spId = c._space;
const closeAll = async () => {
c._settingsDialog = null; c._spaceDialog = null; c._markerDialog = null; if (c._roomDialog) c._roomDialogCancel();
@@ -38,10 +54,14 @@ const out = await page.evaluate(async () => {
};
const wordsOf = (text) => text.trim().split(/\s+/).filter(Boolean).length;
for (const lang of ['en', 'ru', 'de', 'fr']) {
c._config = { ...c._config, language: lang }; await upd();
c._config = { ...c._config, language: lang }; await upd(); await settle();
for (const [kind, opener] of Object.entries(open)) {
await closeAll(); await opener();
await closeAll(); await opener(); await settle();
const dlg = sr().querySelector(`hp-dialog[data-kind="${kind}"]`);
if (lang !== 'en') {
const text = dlg?.textContent || '';
o[`${lang}_${kind}_inLanguage`] = translated[lang].some((value) => text.includes(value));
}
const labels = [...(dlg?.querySelectorAll('.hpf-seg label') || [])];
let broken = [];
let outside = [];
@@ -64,7 +84,7 @@ const out = await page.evaluate(async () => {
await closeAll();
c._config = { ...c._config, language: 'en' }; await upd();
return o;
});
}, translatedSettings);
const normalized = Object.fromEntries(Object.entries(out).map(([k, v]) => [k, v === true]));
checkAll(normalized);
+4
View File
@@ -57,6 +57,10 @@ const out = await page.evaluate(async () => {
c._saveConfig = () => { c._cfgEpoch++; c._modelCache = null; c.requestUpdate(); };
c.requestUpdate();
await c.updateComplete;
// #627: ru strings of the editor dialogs are a lazy chunk; the switch to ru
// above holds the previous frame (inert + aria-busy) until it settles.
for (let i = 0; i < 250 && c.hasAttribute('aria-busy'); i++) await new Promise((r) => setTimeout(r, 20));
await c.updateComplete;
const stage = (c.shadowRoot || c.renderRoot).querySelector('.stage');
const clickAt = (x, y) => {
+4
View File
@@ -244,6 +244,10 @@ const res = await page.evaluate(async () => {
card._config = { ...card._config, language: 'ru' };
card.requestUpdate();
await card.updateComplete;
// #627: the ru strings of the editor dialogs are a lazy chunk; a live switch
// holds the previous frame (inert + aria-busy) until it settles.
for (let i = 0; i < 250 && card.hasAttribute('aria-busy'); i++) await wait(20);
await card.updateComplete;
out.cardLanguage = /Определяет|источник/.test(root()
.querySelector('hp-help[data-help-key="marker.light_role.help"]')?.text || '');
+358
View File
@@ -0,0 +1,358 @@
// #627 AC4–AC7: ru/de/fr словарей пространств `settings`, `support`,
// `topology` — ленивые чанки. Смок идёт по production-бандлу: реальные
// загрузчики рантаймов, реальные retry-URL, журнал запросов и снимок текста
// каждого кадра (MutationObserver коммитит снимок на каждый рендер Lit).
//
// Контракт — тот же, что у de/fr основного каталога (#348): холодное открытие
// показывает только прежний индикатор загрузки рантайма и затем сразу язык
// пользователя; смена языка на лету держит прежний кадр (inert + aria-busy);
// отказ — английский в строках пространства, один тост, без вечной загрузки.
import { readFileSync } from 'node:fs';
import { launchColdView, checkAll, finish } from './serve.mjs';
const manifest = JSON.parse(readFileSync('dist/houseplan-assets.json', 'utf8'));
const NAMESPACE_CHUNKS = (manifest.lazyNamespaceLocaleFiles || []).map((path) => path.split('/').at(-1));
if (NAMESPACE_CHUNKS.length !== 9) throw new Error('bundle manifest has no nine namespace locale chunks');
const chunkName = (namespace, language) => {
const name = NAMESPACE_CHUNKS.find((candidate) => candidate.startsWith(`${namespace}-${language}-`));
if (!name) throw new Error(`namespace chunk ${namespace}-${language} is absent from the manifest`);
return name;
};
const editorRuntimeName = manifest.files.map((file) => file.path.split('/').at(-1))
.find((name) => /^houseplan-editor-runtime-[^/]+\.js$/.test(name));
const dictionary = (namespace, language) => JSON.parse(
readFileSync(`src/i18n/${namespace}/${language}.json`, 'utf8'),
);
const plain = (value) => typeof value === 'string' && !/[{}]/.test(value);
/** English strings that must never be painted for `language` (translation differs). */
const englishLeaks = (namespaces, language) => namespaces.flatMap((namespace) => {
const en = dictionary(namespace, 'en');
const target = dictionary(namespace, language);
return Object.keys(en).filter((key) => plain(en[key]) && en[key].length >= 12
&& target[key] !== en[key]).map((key) => en[key]);
});
const translated = (namespaces, language) => namespaces.flatMap((namespace) => {
const en = dictionary(namespace, 'en');
const target = dictionary(namespace, language);
return Object.keys(target).filter((key) => plain(target[key]) && target[key].length >= 8
&& target[key] !== en[key]).map((key) => target[key]);
});
const rawKeys = (namespaces) => namespaces.flatMap((namespace) => Object.keys(dictionary(namespace, 'en'))
.filter((key) => /[._]/.test(key) && key.length >= 6));
const requestsOf = (page) => {
const requested = [];
page.on('request', (request) => requested.push(new URL(request.url()).pathname.split('/').at(-1)));
return requested;
};
const count = (requested, name) => requested.filter((candidate) => candidate === name).length;
const namespaceRequests = (requested) => requested.filter((name) => NAMESPACE_CHUNKS.includes(name));
const delayChunk = (page, name, ms = 250) => page.route(`**/${name}*`, async (route) => {
await new Promise((resolve) => setTimeout(resolve, ms));
await route.fallback();
});
/** Replace the demo card by a fresh one on `language` (config + HA profile). */
const replaceCard = async (page, language) => {
await page.evaluate((lang) => {
const old = window.__card;
const host = document.getElementById('host');
const config = { ...old._config, language: lang };
const hass = { ...old.hass, language: lang, locale: { ...(old.hass.locale || {}), language: lang } };
old.remove();
const card = document.createElement('houseplan-card');
card.setConfig(config);
card.hass = hass;
host.append(card);
window.__card = card;
}, language);
await page.waitForFunction(() => window.__card?._model?.length > 0
&& window.__card._booting === false && !window.__card.hasAttribute('aria-busy'));
};
/** Snapshot the dialog text and host state on every committed render. */
const recordFrames = (page) => page.evaluate(() => {
const card = window.__card;
const root = card.renderRoot;
window.__hpFrames = [];
const snap = () => window.__hpFrames.push({
busy: card.getAttribute('aria-busy') === 'true',
inert: !!card.inert,
lang: card.getAttribute('lang'),
loading: !!root.querySelector('.editorloading'),
dialogs: [...root.querySelectorAll('hp-dialog')]
.map((dialog) => dialog.textContent.replace(/\s+/g, ' ').trim()),
});
window.__hpFrameObservers?.forEach((observer) => observer.disconnect());
const tree = new MutationObserver(snap);
tree.observe(root, { subtree: true, childList: true, characterData: true });
const host = new MutationObserver(snap);
host.observe(card, { attributes: true, attributeFilter: ['aria-busy', 'lang'] });
window.__hpFrameObservers = [tree, host];
snap();
});
const frames = (page) => page.evaluate(() => window.__hpFrames);
const dialogFrames = (list) => list.filter((frame) => frame.dialogs.some(Boolean));
const leaksIn = (frame, leaks, keys) => frame.dialogs.flatMap((text) => [
...leaks.filter((value) => text.includes(value)),
...keys.filter((key) => text.includes(key)),
]);
const hasTranslation = (frame, strings) => frame.dialogs.some((text) => strings.some((value) => text.includes(value)));
/** A wait that reports instead of throwing: a regression reads as a named check. */
const settled = (promise) => promise.then(() => true, () => false);
const out = {};
// --- AC4a: cold onboarding on ru — the first-run form ------------------------
{
const { page, browser } = await launchColdView();
const requested = requestsOf(page);
await replaceCard(page, 'ru');
out.ruViewRequestsNoNamespaceChunk = namespaceRequests(requested).length === 0;
await delayChunk(page, chunkName('settings', 'ru'));
await recordFrames(page);
const strings = translated(['settings'], 'ru');
await page.evaluate(async () => {
const card = window.__card;
card._onboardingShown = false;
card._serverCfg = { ...card._serverCfg, spaces: [] };
card._model = [];
card.hass = { ...card.hass, floors: {}, areas: {} };
card.requestUpdate();
await card.updateComplete;
});
out.onboardingOpensInRussian = await settled(page.waitForFunction((values) => {
const card = window.__card;
const dialog = card._onboardingRuntime && card.renderRoot.querySelector('hp-dialog');
return !!dialog && values.some((value) => dialog.textContent.includes(value));
}, strings, { timeout: 5000 }));
await page.waitForTimeout(80);
const list = await frames(page);
const withDialog = dialogFrames(list);
const leaks = englishLeaks(['settings'], 'ru');
const keys = rawKeys(['settings']);
out.onboardingFirstDialogFrameIsRussian = withDialog.length > 0
&& hasTranslation(withDialog[0], strings) && leaksIn(withDialog[0], leaks, keys).length === 0;
out.onboardingNoEnglishOrRawKeyInAnyFrame = list.every((frame) => leaksIn(frame, leaks, keys).length === 0)
|| list.flatMap((frame) => leaksIn(frame, leaks, keys)).slice(0, 3);
// Cold open shows only the existing runtime-loading surface: no warm gate.
out.onboardingColdOpenNeverBusy = list.every((frame) => !frame.busy && !frame.inert);
out.onboardingSettingsRuRequestedOnce = count(requested, chunkName('settings', 'ru')) === 1;
out.onboardingRequestsOnlySettings = namespaceRequests(requested)
.every((name) => name === chunkName('settings', 'ru'));
out.onboardingDoesNotLoadEditor = count(requested, editorRuntimeName) === 0;
await browser.close();
}
// --- AC4b: cold «Общие настройки» on de (lazy main catalog too) --------------
{
const { page, browser } = await launchColdView();
const requested = requestsOf(page);
await replaceCard(page, 'de');
out.deViewRequestsNoNamespaceChunk = namespaceRequests(requested).length === 0;
for (const namespace of ['settings', 'support', 'topology']) await delayChunk(page, chunkName(namespace, 'de'));
await recordFrames(page);
const strings = translated(['settings'], 'de');
await page.evaluate(() => window.__card._openSettingsDialog());
out.settingsOpensInGerman = await settled(page.waitForFunction((values) => {
const dialog = window.__card.renderRoot.querySelector('hp-dialog[data-kind="settings"]');
return !!dialog && values.some((value) => dialog.textContent.includes(value));
}, strings, { timeout: 5000 }));
await page.waitForTimeout(80);
const list = await frames(page);
const withDialog = dialogFrames(list);
const namespaces = ['settings', 'support', 'topology'];
const leaks = englishLeaks(namespaces, 'de');
const keys = rawKeys(namespaces);
const firstDialog = list.indexOf(withDialog[0]);
out.settingsFirstDialogFrameIsGerman = withDialog.length > 0
&& hasTranslation(withDialog[0], strings) && leaksIn(withDialog[0], leaks, keys).length === 0;
out.settingsNoEnglishOrRawKeyInAnyFrame = list.every((frame) => leaksIn(frame, leaks, keys).length === 0)
|| list.flatMap((frame) => leaksIn(frame, leaks, keys)).slice(0, 3);
out.settingsDelayShowsOnlyRuntimeIndicator = list.slice(0, firstDialog).some((frame) => frame.loading)
&& list.every((frame) => !frame.busy && !frame.inert);
out.settingsRequestsEachGermanNamespaceOnce = namespaces
.every((namespace) => count(requested, chunkName(namespace, 'de')) === 1);
out.settingsRequestsNoOtherLanguage = namespaceRequests(requested)
.every((name) => /^(settings|support|topology)-de-/.test(name));
await browser.close();
}
// --- AC4c: cold device dialog on fr ------------------------------------------
{
const { page, browser } = await launchColdView();
const requested = requestsOf(page);
await replaceCard(page, 'fr');
await delayChunk(page, chunkName('settings', 'fr'));
await recordFrames(page);
const strings = translated(['settings'], 'fr');
await page.evaluate(() => {
const card = window.__card;
const device = card._devices.find((candidate) => candidate.id === 'd_light1') || card._devices[0];
card._openMarkerDialog(device);
});
out.deviceOpensInFrench = await settled(page.waitForFunction((values) => {
const dialog = window.__card.renderRoot.querySelector('hp-dialog[data-kind="marker"]');
return !!dialog && values.some((value) => dialog.textContent.includes(value));
}, strings, { timeout: 5000 }));
await page.waitForTimeout(80);
const list = await frames(page);
const withDialog = dialogFrames(list);
const namespaces = ['settings', 'support', 'topology'];
const leaks = englishLeaks(namespaces, 'fr');
const keys = rawKeys(namespaces);
out.deviceFirstDialogFrameIsFrench = withDialog.length > 0
&& hasTranslation(withDialog[0], strings) && leaksIn(withDialog[0], leaks, keys).length === 0;
out.deviceNoEnglishOrRawKeyInAnyFrame = list.every((frame) => leaksIn(frame, leaks, keys).length === 0)
|| list.flatMap((frame) => leaksIn(frame, leaks, keys)).slice(0, 3);
out.deviceColdOpenNeverBusy = list.every((frame) => !frame.busy && !frame.inert);
out.deviceRequestsFrenchSettingsOnce = count(requested, chunkName('settings', 'fr')) === 1;
await browser.close();
}
// --- AC5: live switch ru → de with «Общие настройки» open --------------------
{
const { page, browser } = await launchColdView();
const requested = requestsOf(page);
await replaceCard(page, 'ru');
const russian = translated(['settings'], 'ru');
const german = translated(['settings'], 'de');
await page.evaluate(() => window.__card._openSettingsDialog());
await page.waitForFunction((values) => {
const dialog = window.__card.renderRoot.querySelector('hp-dialog[data-kind="settings"]');
return !!dialog && values.some((value) => dialog.textContent.includes(value))
&& !window.__card.hasAttribute('aria-busy');
}, russian);
await delayChunk(page, chunkName('settings', 'de'));
await recordFrames(page);
await page.evaluate(() => {
const card = window.__card;
card._config = { ...card._config, language: 'de' };
card.requestUpdate();
});
out.liveSwitchSettlesInGerman = await settled(page.waitForFunction((values) => {
const card = window.__card;
const dialog = card.renderRoot.querySelector('hp-dialog[data-kind="settings"]');
return !card.hasAttribute('aria-busy') && !!dialog
&& values.some((value) => dialog.textContent.includes(value));
}, german, { timeout: 5000 }));
await page.waitForTimeout(80);
const list = await frames(page);
const namespaces = ['settings', 'support', 'topology'];
const leaks = englishLeaks(namespaces, 'de');
const keys = rawKeys(namespaces);
const busy = list.filter((frame) => frame.busy);
out.liveSwitchHoldsPreviousFrameInertAndBusy = busy.length > 0
&& busy.every((frame) => frame.inert && hasTranslation(frame, russian));
out.liveSwitchNoEnglishFlash = list.every((frame) => leaksIn(frame, leaks, keys).length === 0)
|| list.flatMap((frame) => leaksIn(frame, leaks, keys)).slice(0, 3);
const last = list.at(-1);
out.liveSwitchCommitsWholeGermanFrame = hasTranslation(last, german) && !hasTranslation(last, russian)
&& last.lang === 'de';
out.liveSwitchRequestsGermanSettingsOnce = count(requested, chunkName('settings', 'de')) === 1;
// A second card on the page reuses the page-scoped dictionary.
const before = requested.length;
await page.evaluate(() => {
const first = window.__card;
const second = document.createElement('houseplan-card');
second.setConfig({ ...first._config, title: 'Zweite', language: 'de' });
second.hass = first.hass;
document.getElementById('host').append(second);
window.__second = second;
});
await page.waitForFunction(() => window.__second?._model?.length > 0 && window.__second._booting === false);
await page.evaluate(() => window.__second._openSettingsDialog());
out.secondCardOpensInGerman = await settled(page.waitForFunction((values) => {
const dialog = window.__second.renderRoot.querySelector('hp-dialog[data-kind="settings"]');
return !!dialog && values.some((value) => dialog.textContent.includes(value));
}, german, { timeout: 5000 }));
out.secondCardReusesNamespaceChunks = namespaceRequests(requested.slice(before)).length === 0;
await browser.close();
}
// --- AC6: both attempts of a namespace chunk fail ----------------------------
{
const { page, browser } = await launchColdView();
const requested = requestsOf(page);
let warnings = 0;
page.on('console', (message) => {
if (message.type() === 'warning' && message.text().includes('unable to load de locale')) warnings++;
});
await replaceCard(page, 'de');
let attempts = 0;
await page.route(`**/${chunkName('settings', 'de')}*`, async (route) => {
attempts++;
await route.abort('failed');
});
// The fallback is the English layer of the failed namespace only.
const englishStrings = englishLeaks(['settings'], 'de');
const supportGerman = translated(['support'], 'de');
await page.evaluate(() => window.__card._openSettingsDialog());
out.failureSettlesWithEnglishSettings = await settled(page.waitForFunction((values) => {
const card = window.__card;
const dialog = card.renderRoot.querySelector('hp-dialog[data-kind="settings"]');
return !card.hasAttribute('aria-busy') && !!dialog
&& values.some((value) => dialog.textContent.includes(value));
}, englishStrings, { timeout: 5000 }));
await page.waitForTimeout(120);
const state = await page.evaluate((support) => {
const card = window.__card;
const dialog = card.renderRoot.querySelector('hp-dialog[data-kind="settings"]');
return {
inert: card.inert,
lang: card.getAttribute('lang'),
toast: card._toast,
expectedToast: card._t('toast.locale_load_failed'),
supportGerman: !!dialog && support.some((value) => dialog.textContent.includes(value)),
};
}, supportGerman);
out.failureRetriesExactlyOnce = attempts === 2 && count(requested, chunkName('settings', 'de')) === 2;
out.failureDialogWorksInEnglish = !state.inert;
out.failureShowsOneLocaleToast = state.toast === state.expectedToast && !!state.expectedToast;
out.failureWarnsOnce = warnings === 1;
out.failureKeepsHostLanguage = state.lang === 'de';
out.failureDoesNotSpreadToOtherNamespaces = state.supportGerman;
await browser.close();
}
// --- AC7: View without editors requests nothing; Zigbee layer only topology --
{
const { page, browser } = await launchColdView();
const requested = requestsOf(page);
const initial = await page.evaluate(() => performance.getEntriesByType('resource')
.map((entry) => new URL(entry.name).pathname.split('/').at(-1)));
out.enViewRequestsNoNamespaceChunk = namespaceRequests(initial).length === 0;
await page.evaluate(() => window.__card._requestMode('plan'));
await page.waitForFunction(() => window.__card._mode === 'plan' && !!window.__card._editorRuntime);
await page.waitForTimeout(80);
out.enEditorRequestsNoNamespaceChunk = namespaceRequests(requested).length === 0;
await browser.close();
}
{
const { page, browser } = await launchColdView();
const requested = requestsOf(page);
await replaceCard(page, 'ru');
await page.evaluate(async () => {
const card = window.__card;
card._serverCfg = {
...card._serverCfg,
settings: { ...(card._serverCfg.settings || {}), zigbee_topology: { enabled: true, z2mBaseTopics: [] } },
};
card.requestUpdate();
await card.updateComplete;
});
await page.waitForFunction(() => !!window.__card.renderRoot.querySelector('hp-zigbee-topology-overlay'));
await page.waitForTimeout(300);
out.zigbeeLayerRequestsTopologyRuOnce = count(requested, chunkName('topology', 'ru')) === 1;
out.zigbeeLayerRequestsNothingElse = namespaceRequests(requested)
.every((name) => name === chunkName('topology', 'ru'))
&& count(requested, editorRuntimeName) === 0;
await browser.close();
}
checkAll(out);
await finish(null, out);
@@ -137,6 +137,10 @@ const out = await page.evaluate(async () => {
// The same bounded copy and fallback/count policy is localized in Russian.
forceRed = true;
setCandidate('ru');
// #627: a live switch to ru holds the previous frame until the lazy editor
// dictionaries settle; the dialog is judged after the language gate.
card.requestUpdate(); await card.updateComplete;
for (let i = 0; i < 250 && card.hasAttribute('aria-busy'); i++) await new Promise((r) => setTimeout(r, 20));
card._editorRuntime.optimizePlans.open(); await card.updateComplete;
const russianText = card.renderRoot.querySelector('hp-dialog .body')?.textContent || '';
result.russianFailureHasExactCopy = russianText.includes(
+4
View File
@@ -22,6 +22,10 @@ const out = await page.evaluate(async () => {
},
};
await update();
// #627: a live language switch holds the previous frame until the lazy
// editor dictionaries settle (inert + aria-busy).
for (let i = 0; i < 250 && card.hasAttribute('aria-busy'); i++) await new Promise((r) => setTimeout(r, 20));
await update();
};
const fieldState = () => {
// #600 §4.2: масштаб — поле с единицей внутри рамки (`hpf-unit`).
+4
View File
@@ -302,6 +302,10 @@ const responsiveForm = async ({ width, language, dark, canWrite, kiosk }) => {
document.documentElement.toggleAttribute('dark', dark);
card._config = { ...card._config, language, kiosk };
card._serverCanWrite = canWrite;
// #627: this harness preloads the editor runtime, so a live language
// switch also waits for its lazy dictionaries (inert + aria-busy).
card.requestUpdate(); await card.updateComplete;
for (let i = 0; i < 250 && card.hasAttribute('aria-busy'); i++) await new Promise((r) => setTimeout(r, 20));
card._summary.updated();
await card._summary.openDialog();
await card.updateComplete;
+4
View File
@@ -45,6 +45,10 @@ const out = await page.evaluate(async () => {
card._config = { ...card._config, language };
card.requestUpdate();
await card.updateComplete;
// #627: the live switch holds the previous frame until the lazy editor
// dictionaries of this language settle (inert + aria-busy).
for (let i = 0; i < 250 && card.hasAttribute('aria-busy'); i++) await new Promise((r) => setTimeout(r, 20));
await card.updateComplete;
};
const rebuildGate = async () => {
card._serverCfg = {
+4
View File
@@ -32,6 +32,10 @@ const res = await page.evaluate(async () => {
},
};
await c.updateComplete;
// #627: ru strings of the editor dialogs are a lazy chunk; the switch to ru
// above holds the previous frame (inert + aria-busy) until it settles.
for (let i = 0; i < 250 && c.hasAttribute('aria-busy'); i++) await new Promise((r) => setTimeout(r, 20));
await c.updateComplete;
// заливка temp вкл → класс filled и тултип с температурой
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({ ...s,
settings: { show_borders: true, show_names: true, fill_mode: 'temp', temp_min: 20, temp_max: 25 } })) };
+149 -15
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { assertBundleManifest } from './bundle-tree.mjs';
import { NAMESPACE_LOCALE_CHUNKS } from './bundle-manifest.mjs';
// #352: the budget guards the CLASS of regression — tens of kilobytes from
// an accidentally imported dependency or an eagerly bundled dictionary —
@@ -349,18 +350,32 @@ export const INITIAL_VIEW_CEILING_BAND = 2_000;
*/
export const LOW_HEADROOM_ACKNOWLEDGED_CEILING = null;
/**
* Маркеры формы поддержки и граф, которому каждый принадлежит.
*
* #627: английский — слой отката словаря `support`, он статически в графе
* редактора. Русский с этой задачи — отдельный ленивый чанк `support`-ru: в
* статическом графе редактора его быть НЕ должно, иначе ×4 словарь вернулся.
*/
export const SUPPORT_LAZY_MARKERS = [
'Contact details (email/tg/WhatsApp), optional.',
'Контакт для связи (email/tg/WhatsApp), необязательно.',
{ text: 'Contact details (email/tg/WhatsApp), optional.', graph: 'lazyEditorFiles' },
{ text: 'Контакт для связи (email/tg/WhatsApp), необязательно.', graph: 'lazyNamespaceLocaleFiles' },
];
const GRAPH_LABELS = {
lazyEditorFiles: 'lazy editor graph',
lazyOnboardingFiles: 'lazy onboarding graph',
lazyNamespaceLocaleFiles: 'lazy namespace locale graph',
};
/**
* Форма поддержки живёт только в ленивом графе редактора (#423).
* Форма поддержки живёт только в ленивых графах (#423, #627).
*
* Функция судит ВЛАДЕНИЕ, а не размер: маркеры формы обязаны отсутствовать в
* `initialViewFiles` и присутствовать в `lazyEditorFiles`. Размер охраняют
* `assertBundleBudget` и `lowHeadroomWarning` — им для этого не нужен чужой
* номер issue (#429).
* Функция судит ВЛАДЕНИЕ, а не размер: маркер обязан отсутствовать в
* `initialViewFiles` и присутствовать в своём графе; маркер ленивого словаря
* вдобавок обязан отсутствовать в статических графах редактора и онбординга.
* Размер охраняют `assertBundleBudget` и `lowHeadroomWarning` — им для этого не
* нужен чужой номер issue (#429).
*/
export function assertSupportBundleOwnership(
manifest,
@@ -371,13 +386,85 @@ export function assertSupportBundleOwnership(
.map((path) => readFileSync(resolve(root, path), 'utf8'))
.join('\n');
const initial = graphText(manifest.initialViewFiles || []);
const editor = graphText(manifest.lazyEditorFiles || []);
for (const marker of markers) {
if (initial.includes(marker)) {
throw new Error(`support form copy leaked into initial View graph: ${marker}`);
for (const { text, graph } of markers) {
if (initial.includes(text)) {
throw new Error(`support form copy leaked into initial View graph: ${text}`);
}
if (!editor.includes(marker)) {
throw new Error(`support form copy missing from lazy editor graph: ${marker}`);
if (!graphText(manifest[graph] || []).includes(text)) {
throw new Error(`support form copy missing from ${GRAPH_LABELS[graph] || graph}: ${text}`);
}
if (graph === 'lazyNamespaceLocaleFiles') {
for (const staticGraph of ['lazyEditorFiles', 'lazyOnboardingFiles']) {
if (graphText(manifest[staticGraph] || []).includes(text)) {
throw new Error(`support form copy of a lazy locale leaked into ${GRAPH_LABELS[staticGraph]}: ${text}`);
}
}
}
}
}
/** #627: кто статически несёт английский слой каждого пространства. */
export const NAMESPACE_ENGLISH_CONSUMERS = {
settings: ['lazyEditorFiles', 'lazyOnboardingFiles'],
support: ['lazyEditorFiles'],
topology: ['lazyEditorFiles'],
};
/**
* #627 AC3: владение словарей пространств по СОДЕРЖИМОМУ собранных файлов.
*
* Маркер — самая длинная строка словаря, которой нет ни в одном словаре
* основного каталога (иначе совпадение с первым кадром было бы ложным).
* Английский обязан лежать в статическом графе каждого потребителя и не лежать
* в initial View; ru/de/fr — только в своём ленивом чанке: ни в initial View,
* ни в статических графах редактора и онбординга.
*/
export function namespaceLocaleMarkers(source = fileURLToPath(new URL('../src/i18n/', import.meta.url))) {
const read = (path) => JSON.parse(readFileSync(resolve(source, path), 'utf8'));
const main = ['en', 'ru', 'de', 'fr'].map((code) => JSON.stringify(read(`${code}.json`)));
const printable = (value) => typeof value === 'string' && value.length >= 12
&& !/["'`\\\n$]/.test(value) && !main.some((dictionary) => dictionary.includes(value));
const markers = [];
for (const namespace of Object.keys(NAMESPACE_ENGLISH_CONSUMERS)) {
for (const language of ['en', 'ru', 'de', 'fr']) {
const values = Object.values(read(`${namespace}/${language}.json`)).filter(printable)
.sort((left, right) => right.length - left.length || left.localeCompare(right));
if (!values.length) throw new Error(`${namespace}/${language}: no unique marker string`);
markers.push({ namespace, language, text: values[0] });
}
}
return markers;
}
export function assertNamespaceLocaleOwnership(
manifest,
root = 'dist',
markers = namespaceLocaleMarkers(),
) {
const graphText = (paths) => paths
.map((path) => readFileSync(resolve(root, path), 'utf8'))
.join('\n');
const text = Object.fromEntries(['initialViewFiles', ...Object.keys(GRAPH_LABELS)]
.map((graph) => [graph, graphText(manifest[graph] || [])]));
for (const marker of markers) {
const where = `${marker.namespace}/${marker.language}`;
if (text.initialViewFiles.includes(marker.text)) {
throw new Error(`${where} dictionary leaked into initial View graph: ${marker.text}`);
}
const owners = marker.language === 'en'
? NAMESPACE_ENGLISH_CONSUMERS[marker.namespace]
: ['lazyNamespaceLocaleFiles'];
for (const graph of owners) {
if (!text[graph].includes(marker.text)) {
throw new Error(`${where} dictionary missing from ${GRAPH_LABELS[graph]}: ${marker.text}`);
}
}
if (marker.language !== 'en') {
for (const graph of ['lazyEditorFiles', 'lazyOnboardingFiles']) {
if (text[graph].includes(marker.text)) {
throw new Error(`${where} dictionary is static in ${GRAPH_LABELS[graph]} — it must be its own lazy chunk: ${marker.text}`);
}
}
}
}
}
@@ -472,10 +559,31 @@ export const LAZY_FURNITURE_ART_GZIP_CEILING = 17_900;
* адаптер порта из 18 членов уникальны, а сжатие соседних `this.host._t(`
* в рантайме их не покрывает. Старый центр оставлял 425 Б сверху, меньше
* порога шума #593; новый — 1 025 Б сверху и 975 Б до нижней границы.
* - #627: 246 000 → 229 500 (замер XXX). ru/de/fr словарей `settings`,
* `support`, `topology` ушли из статического графа в девять ленивых чанков
* (по одному на пару «пространство × язык», грузится только язык на экране);
* в графе остался английский слой отката и загрузчик. Новая строка любого из
* трёх словарей теперь стоит здесь ×1, а не ×4. Центр оставляет XXX Б
* сверху и XXX Б до нижней границы полосы.
*/
export const LAZY_EDITOR_GZIP_CEILING = 246_000;
export const LAZY_EDITOR_GZIP_CEILING = 229_500;
export const LAZY_GRAPH_CEILING_BAND = 2_000;
/**
* #627: граф первого запуска (форма «Пространство») впервые под потолком.
* До этой задачи он только считался манифестом и не печатался даже в отчёт —
* и вырос 13 918 → 34 526 Б (+148 %) незаметно: общая форма #600 утащила в
* него form-kit, `hp-color-opacity` и четыре словаря `settings`.
*
* Потолок 28 500 (замер 27 470): ru/de/fr словаря `settings` — ленивые чанки,
* в графе остались английский слой и загрузчик. Центр оставляет 1 030 Б
* сверху и 970 Б до нижней границы полосы. Исходная цель ≤ 20 КБ этим не
* достигается: остаток — form-kit, `space-form`, `hp-help` и стили набора
* (форма #600), не словари; их вынос заметен пользователю (задержка на первом
* «?») и в скоуп #627 не входит.
*/
export const LAZY_ONBOARDING_GZIP_CEILING = 28_500;
/**
* Потолок ленивого графа: `null`, пока значение внутри полосы.
*
@@ -537,6 +645,7 @@ export function assertBundleBudget(
panelOnlyBudget = INITIAL_PANEL_ONLY_GZIP_BUDGET,
lazyFurnitureArtCeiling = LAZY_FURNITURE_ART_GZIP_CEILING,
lazyEditorCeiling = LAZY_EDITOR_GZIP_CEILING,
lazyOnboardingCeiling = LAZY_ONBOARDING_GZIP_CEILING,
) {
assertBundleManifest(manifest);
if (!manifest.lazyEditorFiles?.length) {
@@ -575,6 +684,22 @@ export function assertBundleBudget(
|| manifest.lazyOnboardingFiles?.includes(path))) {
throw new Error('lazy locale graph overlaps an editor graph');
}
// #627 AC3: ru/de/fr трёх словарей пространств — девять отдельных чанков,
// ни один не входит ни в первый кадр, ни в статический граф редакторов.
const namespaceLocales = manifest.lazyNamespaceLocaleFiles;
if (!Array.isArray(namespaceLocales) || namespaceLocales.length !== NAMESPACE_LOCALE_CHUNKS.length) {
throw new Error(`lazy namespace locale graph has ${namespaceLocales?.length ?? 0} files,`
+ ` expected ${NAMESPACE_LOCALE_CHUNKS.length} (settings/support/topology × ru/de/fr)`);
}
for (const [graph, label] of [
['initialViewFiles', 'initial View graph'],
['lazyEditorFiles', 'lazy editor graph'],
['lazyOnboardingFiles', 'lazy onboarding graph'],
]) {
if (namespaceLocales.some((path) => manifest[graph]?.includes(path))) {
throw new Error(`${label} overlaps lazy namespace locale graph`);
}
}
if (manifest.initialViewGzipBytes > budget) {
throw new Error(
`initial View graph ${manifest.initialViewGzipBytes} B gzip exceeds ${budget} B budget`,
@@ -590,6 +715,8 @@ export function assertBundleBudget(
for (const [bytes, ceiling, label] of [
[manifest.lazyFurnitureArtGzipBytes, lazyFurnitureArtCeiling, 'lazy furniture art graph'],
[manifest.lazyEditorGzipBytes, lazyEditorCeiling, 'lazy editor graph'],
// #627: граф первого запуска — третий гейт, тем же правилом.
[manifest.lazyOnboardingGzipBytes, lazyOnboardingCeiling, 'lazy onboarding graph'],
]) {
const violation = lazyGraphCeilingViolation(bytes, { ceiling, label });
if (violation) throw new Error(violation.text);
@@ -599,6 +726,8 @@ export function assertBundleBudget(
initialPanelGzipBytes: manifest.initialPanelGzipBytes,
initialPanelOnlyGzipBytes: manifest.initialPanelOnlyGzipBytes,
lazyEditorGzipBytes: manifest.lazyEditorGzipBytes,
lazyOnboardingGzipBytes: manifest.lazyOnboardingGzipBytes,
lazyNamespaceLocaleGzipBytes: manifest.lazyNamespaceLocaleGzipBytes,
lazyLocaleGzipBytes: manifest.lazyLocaleGzipBytes,
lazyIsometricGzipBytes: manifest.lazyIsometricGzipBytes,
lazyFurnitureArtGzipBytes: manifest.lazyFurnitureArtGzipBytes,
@@ -611,6 +740,7 @@ if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
const manifest = JSON.parse(readFileSync(resolve('dist/houseplan-assets.json'), 'utf8'));
const result = assertBundleBudget(manifest);
assertSupportBundleOwnership(manifest);
assertNamespaceLocaleOwnership(manifest);
const ceiling = initialViewCeilingViolation(result.initialViewGzipBytes);
if (ceiling) throw new Error(ceiling.text);
const headroom = INITIAL_VIEW_GZIP_BUDGET - result.initialViewGzipBytes;
@@ -623,6 +753,10 @@ if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
+ ` (budget ${INITIAL_PANEL_ONLY_GZIP_BUDGET} B,`
+ ` headroom ${INITIAL_PANEL_ONLY_GZIP_BUDGET - result.initialPanelOnlyGzipBytes} B)`,
`lazy editor: ${result.lazyEditorGzipBytes} B gzip (потолок ${LAZY_EDITOR_GZIP_CEILING} B ±${LAZY_GRAPH_CEILING_BAND})`,
`lazy onboarding: ${result.lazyOnboardingGzipBytes} B gzip`
+ ` (потолок ${LAZY_ONBOARDING_GZIP_CEILING} B ±${LAZY_GRAPH_CEILING_BAND})`,
`lazy namespace locales: ${result.lazyNamespaceLocaleGzipBytes} B gzip`
+ ` (${NAMESPACE_LOCALE_CHUNKS.length} chunks, по одному грузится на пространство)`,
`lazy furniture art: ${result.lazyFurnitureArtGzipBytes} B gzip`
+ ` (потолок ${LAZY_FURNITURE_ART_GZIP_CEILING} B ±${LAZY_GRAPH_CEILING_BAND})`,
`lazy locale: ${result.lazyLocaleGzipBytes} B gzip`,
+48
View File
@@ -12,6 +12,22 @@ const FR_RETRY_ASSET_TOKEN = '__HOUSEPLAN_FR_RETRY_ASSET__';
const FURNITURE_ART_RETRY_ASSET_TOKEN = '__HOUSEPLAN_FURNITURE_ART_RETRY_ASSET__';
const PDF_RETRY_ASSET_TOKEN = '__HOUSEPLAN_PDF_RETRY_ASSET__';
/**
* #627: ru/de/fr of the three lazy dictionary namespaces are one chunk per
* namespace × language. Their loader modules are the recognition marker for
* the manifest graph and the target of each second-attempt retry token.
*/
export const NAMESPACE_LOCALE_CHUNKS = ['settings', 'support', 'topology']
.flatMap((namespace) => ['ru', 'de', 'fr'].map((language) => ({
namespace,
language,
module: `/src/i18n/${namespace}/${namespace}-${language}.ts`,
token: `__HOUSEPLAN_${namespace.toUpperCase()}_${language.toUpperCase()}_RETRY_ASSET__`,
})));
const isNamespaceLocaleModule = (id) => NAMESPACE_LOCALE_CHUNKS
.some((chunk) => id.endsWith(chunk.module));
export const CARD_ENTRY_FILE = 'houseplan-card.js';
export const PANEL_ENTRY_FILE = 'houseplan-panel.js';
@@ -84,6 +100,8 @@ export function buildBundleManifest(bundle, fingerprint) {
const modules = Object.keys(chunk.modules || {}).map((id) => id.replaceAll('\\', '/'));
const role = modules.some((id) => id.endsWith('/src/i18n/de.ts') || id.endsWith('/src/i18n/fr.ts'))
? 'locale'
: modules.some(isNamespaceLocaleModule)
? 'namespace-locale'
: modules.some((id) => id.endsWith('/src/houseplan-onboarding-runtime.ts'))
? 'onboarding'
: modules.some((id) => id.endsWith('/src/houseplan-editor-runtime.ts'))
@@ -135,6 +153,10 @@ export function buildBundleManifest(bundle, fingerprint) {
const furnitureArtRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'furniture-art');
const pdfRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'pdf'
|| path.includes('pdf-export-'));
// #627: namespace dictionaries are dynamic imports of LAZY chunks, never of
// the initial graph, so they are found by their module, not as a root.
const namespaceLocaleRoots = files.filter((file) => file._role === 'namespace-locale')
.map((file) => file.path);
const graphFrom = (roots) => {
const graph = new Set();
for (const root of roots) {
@@ -148,6 +170,7 @@ export function buildBundleManifest(bundle, fingerprint) {
const lazyIsometric = graphFrom(isometricRoots);
const lazyFurnitureArt = graphFrom(furnitureArtRoots);
const lazyPdf = graphFrom(pdfRoots);
const lazyNamespaceLocale = graphFrom(namespaceLocaleRoots);
const sum = (paths) => [...paths]
.reduce((total, path) => total + (byPath.get(path)?.gzipBytes || 0), 0);
return {
@@ -175,6 +198,8 @@ export function buildBundleManifest(bundle, fingerprint) {
lazyFurnitureArtGzipBytes: sum(lazyFurnitureArt),
lazyPdfFiles: [...lazyPdf].sort(),
lazyPdfGzipBytes: sum(lazyPdf),
lazyNamespaceLocaleFiles: [...lazyNamespaceLocale].sort(),
lazyNamespaceLocaleGzipBytes: sum(lazyNamespaceLocale),
files: files.map(({ _role, ...file }) => file),
};
}
@@ -239,6 +264,14 @@ export function editorRuntimeRetryUrlPlugin() {
if (!french) throw new Error('French locale chunk was not emitted');
if (!furnitureArt) throw new Error('furniture artwork chunk was not emitted');
if (!pdf) throw new Error('PDF export runtime chunk was not emitted');
const namespaceLocales = NAMESPACE_LOCALE_CHUNKS.map((entry) => {
const chunk = chunks.find((candidate) => Object.keys(candidate.modules)
.some((id) => id.replaceAll('\\', '/').endsWith(entry.module)));
if (!chunk) {
throw new Error(`${entry.namespace} ${entry.language} locale chunk was not emitted`);
}
return { ...entry, chunk, replacements: 0 };
});
let furnitureArtReplacements = 0;
let editorReplacements = 0;
let onboardingReplacements = 0;
@@ -289,6 +322,13 @@ export function editorRuntimeRetryUrlPlugin() {
pdfReplacements += chunk.code.split(PDF_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(PDF_RETRY_ASSET_TOKEN, asset);
}
for (const entry of namespaceLocales) {
if (!chunk.code.includes(entry.token)) continue;
let asset = posix.relative(posix.dirname(chunk.fileName), entry.chunk.fileName);
if (!asset.startsWith('.')) asset = `./${asset}`;
entry.replacements += chunk.code.split(entry.token).length - 1;
chunk.code = chunk.code.replaceAll(entry.token, asset);
}
}
if (editorReplacements !== 1 || onboardingReplacements !== 1 || isometricReplacements !== 1
|| germanReplacements !== 1 || frenchReplacements !== 1 || furnitureArtReplacements !== 1
@@ -296,6 +336,14 @@ export function editorRuntimeRetryUrlPlugin() {
throw new Error('lazy retry URL placeholder counts are '
+ `${editorReplacements}/${onboardingReplacements}/${isometricReplacements}/${germanReplacements}/${frenchReplacements}/${furnitureArtReplacements}/${pdfReplacements}, expected 1/1/1/1/1/1/1`);
}
// #627: the same strict rule for every namespace × language token —
// exactly one second-attempt URL each, never zero and never two.
const namespaceMismatch = namespaceLocales.filter((entry) => entry.replacements !== 1);
if (namespaceMismatch.length) {
throw new Error('namespace locale retry URL placeholder counts are '
+ namespaceMismatch.map((entry) => `${entry.namespace}-${entry.language}=${entry.replacements}`).join(', ')
+ ', expected exactly 1 each');
}
},
};
}
+76 -5
View File
@@ -2478,6 +2478,63 @@ const MUTANT_DEFINITIONS = [
replace: " : await import(/* @vite-ignore */ new URL(`${FRENCH_RETRY_ASSET}?retry`, import.meta.url).href);\n void module.dictionary;\n return { dictionary: en, fingerprint: module.fingerprint };",
}],
},
{
id: 'i18n-namespace-eager-locale',
guard: 'node --test test/i18n-lazy-namespaces.test.mjs',
because: 'a static import of one ru/de/fr dictionary of a namespace puts all of it back into '
+ 'the static editor and onboarding graphs — every new string costs ×4 again, the exact '
+ 'growth #627 removes (13.9 → 34.5 KB gzip of first-run onboarding)',
patches: [{
file: 'src/i18n/settings.ts',
find: "import en from './settings/en.json' with { type: 'json' };\n",
replace: "import en from './settings/en.json' with { type: 'json' };\n"
+ "import ru from './settings/ru.json' with { type: 'json' };\n",
}, {
file: 'src/i18n/settings.ts',
find: ' SETTINGS_LANGUAGE_RUNTIME.dictionary(lang)?.[key] ?? en[key];',
replace: " (lang === 'ru' ? ru[key] : SETTINGS_LANGUAGE_RUNTIME.dictionary(lang)?.[key]) ?? en[key];",
}],
},
{
id: 'locale-gate-ignores-namespaces',
guard: 'node --test --test-name-pattern="#627 AC5" test/i18n-runtime.test.mjs',
because: 'a host gate that asks only the main catalog paints the open settings dialog in '
+ 'English for one frame after a live language switch — the flash #348 forbids, now for '
+ 'the lazy namespace dictionaries (#627 AC5)',
patches: [{
file: 'src/i18n/namespace-language.ts',
find: ' state: (code) => {\n'
+ ' const state = primary.state(code);\n'
+ " return state !== 'pending' && namespaces.some((runtime) => runtime.state(code) === 'pending')\n"
+ " ? 'pending' : state;\n"
+ ' },',
replace: ' state: (code) => primary.state(code),',
}],
},
{
id: 'onboarding-loader-skips-namespace-ensure',
guard: 'node demo/smoke_lazy_admin_locale.mjs',
because: 'an onboarding runtime installed before its settings dictionary settled forces the '
+ 'host into the warm branch: the first-run form waits behind an inert, busy card instead '
+ 'of the existing runtime indicator — a new interface state the contract rules out (#627 AC4)',
patches: [{
file: 'src/houseplan-card.ts',
find: ' if (module.ONBOARDING_RUNTIME_FINGERPRINT === ENTRY_BUILD_FINGERPRINT) await module.ONBOARDING_LANGUAGE_RUNTIME.ensure(langOf(this.hass, this._config?.language)); // #627\n',
replace: '',
}],
},
{
id: 'namespace-loader-returns-english',
guard: 'node demo/smoke_lazy_admin_locale.mjs',
because: 'a German administrator silently reading the English settings dialog is invisible '
+ 'to parity units — only the bundle smoke proves the de loader commits the de chunk (#627 AC6)',
patches: [{
file: 'src/i18n/settings.ts',
find: " : import(/* @vite-ignore */ retryUrl('__HOUSEPLAN_SETTINGS_DE_RETRY_ASSET__'))),\n",
replace: " : import(/* @vite-ignore */ retryUrl('__HOUSEPLAN_SETTINGS_DE_RETRY_ASSET__')))\n"
+ ' .then((module) => ({ dictionary: en, fingerprint: module.fingerprint })),\n',
}],
},
{
id: 'static-glow-light-cache-spread',
guard: 'node --test test/space-render-caches.test.mjs',
@@ -2668,8 +2725,9 @@ const MUTANT_DEFINITIONS = [
find: " /** Synchronize host/runtime language state and return the current branch. */\n"
+ ' private _syncDangerConfirmLocaleGate(): LanguageRenderGate {\n'
+ " if (!this._config || !this.hass) return 'ready';\n"
+ ' return languageRenderGate(\n'
+ ' this, LANGUAGE_RUNTIME, langOf(this.hass, this._config.language),\n'
+ ' return languageRenderGate( // #627: + dictionaries of the surfaces loaded on THIS host\n'
+ ' this, this._editorRuntime?.languageRuntime ?? this._onboardingRuntime?.languageRuntime ?? LANGUAGE_RUNTIME,\n'
+ ' langOf(this.hass, this._config.language),\n'
+ ' );\n'
+ ' }',
replace: " private _dangerConfirmLocaleGate: LanguageRenderGate = 'ready';\n"
@@ -2681,7 +2739,8 @@ const MUTANT_DEFINITIONS = [
find: ' const localeGate = this._syncDangerConfirmLocaleGate();\n'
+ " if (localeGate === 'cold') return languageLoadingTemplate();",
replace: ' const localeGate = languageRenderGate(\n'
+ ' this, LANGUAGE_RUNTIME, langOf(this.hass, this._config.language),\n'
+ ' this, this._editorRuntime?.languageRuntime ?? this._onboardingRuntime?.languageRuntime ?? LANGUAGE_RUNTIME,\n'
+ ' langOf(this.hass, this._config.language),\n'
+ ' );\n'
+ ' this._dangerConfirmLocaleGate = localeGate;\n'
+ " if (localeGate === 'cold') return languageLoadingTemplate();",
@@ -8133,8 +8192,8 @@ const MUTANT_DEFINITIONS = [
+ 'dictionary, not of the resolved string (#459 AC2)',
patches: [{
file: 'src/i18n/topology.ts',
find: ' const value = DICTIONARIES[lang]?.[key] ?? en[key];',
replace: ' const value = DICTIONARIES[lang]?.[key] ?? en[key] ?? key;',
find: ' const value = TOPOLOGY_LANGUAGE_RUNTIME.dictionary(lang)?.[key] ?? en[key];',
replace: ' const value = TOPOLOGY_LANGUAGE_RUNTIME.dictionary(lang)?.[key] ?? en[key] ?? key;',
}],
},
{
@@ -9524,6 +9583,18 @@ const MUTANT_DEFINITIONS = [
+ ' if (Number.isFinite(bytes)) return null; // mutant',
}],
},
{
id: 'bundle-budget-onboarding-ceiling-dropped',
guard: 'node --test --test-name-pattern="#627 AC1" test/bundle-assets.test.mjs',
because: '#627: the first-run graph grew 13.9 → 34.5 KB gzip unnoticed because it was only '
+ 'counted, never compared. Dropping it from the ceiling loop returns exactly that state — '
+ 'a declared ceiling nobody enforces',
patches: [{
file: 'scripts/bundle-budget.mjs',
find: " [manifest.lazyOnboardingGzipBytes, lazyOnboardingCeiling, 'lazy onboarding graph'],\n",
replace: '',
}],
},
{
id: 'furniture-art-eager-import',
guard: 'npm run build && node scripts/bundle-budget.mjs',
+11
View File
@@ -283,6 +283,17 @@ export const SMOKE_LINKS = [
+ 'requests and four root render gates: neutral cold frame, atomic German commit, page-cache '
+ 'reuse and the bounded retry-to-English path cannot be proven from pure registry tests (#348)',
},
{
symbols: [
'namespaceLanguageRuntime', 'composeLanguageRuntimes', 'surfaceLanguageRuntime',
'SETTINGS_LANGUAGE_RUNTIME', 'SUPPORT_LANGUAGE_RUNTIME', 'TOPOLOGY_LANGUAGE_RUNTIME',
'EDITOR_LANGUAGE_RUNTIME', 'ONBOARDING_LANGUAGE_RUNTIME', 'settingsT', 'supportT', 'topologyT',
],
smokes: ['smoke_lazy_admin_locale.mjs', 'smoke_dialog_segments_i18n.mjs'],
because: 'the ru/de/fr namespace dictionaries are separate lazy chunks: only the production '
+ 'bundle shows the per-frame dialog text on a cold open, the held frame on a live switch, '
+ 'the bounded retry-to-English path and the exact chunk requests of each surface (#627)',
},
{
symbols: [
'probeBackdrop', 'classifyPlanFile', 'downscaleBackdrop', 'renderBackdropGuard',
+5 -2
View File
@@ -569,6 +569,7 @@ export class HouseplanCard extends LitElement {
url.searchParams.set('hp_retry', `${CARD_VERSION}-${++hpLazyRetrySeq}`);
return url.href;
})()) as typeof import('./houseplan-editor-runtime');
if (module.EDITOR_RUNTIME_FINGERPRINT === ENTRY_BUILD_FINGERPRINT) await module.EDITOR_LANGUAGE_RUNTIME.ensure(langOf(this.hass, this._config?.language)); // #627: dictionaries settle before install
return {
fingerprint: module.EDITOR_RUNTIME_FINGERPRINT,
create: () => new module.HouseplanEditorRuntime(
@@ -602,6 +603,7 @@ export class HouseplanCard extends LitElement {
url.searchParams.set('hp_retry', `${CARD_VERSION}-${++hpLazyRetrySeq}`);
return url.href;
})()) as typeof import('./houseplan-onboarding-runtime');
if (module.ONBOARDING_RUNTIME_FINGERPRINT === ENTRY_BUILD_FINGERPRINT) await module.ONBOARDING_LANGUAGE_RUNTIME.ensure(langOf(this.hass, this._config?.language)); // #627
return {
fingerprint: module.ONBOARDING_RUNTIME_FINGERPRINT,
create: () => new module.HouseplanOnboardingRuntime(
@@ -1965,8 +1967,9 @@ export class HouseplanCard extends LitElement {
/** Synchronize host/runtime language state and return the current branch. */
private _syncDangerConfirmLocaleGate(): LanguageRenderGate {
if (!this._config || !this.hass) return 'ready';
return languageRenderGate(
this, LANGUAGE_RUNTIME, langOf(this.hass, this._config.language),
return languageRenderGate( // #627: + dictionaries of the surfaces loaded on THIS host
this, this._editorRuntime?.languageRuntime ?? this._onboardingRuntime?.languageRuntime ?? LANGUAGE_RUNTIME,
langOf(this.hass, this._config.language),
);
}
private readonly _dangerConfirmController = new HpConfirmController((state) => {
+4 -1
View File
@@ -205,7 +205,7 @@ import { enqueueSerializedWrite, type OptimisticAttempt } from './serialized-wri
import { applyCalibrationProposal, saveAutomaticCalibration, saveManualCalibration, saveVacuumMatrix,
type CalibrationProposal, type VacuumFit } from './vacuum-calibration-write';
import { hasTranslation, langOf, t, type I18nKey } from './i18n';
import { supportT, type SupportI18nKey } from './i18n/support'; import { writeZigbeeTopologySettings, zigbeeTopologySettingsOf, type ZigbeeTopologySettings } from './zigbee-topology-settings';
import { supportT, type SupportI18nKey } from './i18n/support'; import { EDITOR_LANGUAGE_RUNTIME } from './i18n/editor-language'; import { writeZigbeeTopologySettings, zigbeeTopologySettingsOf, type ZigbeeTopologySettings } from './zigbee-topology-settings';
import {
newSupportDialogState,
supportApiCompatible,
@@ -793,7 +793,10 @@ export interface HouseplanEditorHostPort {
/** Lazily loaded implementation of editor-only interaction and rendering. */
export const EDITOR_RUNTIME_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
export { EDITOR_LANGUAGE_RUNTIME }; // #627: see src/i18n/editor-language.ts
export class HouseplanEditorRuntime {
public readonly languageRuntime = EDITOR_LANGUAGE_RUNTIME;
private _junctionBaselineCache = new WeakMap<object, {
spaceId: string; fingerprint: string; violations: JunctionLimitViolation[];
}>();
+6
View File
@@ -1,6 +1,8 @@
import { html, nothing, type TemplateResult } from 'lit';
import { classifyPlanFile, encodePlanFile, renderBackdropGuard } from './backdrop-pick';
import { hasTranslation, langOf, t, type I18nKey } from './i18n';
import { SETTINGS_LANGUAGE_RUNTIME } from './i18n/settings';
import { surfaceLanguageRuntime } from './i18n/namespace-language';
import './hp-help';
import {
@@ -39,7 +41,11 @@ const BUILD_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
*/
export const ONBOARDING_RUNTIME_FINGERPRINT = BUILD_FINGERPRINT;
/** #627: the first-run space form paints only the `settings` namespace. */
export const ONBOARDING_LANGUAGE_RUNTIME = surfaceLanguageRuntime([SETTINGS_LANGUAGE_RUNTIME]);
export class HouseplanOnboardingRuntime {
public readonly languageRuntime = ONBOARDING_LANGUAGE_RUNTIME;
public constructor(public readonly host: HouseplanEditorHostPort) {}
private _help(key: Extract<I18nKey, `${string}.help`>): TemplateResult | typeof nothing {
+12 -1
View File
@@ -1,7 +1,7 @@
import { LitElement, css, html, svg, nothing, type PropertyValues } from 'lit';
import { langOf } from './i18n';
import { lqiColor } from './logic';
import { topologyT } from './i18n/topology';
import { TOPOLOGY_LANGUAGE_RUNTIME, topologyT } from './i18n/topology';
import {
mapTopologies, resolveMappedTopologyHover, type ZigbeeMappedTopology,
type ZigbeeParentTarget,
@@ -266,6 +266,17 @@ export class HpZigbeeTopologyOverlay extends LitElement {
}
protected render() {
// #627: the ru/de/fr topology strings are a lazy chunk. Request it once on
// mount — before the first hover can paint a label — and draw nothing
// until it settled (ready or bounded English fallback).
const lang = langOf(this.hass);
if (TOPOLOGY_LANGUAGE_RUNTIME.state(lang) === 'pending') {
void TOPOLOGY_LANGUAGE_RUNTIME.ensure(lang).then(() => {
if (this.isConnected) this.requestUpdate();
});
this._setDesiredEndpointIds([]);
return nothing;
}
if (!this._hovered || !this.registry || !this._runtime.topologies.length) {
this._setDesiredEndpointIds([]);
return nothing;
+19
View File
@@ -0,0 +1,19 @@
/**
* #627: the main catalog plus every lazy dictionary an editor dialog may
* paint. The host loader settles it for the current language before
* installing the editor runtime (a cold open never paints English first; the
* wait sits under the existing runtime indicator), and the host render gate
* consults it after a live language switch. It includes the onboarding set
* (`settings`), so a host with both runtimes loaded asks the editor's.
*
* A module of its own, not a line in the editor runtime: the onboarding graph
* must not reach `support`/`topology` through it.
*/
import { SETTINGS_LANGUAGE_RUNTIME } from './settings';
import { SUPPORT_LANGUAGE_RUNTIME } from './support';
import { TOPOLOGY_LANGUAGE_RUNTIME } from './topology';
import { surfaceLanguageRuntime } from './namespace-language';
export const EDITOR_LANGUAGE_RUNTIME = surfaceLanguageRuntime([
SETTINGS_LANGUAGE_RUNTIME, SUPPORT_LANGUAGE_RUNTIME, TOPOLOGY_LANGUAGE_RUNTIME,
]);
+70
View File
@@ -0,0 +1,70 @@
/**
* #627: page-scoped runtime of one lazy dictionary namespace (`settings`,
* `support`, `topology`). English is static — the synchronous fallback layer,
* exactly as in the main catalog — and ru/de/fr are separate lazy chunks, one
* per namespace × language, loaded only for the language on screen.
*
* It IS the tested `LanguageRuntime` (#354): two bounded attempts, the exact
* build fingerprint, `fallback` as a settled state, and the same page-scoped
* failure listener as the main catalog, so a failed namespace toasts through
* the one View-card subscriber and never adds a second message text.
*/
import {
LanguageRuntime, type LanguageRuntimeContract, type LazyLanguageModule, type LocaleDictionary,
} from './language-runtime';
import { LANGUAGE_RUNTIME, notifyLanguageLoadFailures } from './registry';
const BUILD_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
export type NamespaceLoader = (attempt: 0 | 1) => Promise<LazyLanguageModule>;
export interface NamespaceLoaders {
readonly ru: NamespaceLoader;
readonly de: NamespaceLoader;
readonly fr: NamespaceLoader;
}
export function namespaceLanguageRuntime(
english: LocaleDictionary,
loaders: NamespaceLoaders,
): LanguageRuntime {
return new LanguageRuntime([
{ code: 'en', dictionary: english },
{ code: 'ru', loadDictionary: loaders.ru },
{ code: 'de', loadDictionary: loaders.de },
{ code: 'fr', loadDictionary: loaders.fr },
], BUILD_FINGERPRINT, console.warn, notifyLanguageLoadFailures);
}
/**
* #627: the host gate of a card whose lazy surfaces carry their own
* dictionaries. Pending while the main catalog OR any namespace used by a
* surface loaded on this host is pending; otherwise the main catalog's state,
* so a failed namespace alone never flips the host `lang` to English.
*/
export function composeLanguageRuntimes(
primary: LanguageRuntimeContract,
namespaces: readonly LanguageRuntimeContract[],
): LanguageRuntimeContract {
return {
state: (code) => {
const state = primary.state(code);
return state !== 'pending' && namespaces.some((runtime) => runtime.state(code) === 'pending')
? 'pending' : state;
},
dictionary: (code) => primary.dictionary(code),
ensure: (code) => Promise.all([primary, ...namespaces].map((runtime) => runtime.ensure(code)))
.then(() => undefined),
};
}
/**
* The host-facing runtime of one lazy surface: the main catalog plus the
* namespaces the surface paints. Lives in the lazy graph, so the initial View
* pays nothing for it; the host only picks the runtime of what it loaded.
*/
export function surfaceLanguageRuntime(
namespaces: readonly LanguageRuntimeContract[],
): LanguageRuntimeContract {
return composeLanguageRuntimes(LANGUAGE_RUNTIME, namespaces);
}
+20 -6
View File
@@ -3,26 +3,40 @@
* инструмента администратора живут в ленивом графе, а не в первом кадре, и в
* своём словаре, а не в `support` — иначе онбординг тянул бы весь словарь
* редактора ради подписей одной формы.
*
* #627: статически здесь только английский — слой отката; ru/de/fr — отдельные
* ленивые чанки, по одному на язык, и грузится только язык на экране. Готовность
* до отрисовки обеспечивают загрузчики рантаймов и составной гейт хоста.
*/
import { subst } from '../logic';
import type { Lang } from './registry';
import de from './settings/de.json' with { type: 'json' };
import en from './settings/en.json' with { type: 'json' };
import fr from './settings/fr.json' with { type: 'json' };
import ru from './settings/ru.json' with { type: 'json' };
import { namespaceLanguageRuntime } from './namespace-language';
export type SettingsI18nKey = keyof typeof en;
const DICTIONARIES: Record<Lang, Record<SettingsI18nKey, string>> = { en, ru, de, fr };
const retryUrl = (asset: string): string => new URL(`${asset}?retry`, import.meta.url).href;
export const SETTINGS_LANGUAGE_RUNTIME = namespaceLanguageRuntime(en, {
ru: (attempt) => (attempt === 0 ? import('./settings/settings-ru')
: import(/* @vite-ignore */ retryUrl('__HOUSEPLAN_SETTINGS_RU_RETRY_ASSET__'))),
de: (attempt) => (attempt === 0 ? import('./settings/settings-de')
: import(/* @vite-ignore */ retryUrl('__HOUSEPLAN_SETTINGS_DE_RETRY_ASSET__'))),
fr: (attempt) => (attempt === 0 ? import('./settings/settings-fr')
: import(/* @vite-ignore */ retryUrl('__HOUSEPLAN_SETTINGS_FR_RETRY_ASSET__'))),
});
const entryOf = (lang: Lang, key: SettingsI18nKey): string | undefined =>
SETTINGS_LANGUAGE_RUNTIME.dictionary(lang)?.[key] ?? en[key];
/** Есть ли у ключа непустой текст (английский — слой отката, как у `topologyT`). */
export function hasSettingsTranslation(lang: Lang, key: string): key is SettingsI18nKey {
const value = DICTIONARIES[lang]?.[key as SettingsI18nKey] ?? en[key as SettingsI18nKey];
const value = entryOf(lang, key as SettingsI18nKey);
return typeof value === 'string' && value.trim().length > 0;
}
export function settingsT(
lang: Lang, key: SettingsI18nKey, vars?: Record<string, string | number>,
): string {
return subst(DICTIONARIES[lang]?.[key] ?? en[key] ?? key, vars);
return subst(entryOf(lang, key) ?? key, vars);
}
+6
View File
@@ -0,0 +1,6 @@
import dictionary from './de.json' with { type: 'json' };
/** #627: lazy `settings` × `de` chunk; must match the entry build before it is committed. */
export const fingerprint = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
export { dictionary };
+6
View File
@@ -0,0 +1,6 @@
import dictionary from './fr.json' with { type: 'json' };
/** #627: lazy `settings` × `fr` chunk; must match the entry build before it is committed. */
export const fingerprint = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
export { dictionary };
+6
View File
@@ -0,0 +1,6 @@
import dictionary from './ru.json' with { type: 'json' };
/** #627: lazy `settings` × `ru` chunk; must match the entry build before it is committed. */
export const fingerprint = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
export { dictionary };
+18 -11
View File
@@ -1,19 +1,26 @@
/** Lazy editor-only dictionaries owned by the editor runtime (#423). */
/**
* Lazy editor-only dictionaries owned by the editor runtime (#423).
*
* #627: English is static (the synchronous fallback); ru/de/fr are separate
* lazy chunks, one per language, loaded only for the language on screen.
*/
import { subst } from '../logic';
import type { Lang } from './registry';
import de from './support/de.json' with { type: 'json' };
import en from './support/en.json' with { type: 'json' };
import fr from './support/fr.json' with { type: 'json' };
import ru from './support/ru.json' with { type: 'json' };
import { namespaceLanguageRuntime } from './namespace-language';
export type SupportI18nKey = keyof typeof en;
const SUPPORT_DICTIONARIES: Record<Lang, Record<SupportI18nKey, string>> = {
en,
ru,
de,
fr,
};
const retryUrl = (asset: string): string => new URL(`${asset}?retry`, import.meta.url).href;
export const SUPPORT_LANGUAGE_RUNTIME = namespaceLanguageRuntime(en, {
ru: (attempt) => (attempt === 0 ? import('./support/support-ru')
: import(/* @vite-ignore */ retryUrl('__HOUSEPLAN_SUPPORT_RU_RETRY_ASSET__'))),
de: (attempt) => (attempt === 0 ? import('./support/support-de')
: import(/* @vite-ignore */ retryUrl('__HOUSEPLAN_SUPPORT_DE_RETRY_ASSET__'))),
fr: (attempt) => (attempt === 0 ? import('./support/support-fr')
: import(/* @vite-ignore */ retryUrl('__HOUSEPLAN_SUPPORT_FR_RETRY_ASSET__'))),
});
/** Translate editor-only copy with the same synchronous English fallback. */
export function supportT(
@@ -21,5 +28,5 @@ export function supportT(
key: SupportI18nKey,
vars?: Record<string, string | number>,
): string {
return subst(SUPPORT_DICTIONARIES[lang]?.[key] ?? en[key] ?? key, vars);
return subst(SUPPORT_LANGUAGE_RUNTIME.dictionary(lang)?.[key] ?? en[key] ?? key, vars);
}
+6
View File
@@ -0,0 +1,6 @@
import dictionary from './de.json' with { type: 'json' };
/** #627: lazy `support` × `de` chunk; must match the entry build before it is committed. */
export const fingerprint = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
export { dictionary };
+6
View File
@@ -0,0 +1,6 @@
import dictionary from './fr.json' with { type: 'json' };
/** #627: lazy `support` × `fr` chunk; must match the entry build before it is committed. */
export const fingerprint = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
export { dictionary };
+6
View File
@@ -0,0 +1,6 @@
import dictionary from './ru.json' with { type: 'json' };
/** #627: lazy `support` × `ru` chunk; must match the entry build before it is committed. */
export const fingerprint = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
export { dictionary };
+18 -6
View File
@@ -1,13 +1,25 @@
import { subst } from '../logic';
import type { Lang } from './registry';
import de from './topology/de.json' with { type: 'json' };
import en from './topology/en.json' with { type: 'json' };
import fr from './topology/fr.json' with { type: 'json' };
import ru from './topology/ru.json' with { type: 'json' };
import { namespaceLanguageRuntime } from './namespace-language';
export type TopologyI18nKey = keyof typeof en;
const DICTIONARIES: Record<Lang, Record<TopologyI18nKey, string>> = { en, ru, de, fr };
const retryUrl = (asset: string): string => new URL(`${asset}?retry`, import.meta.url).href;
/**
* #627: English is static (the synchronous fallback); ru/de/fr are separate
* lazy chunks. The View overlay and the editor runtime both wait for this
* runtime before they paint topology copy.
*/
export const TOPOLOGY_LANGUAGE_RUNTIME = namespaceLanguageRuntime(en, {
ru: (attempt) => (attempt === 0 ? import('./topology/topology-ru')
: import(/* @vite-ignore */ retryUrl('__HOUSEPLAN_TOPOLOGY_RU_RETRY_ASSET__'))),
de: (attempt) => (attempt === 0 ? import('./topology/topology-de')
: import(/* @vite-ignore */ retryUrl('__HOUSEPLAN_TOPOLOGY_DE_RETRY_ASSET__'))),
fr: (attempt) => (attempt === 0 ? import('./topology/topology-fr')
: import(/* @vite-ignore */ retryUrl('__HOUSEPLAN_TOPOLOGY_FR_RETRY_ASSET__'))),
});
/**
* Whether the namespace really carries this string (#459).
@@ -20,12 +32,12 @@ const DICTIONARIES: Record<Lang, Record<TopologyI18nKey, string>> = { en, ru, de
* missing in one locale still counts as available.
*/
export function hasTopologyTranslation(lang: Lang, key: TopologyI18nKey): boolean {
const value = DICTIONARIES[lang]?.[key] ?? en[key];
const value = TOPOLOGY_LANGUAGE_RUNTIME.dictionary(lang)?.[key] ?? en[key];
return typeof value === 'string' && value.trim().length > 0;
}
export function topologyT(
lang: Lang, key: TopologyI18nKey, vars?: Record<string, string | number>,
): string {
return subst(DICTIONARIES[lang]?.[key] ?? en[key] ?? key, vars);
return subst(TOPOLOGY_LANGUAGE_RUNTIME.dictionary(lang)?.[key] ?? en[key] ?? key, vars);
}
+6
View File
@@ -0,0 +1,6 @@
import dictionary from './de.json' with { type: 'json' };
/** #627: lazy `topology` × `de` chunk; must match the entry build before it is committed. */
export const fingerprint = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
export { dictionary };
+6
View File
@@ -0,0 +1,6 @@
import dictionary from './fr.json' with { type: 'json' };
/** #627: lazy `topology` × `fr` chunk; must match the entry build before it is committed. */
export const fingerprint = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
export { dictionary };
+6
View File
@@ -0,0 +1,6 @@
import dictionary from './ru.json' with { type: 'json' };
/** #627: lazy `topology` × `ru` chunk; must match the entry build before it is committed. */
export const fingerprint = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
export { dictionary };
+229 -12
View File
@@ -8,13 +8,14 @@ import test from 'node:test';
import {
buildBundleManifest, buildFingerprintPlugin, editorRuntimeRetryUrlPlugin,
entryFallbackPlugin,
entryFallbackPlugin, NAMESPACE_LOCALE_CHUNKS,
} from '../scripts/bundle-manifest.mjs';
import {
INITIAL_PANEL_ONLY_GZIP_BUDGET, INITIAL_VIEW_CEILING_BAND,
INITIAL_VIEW_GZIP_BUDGET, INITIAL_VIEW_GZIP_CEILING,
LAZY_EDITOR_GZIP_CEILING, LAZY_FURNITURE_ART_GZIP_CEILING, LAZY_GRAPH_CEILING_BAND,
lazyGraphCeilingViolation,
LAZY_ONBOARDING_GZIP_CEILING, lazyGraphCeilingViolation,
assertNamespaceLocaleOwnership, namespaceLocaleMarkers,
LOW_HEADROOM_ACKNOWLEDGED_CEILING, LOW_HEADROOM_WARNING_BYTES,
SUPPORT_LAZY_MARKERS,
assertBundleBudget, assertSupportBundleOwnership, initialViewCeilingViolation,
@@ -28,6 +29,15 @@ import {
cssTemplateMinifier, minifyCssText, minifyStaticCssTemplates,
} from '../scripts/css-template-minifier.mjs';
/** #627: the nine namespace × language chunks, each a dynamic import of a lazy chunk. */
const namespaceLocaleChunkPath = (entry) => `houseplan-assets/${entry.namespace}-${entry.language}-HASH.js`;
const namespaceLocaleBundleChunks = (code = (entry) => `${entry.namespace} ${entry.language}`) => Object.fromEntries(
NAMESPACE_LOCALE_CHUNKS.map((entry) => [namespaceLocaleChunkPath(entry), {
type: 'chunk', fileName: namespaceLocaleChunkPath(entry), code: code(entry), isEntry: false,
imports: [], dynamicImports: [], modules: { [`/repo${entry.module}`]: {} },
}]),
);
const minimalTwoEntryBundle = () => ({
'houseplan-panel.js': {
type: 'chunk', fileName: 'houseplan-panel.js', code: 'panel', isEntry: true,
@@ -171,8 +181,11 @@ test('bundle manifest separates static initial graph from dynamic editor graph',
type: 'chunk', fileName: 'houseplan-assets/editor.js', code: 'editor', isEntry: false,
// #474: the editor imports the furniture artwork statically; the View
// reaches the same chunk only dynamically, so it stays out of initial.
imports: ['shared.js', 'houseplan-assets/furniture-plan-art.generated-HASH.js'], dynamicImports: [],
imports: ['shared.js', 'houseplan-assets/furniture-plan-art.generated-HASH.js'],
// #627: namespace dictionaries are dynamic imports of a LAZY chunk.
dynamicImports: NAMESPACE_LOCALE_CHUNKS.map(namespaceLocaleChunkPath),
},
...namespaceLocaleBundleChunks(),
'houseplan-assets/furniture-plan-art.generated-HASH.js': {
type: 'chunk', fileName: 'houseplan-assets/furniture-plan-art.generated-HASH.js',
code: 'furniture artwork', isEntry: false, imports: [], dynamicImports: [],
@@ -219,6 +232,11 @@ test('bundle manifest separates static initial graph from dynamic editor graph',
assert.deepEqual(manifest.lazyFurnitureArtFiles, ['houseplan-assets/furniture-plan-art.generated-HASH.js']);
assert.deepEqual(manifest.lazyPdfFiles, ['houseplan-assets/pdf-export-HASH.js']);
assert.deepEqual(manifest.lazyEditorFiles, ['houseplan-assets/editor.js', 'houseplan-assets/furniture-plan-art.generated-HASH.js']);
// #627: found by module, not as a root of the initial graph; own graph.
assert.deepEqual(manifest.lazyNamespaceLocaleFiles, NAMESPACE_LOCALE_CHUNKS.map(namespaceLocaleChunkPath).sort());
assert.equal(manifest.lazyNamespaceLocaleGzipBytes, manifest.files
.filter((file) => manifest.lazyNamespaceLocaleFiles.includes(file.path))
.reduce((total, file) => total + file.gzipBytes, 0));
assert.deepEqual(manifest.lazyFiles, [
'houseplan-assets/de-HASH.js', 'houseplan-assets/editor.js',
'houseplan-assets/furniture-plan-art.generated-HASH.js',
@@ -228,13 +246,30 @@ test('bundle manifest separates static initial graph from dynamic editor graph',
]);
// Этот тест про РАЗДЕЛЕНИЕ графов, а не про их размеры: потолки ленивых
// графов (#593) задаются по самой фикстуре, чтобы она не проверяла лишнего.
const lazyCeilings = [manifest.lazyFurnitureArtGzipBytes, manifest.lazyEditorGzipBytes];
const lazyCeilings = [
manifest.lazyFurnitureArtGzipBytes, manifest.lazyEditorGzipBytes, manifest.lazyOnboardingGzipBytes,
];
assert.doesNotThrow(() => assertBundleBudget(manifest, 1_000_000, undefined, ...lazyCeilings));
assert.throws(() => assertBundleBudget(manifest, 1, undefined, ...lazyCeilings), /exceeds/);
assert.throws(
() => assertBundleBudget(manifest, 1_000_000, manifest.initialPanelOnlyGzipBytes - 1, ...lazyCeilings),
/panel-only graph.*exceeds/,
);
// #627 AC3: a namespace chunk pulled into a static graph is refused by name.
// A chunk reached from the initial graph never enters the namespace graph
// at all (the builder subtracts initial), so that leak surfaces as the
// count refusal below; the static editor graphs are checked by name.
for (const [graph, label] of [
['lazyEditorFiles', 'lazy editor graph'],
['lazyOnboardingFiles', 'lazy onboarding graph'],
]) {
const leaked = { ...manifest, [graph]: [...manifest[graph], manifest.lazyNamespaceLocaleFiles[0]] };
assert.throws(() => assertBundleBudget(leaked, 1_000_000, undefined, ...lazyCeilings),
new RegExp(`${label} overlaps lazy namespace locale graph`));
}
assert.throws(() => assertBundleBudget({
...manifest, lazyNamespaceLocaleFiles: manifest.lazyNamespaceLocaleFiles.slice(1),
}, 1_000_000, undefined, ...lazyCeilings), /lazy namespace locale graph has 8 files, expected 9/);
});
test('#486 Rollup names both stable entries explicitly', async () => {
@@ -401,13 +436,29 @@ test('#423 support form copy belongs only to the lazy editor graph', () => {
const temp = mkdtempSync(join(tmpdir(), 'houseplan-support-graph-'));
try {
writeFileSync(join(temp, 'initial.js'), 'header only');
writeFileSync(join(temp, 'editor.js'), 'lazy English marker · lazy Russian marker');
writeFileSync(join(temp, 'editor.js'), 'lazy English marker');
writeFileSync(join(temp, 'support-ru.js'), 'lazy Russian marker');
const manifest = {
initialViewFiles: ['initial.js'],
lazyEditorFiles: ['editor.js'],
lazyNamespaceLocaleFiles: ['support-ru.js'],
};
const markers = ['lazy English marker', 'lazy Russian marker'];
const markers = [
{ text: 'lazy English marker', graph: 'lazyEditorFiles' },
{ text: 'lazy Russian marker', graph: 'lazyNamespaceLocaleFiles' },
];
assert.doesNotThrow(() => assertSupportBundleOwnership(manifest, temp, markers));
// #627: the Russian copy statically in the editor again is the ×4 regression.
writeFileSync(join(temp, 'editor.js'), 'lazy English marker · lazy Russian marker');
assert.throws(
() => assertSupportBundleOwnership(manifest, temp, markers),
/lazy locale leaked into lazy editor graph/,
);
writeFileSync(join(temp, 'editor.js'), 'lazy English marker');
assert.throws(
() => assertSupportBundleOwnership({ ...manifest, lazyNamespaceLocaleFiles: [] }, temp, markers),
/missing from lazy namespace locale graph/,
);
writeFileSync(join(temp, 'initial.js'), 'lazy English marker');
assert.throws(
() => assertSupportBundleOwnership(manifest, temp, markers),
@@ -416,7 +467,7 @@ test('#423 support form copy belongs only to the lazy editor graph', () => {
writeFileSync(join(temp, 'initial.js'), 'header only');
assert.throws(
() => assertSupportBundleOwnership(
{ initialViewFiles: ['initial.js'], lazyEditorFiles: [] }, temp, markers,
{ ...manifest, lazyEditorFiles: [] }, temp, markers,
),
/missing from lazy editor graph/,
);
@@ -448,6 +499,12 @@ test('retry URL points at the content-hashed runtime chunk after naming', () =>
+ 'new URL("__HOUSEPLAN_FURNITURE_ART_RETRY_ASSET__", import.meta.url);'
+ 'new URL("__HOUSEPLAN_PDF_RETRY_ASSET__", import.meta.url)', modules: {},
},
// #627: the lazy namespace chunk that owns the nine second-attempt tokens.
'houseplan-assets/backdrop-pick-HASH.js': {
type: 'chunk', fileName: 'houseplan-assets/backdrop-pick-HASH.js', modules: {},
code: NAMESPACE_LOCALE_CHUNKS.map((entry) => `import("${entry.token}?retry")`).join(';'),
},
...namespaceLocaleBundleChunks(() => ''),
'houseplan-assets/houseplan-editor-runtime-HASH.js': {
type: 'chunk', fileName: 'houseplan-assets/houseplan-editor-runtime-HASH.js', code: '',
modules: { '/repo/src/houseplan-editor-runtime.ts': {} },
@@ -488,6 +545,50 @@ test('retry URL points at the content-hashed runtime chunk after naming', () =>
+ 'new URL("./furniture-plan-art.generated-HASH.js", import.meta.url);'
+ 'new URL("./pdf-export-HASH.js", import.meta.url)',
);
assert.equal(
bundle['houseplan-assets/backdrop-pick-HASH.js'].code,
NAMESPACE_LOCALE_CHUNKS.map((entry) => `import("./${entry.namespace}-${entry.language}-HASH.js?retry")`).join(';'),
);
});
test('#627 namespace retry tokens stay strict: exactly one each, every chunk emitted', () => {
const run = (mutate) => {
const bundle = {
'houseplan-assets/houseplan-card.js': {
type: 'chunk', fileName: 'houseplan-assets/houseplan-card.js', modules: {},
code: ['EDITOR', 'ONBOARDING', 'ISO', 'DE', 'FR', 'FURNITURE_ART', 'PDF']
.map((name) => `"__HOUSEPLAN_${name}_RETRY_ASSET__"`).join(';'),
},
...Object.fromEntries([
['houseplan-editor-runtime', '/src/houseplan-editor-runtime.ts'],
['houseplan-onboarding-runtime', '/src/houseplan-onboarding-runtime.ts'],
['iso-scene-render', '/src/iso-scene-render.ts'], ['de', '/src/i18n/de.ts'],
['fr', '/src/i18n/fr.ts'], ['furniture-plan-art.generated', '/src/furniture-plan-art.generated.ts'],
['pdf-export', '/src/pdf/pdf-export.ts'],
].map(([name, module]) => [`houseplan-assets/${name}-HASH.js`, {
type: 'chunk', fileName: `houseplan-assets/${name}-HASH.js`, code: '', modules: { [`/repo${module}`]: {} },
}])),
'houseplan-assets/lazy-HASH.js': {
type: 'chunk', fileName: 'houseplan-assets/lazy-HASH.js', modules: {},
code: NAMESPACE_LOCALE_CHUNKS.map((entry) => `"${entry.token}"`).join(';'),
},
...namespaceLocaleBundleChunks(() => ''),
};
mutate(bundle);
editorRuntimeRetryUrlPlugin().generateBundle({}, bundle);
return bundle;
};
assert.doesNotThrow(() => run(() => {}));
const token = NAMESPACE_LOCALE_CHUNKS[4].token;
assert.throws(() => run((bundle) => {
bundle['houseplan-assets/lazy-HASH.js'].code = bundle['houseplan-assets/lazy-HASH.js'].code.replace(`"${token}"`, '""');
}), /namespace locale retry URL placeholder counts are support-de=0, expected exactly 1 each/);
assert.throws(() => run((bundle) => {
bundle['houseplan-assets/lazy-HASH.js'].code += `;"${token}"`;
}), /support-de=2/);
assert.throws(() => run((bundle) => {
delete bundle[namespaceLocaleChunkPath(NAMESPACE_LOCALE_CHUNKS[8])];
}), /topology fr locale chunk was not emitted/);
});
test('bundle tree verification fails for a missing or tampered manifest asset', () => {
@@ -710,9 +811,16 @@ test('#429 проверка владения не судит размер гра
const temp = mkdtempSync(join(tmpdir(), 'houseplan-support-size-'));
try {
writeFileSync(join(temp, 'initial.js'), 'header only');
writeFileSync(join(temp, 'editor.js'), 'lazy English marker · lazy Russian marker');
const markers = ['lazy English marker', 'lazy Russian marker'];
const base = { initialViewFiles: ['initial.js'], lazyEditorFiles: ['editor.js'] };
writeFileSync(join(temp, 'editor.js'), 'lazy English marker');
writeFileSync(join(temp, 'support-ru.js'), 'lazy Russian marker');
const markers = [
{ text: 'lazy English marker', graph: 'lazyEditorFiles' },
{ text: 'lazy Russian marker', graph: 'lazyNamespaceLocaleFiles' },
];
const base = {
initialViewFiles: ['initial.js'], lazyEditorFiles: ['editor.js'],
lazyNamespaceLocaleFiles: ['support-ru.js'],
};
for (const initialViewGzipBytes of [0, 291_046, 10_000_000, undefined]) {
assert.doesNotThrow(
() => assertSupportBundleOwnership({ ...base, initialViewGzipBytes }, temp, markers),
@@ -831,7 +939,23 @@ const runBudgetCli = (initialViewGzipBytes) => {
mkdirSync(join(dir, 'dist'));
writeFileSync(join(dir, 'dist/houseplan-card.js'), 'view graph without support copy');
writeFileSync(join(dir, 'dist/houseplan-panel.js'), 'panel shell');
writeFileSync(join(dir, 'dist/editor.js'), SUPPORT_LAZY_MARKERS.join('\n'));
// #627: the CLI judges ownership by content — each marker in its own graph.
const namespaceMarkers = namespaceLocaleMarkers();
const english = (namespace) => namespaceMarkers
.find((marker) => marker.namespace === namespace && marker.language === 'en').text;
writeFileSync(join(dir, 'dist/editor.js'), [
...SUPPORT_LAZY_MARKERS.filter((marker) => marker.graph === 'lazyEditorFiles').map((marker) => marker.text),
english('settings'), english('support'), english('topology'),
].join('\n'));
writeFileSync(join(dir, 'dist/onboarding.js'), english('settings'));
for (const entry of NAMESPACE_LOCALE_CHUNKS) {
const marker = namespaceMarkers.find((candidate) => candidate.namespace === entry.namespace
&& candidate.language === entry.language).text;
const support = entry.namespace === 'support' && entry.language === 'ru'
? SUPPORT_LAZY_MARKERS.filter((candidate) => candidate.graph === 'lazyNamespaceLocaleFiles')
.map((candidate) => candidate.text) : [];
writeFileSync(join(dir, `dist/${entry.namespace}-${entry.language}.js`), [marker, ...support].join('\n'));
}
writeFileSync(join(dir, 'dist/locale.js'), 'lazy locale dictionary');
writeFileSync(join(dir, 'dist/isometric.js'), 'lazy isometric runtime');
writeFileSync(join(dir, 'dist/furniture-art.js'), 'lazy furniture artwork');
@@ -865,7 +989,11 @@ const runBudgetCli = (initialViewGzipBytes) => {
lazyFurnitureArtGzipBytes: LAZY_FURNITURE_ART_GZIP_CEILING - 1_000,
lazyPdfFiles: ['pdf.js'],
lazyPdfGzipBytes: 100,
lazyOnboardingFiles: [],
lazyOnboardingFiles: ['onboarding.js'],
lazyOnboardingGzipBytes: LAZY_ONBOARDING_GZIP_CEILING - 1_000,
lazyNamespaceLocaleFiles: NAMESPACE_LOCALE_CHUNKS
.map((entry) => `${entry.namespace}-${entry.language}.js`),
lazyNamespaceLocaleGzipBytes: 900,
}));
const script = fileURLToPath(new URL('../scripts/bundle-budget.mjs', import.meta.url));
const run = spawnSync(process.execPath, [script], { cwd: dir, encoding: 'utf8' });
@@ -878,6 +1006,9 @@ const runBudgetCli = (initialViewGzipBytes) => {
test('#438 CLI действительно применяет потолок, а не только объявляет его', () => {
const inside = runBudgetCli(INITIAL_VIEW_GZIP_CEILING - 500);
assert.equal(inside.status, 0, inside.output);
// #627 AC1: the onboarding graph is printed with its ceiling and band.
assert.match(inside.output, new RegExp(`lazy onboarding: ${LAZY_ONBOARDING_GZIP_CEILING - 1_000} B gzip`
+ ` \\(потолок ${LAZY_ONBOARDING_GZIP_CEILING} B ±${LAZY_GRAPH_CEILING_BAND}\\)`));
const grew = runBudgetCli(INITIAL_VIEW_GZIP_CEILING + 1);
assert.equal(grew.status, 1, grew.output);
@@ -905,6 +1036,8 @@ test('#593 потолки ленивых графов — гейт, а не ст
for (const [bytes, ceiling, label] of [
[manifest.lazyFurnitureArtGzipBytes, LAZY_FURNITURE_ART_GZIP_CEILING, 'lazy furniture art graph'],
[manifest.lazyEditorGzipBytes, LAZY_EDITOR_GZIP_CEILING, 'lazy editor graph'],
// #627 AC1: the first-run graph is the third gated lazy graph.
[manifest.lazyOnboardingGzipBytes, LAZY_ONBOARDING_GZIP_CEILING, 'lazy onboarding graph'],
]) {
const violation = lazyGraphCeilingViolation(bytes, { ceiling, label });
assert.equal(violation, null, violation?.text);
@@ -929,3 +1062,87 @@ test('#593 потолки ленивых графов — гейт, а не ст
assert.match(lazyGraphCeilingViolation(NaN, { ceiling: 1, label: 'lazy editor graph' }).text,
/lazy editor graph/);
});
const shippedManifest = () => JSON.parse(
readFileSync(new URL('../dist/houseplan-assets.json', import.meta.url), 'utf8'),
);
const shippedDist = fileURLToPath(new URL('../dist/', import.meta.url));
test('#627 AC1 граф онбординга гейтится тем же потолком с полосой, что editor и furniture art', () => {
const manifest = shippedManifest();
const ceilings = (onboarding) => [
manifest.lazyFurnitureArtGzipBytes, manifest.lazyEditorGzipBytes, onboarding,
];
const bytes = manifest.lazyOnboardingGzipBytes;
assert.ok(Number.isFinite(bytes) && bytes > 0, 'манифест обязан измерять граф онбординга');
assert.doesNotThrow(() => assertBundleBudget(manifest, 1_000_000, undefined, ...ceilings(bytes)));
assert.throws(
() => assertBundleBudget(manifest, 1_000_000, undefined, ...ceilings(bytes - 1)),
new RegExp(`lazy onboarding graph ${bytes} B gzip выше потолка ${bytes - 1} B на 1 B`),
);
assert.throws(
() => assertBundleBudget(manifest, 1_000_000, undefined, ...ceilings(bytes + LAZY_GRAPH_CEILING_BAND + 1)),
/lazy onboarding graph \d+ B gzip ниже потолка .*Опустите потолок/,
);
// Потолок по умолчанию — поставляемый, и поставляемый граф в его полосе.
assert.doesNotThrow(() => assertBundleBudget(manifest));
});
test('#627 AC3 словари пространств: девять ленивых чанков, английский — у потребителя', () => {
const manifest = shippedManifest();
assert.equal(manifest.lazyNamespaceLocaleFiles.length, NAMESPACE_LOCALE_CHUNKS.length);
for (const entry of NAMESPACE_LOCALE_CHUNKS) {
const own = manifest.lazyNamespaceLocaleFiles
.filter((path) => path.startsWith(`houseplan-assets/${entry.namespace}-${entry.language}-`));
assert.equal(own.length, 1, `${entry.namespace}-${entry.language}: ровно один отдельный чанк`);
}
for (const graph of ['initialViewFiles', 'lazyEditorFiles', 'lazyOnboardingFiles']) {
assert.deepEqual(manifest.lazyNamespaceLocaleFiles.filter((path) => manifest[graph].includes(path)), [],
`${graph} не содержит ни одного чанка словаря пространства`);
}
// По содержимому: en — статически у каждого потребителя и не в первом кадре;
// ru/de/fr — только в своём чанке.
assert.doesNotThrow(() => assertNamespaceLocaleOwnership(manifest, shippedDist));
assert.doesNotThrow(() => assertSupportBundleOwnership(manifest, shippedDist));
});
test('#627 AC3 проверка владения краснеет на каждом нарушении', () => {
const temp = mkdtempSync(join(tmpdir(), 'houseplan-namespace-owner-'));
const marker = (namespace, language) => ({ namespace, language, text: `${namespace} ${language} marker` });
const markers = [marker('settings', 'en'), marker('settings', 'ru'), marker('topology', 'en')];
const manifest = {
initialViewFiles: ['initial.js'], lazyEditorFiles: ['editor.js'],
lazyOnboardingFiles: ['onboarding.js'], lazyNamespaceLocaleFiles: ['settings-ru.js'],
};
const write = (files) => {
for (const [name, text] of Object.entries({
'initial.js': 'view', 'editor.js': 'settings en marker topology en marker',
'onboarding.js': 'settings en marker', 'settings-ru.js': 'settings ru marker', ...files,
})) writeFileSync(join(temp, name), text);
};
try {
write({});
assert.doesNotThrow(() => assertNamespaceLocaleOwnership(manifest, temp, markers));
write({ 'editor.js': 'settings en marker topology en marker settings ru marker' });
assert.throws(() => assertNamespaceLocaleOwnership(manifest, temp, markers),
/settings\/ru dictionary is static in lazy editor graph/);
write({ 'onboarding.js': 'settings en marker settings ru marker' });
assert.throws(() => assertNamespaceLocaleOwnership(manifest, temp, markers),
/settings\/ru dictionary is static in lazy onboarding graph/);
write({ 'initial.js': 'topology en marker' });
assert.throws(() => assertNamespaceLocaleOwnership(manifest, temp, markers),
/topology\/en dictionary leaked into initial View graph/);
write({ 'onboarding.js': 'form only' });
assert.throws(() => assertNamespaceLocaleOwnership(manifest, temp, markers),
/settings\/en dictionary missing from lazy onboarding graph/);
write({ 'settings-ru.js': 'empty' });
assert.throws(() => assertNamespaceLocaleOwnership(manifest, temp, markers),
/settings\/ru dictionary missing from lazy namespace locale graph/);
} finally {
rmSync(temp, { recursive: true, force: true });
}
// Маркеры реальных словарей не совпадают ни с одной строкой основного каталога.
const real = namespaceLocaleMarkers();
assert.equal(real.length, 12);
assert.equal(new Set(real.map((entry) => entry.text)).size, 12);
});
+107
View File
@@ -0,0 +1,107 @@
// #627 AC3: словари пространств `settings`, `support`, `topology` — английский
// статически (синхронный слой отката), ru/de/fr — отдельные ленивые чанки, по
// одному на пару «пространство × язык», у каждого загрузчик с двумя попытками.
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import { NAMESPACE_LOCALE_CHUNKS } from '../scripts/bundle-manifest.mjs';
const NAMESPACES = ['settings', 'support', 'topology'];
const LAZY_LANGUAGES = ['ru', 'de', 'fr'];
const source = (path) => readFileSync(new URL(`../src/i18n/${path}`, import.meta.url), 'utf8');
const json = (path) => JSON.parse(source(path));
const escape = (value) => value.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
test('#627 AC3 ни один ru/de/fr словарь пространства не импортируется статически', () => {
for (const namespace of NAMESPACES) {
const text = source(`${namespace}.ts`);
const staticImports = [...text.matchAll(/^import\s[^;]*?from\s+['"]([^'"]+)['"]/gm)]
.map((match) => match[1]);
assert.ok(staticImports.includes(`./${namespace}/en.json`),
`${namespace}: английский слой отката обязан быть статическим`);
for (const language of LAZY_LANGUAGES) {
const lazy = new RegExp(`^\\./${namespace}/(?:${language}\\.json|${namespace}-${language})$`);
assert.deepEqual(staticImports.filter((path) => lazy.test(path)), [],
`${namespace}/${language}: статический импорт вернул бы словарь в граф редактора ×4`);
}
// Ни одного JSON, кроме английского, в модуле пространства вообще.
assert.deepEqual(staticImports.filter((path) => path.endsWith('.json') && path !== `./${namespace}/en.json`), []);
}
});
test('#627 AC3 у каждого из девяти чанков — загрузчик с двумя попытками и свой retry-токен', () => {
assert.equal(NAMESPACE_LOCALE_CHUNKS.length, NAMESPACES.length * LAZY_LANGUAGES.length);
for (const namespace of NAMESPACES) {
const text = source(`${namespace}.ts`);
for (const language of LAZY_LANGUAGES) {
const entry = NAMESPACE_LOCALE_CHUNKS.find((candidate) => candidate.namespace === namespace
&& candidate.language === language);
assert.ok(entry, `${namespace}-${language}: запись манифеста`);
assert.equal(entry.module, `/src/i18n/${namespace}/${namespace}-${language}.ts`);
assert.match(text, new RegExp(
`${language}: \\(attempt\\) => \\(attempt === 0 \\? import\\('${escape(`./${namespace}/${namespace}-${language}`)}'\\)\\n`
+ `\\s+: import\\(/\\* @vite-ignore \\*/ retryUrl\\('${entry.token}'\\)\\)\\)`,
), `${namespace}-${language}: первая попытка — ребро rollup, вторая — точный hashed-адрес`);
assert.equal(text.split(entry.token).length - 1, 1, `${entry.token}: ровно одно вхождение`);
// Чанк несёт свой язык и отпечаток сборки.
const loader = new URL(`../src/i18n/${namespace}/${namespace}-${language}.ts`, import.meta.url);
assert.ok(existsSync(loader), `${namespace}-${language}.ts существует`);
const loaderText = readFileSync(loader, 'utf8');
assert.match(loaderText, new RegExp(`^import dictionary from '\\./${language}\\.json' with \\{ type: 'json' \\};`, 'm'));
assert.match(loaderText, /export const fingerprint = '__HOUSEPLAN_SOURCE_FINGERPRINT__';/);
}
}
});
test('#627 рантайм пространства: en готов сразу, ru/de/fr — после ensure и ровно своим словарём', async () => {
const modules = {
settings: await import('../test-build/i18n/settings.js'),
support: await import('../test-build/i18n/support.js'),
topology: await import('../test-build/i18n/topology.js'),
};
const runtimes = {
settings: modules.settings.SETTINGS_LANGUAGE_RUNTIME,
support: modules.support.SUPPORT_LANGUAGE_RUNTIME,
topology: modules.topology.TOPOLOGY_LANGUAGE_RUNTIME,
};
const translate = {
settings: modules.settings.settingsT,
support: modules.support.supportT,
topology: modules.topology.topologyT,
};
for (const namespace of NAMESPACES) {
const runtime = runtimes[namespace];
const english = json(`${namespace}/en.json`);
assert.equal(runtime.state('en'), 'ready', `${namespace}: английский статический`);
for (const language of LAZY_LANGUAGES) {
const expected = json(`${namespace}/${language}.json`);
const key = Object.keys(expected).find((candidate) => expected[candidate] !== english[candidate]
&& !/\{/.test(expected[candidate]));
assert.ok(key, `${namespace}/${language}: есть переведённый ключ без плейсхолдеров`);
assert.equal(runtime.state(language), 'pending', `${namespace}/${language}: не загружен до ensure`);
assert.equal(translate[namespace](language, key), english[key],
`${namespace}/${language}: до загрузки — синхронный английский откат, не сырой ключ`);
await runtime.ensure(language);
assert.equal(runtime.state(language), 'ready');
assert.deepEqual(runtime.dictionary(language), expected,
`${namespace}/${language}: загружен словарь своего языка`);
assert.equal(translate[namespace](language, key), expected[key]);
}
}
});
test('#627 рантайм пространства — тот же проверенный класс и тот же слушатель отказа', async () => {
const { LanguageRuntime } = await import('../test-build/i18n/language-runtime.js');
const namespaceSource = source('namespace-language.ts');
assert.match(namespaceSource,
/\], BUILD_FINGERPRINT, console\.warn, notifyLanguageLoadFailures\);/,
'отказ словаря пространства идёт тем же тостом, что и основного каталога');
for (const [path, name] of [
['settings.js', 'SETTINGS_LANGUAGE_RUNTIME'], ['support.js', 'SUPPORT_LANGUAGE_RUNTIME'],
['topology.js', 'TOPOLOGY_LANGUAGE_RUNTIME'],
]) {
const module = await import(`../test-build/i18n/${path}`);
assert.ok(module[name] instanceof LanguageRuntime, `${name}: LanguageRuntime, а не двойник`);
}
});
+60
View File
@@ -189,3 +189,63 @@ test('language load failure subscription delivers codes and unsubscribes (#354)'
notifyLanguageLoadFailures('de');
assert.deepEqual(seen, ['de', '2:de', '2:de'], 'no listeners — no deliveries');
});
test('#627 AC5 составной гейт ждёт словарь пространства и не роняет lang в en при его отказе', async () => {
const { composeLanguageRuntimes } = await import('../test-build/i18n/namespace-language.js');
const primary = new LanguageRuntime([
{ code: 'en', dictionary: {} }, { code: 'ru', dictionary: {} },
], 'build');
const namespaceLoad = deferred();
const loads = [];
const namespace = new LanguageRuntime([
{ code: 'en', dictionary: { title: 'Settings' } },
{ code: 'ru', loadDictionary: async (attempt) => { loads.push(attempt); return namespaceLoad.promise; } },
], 'build', () => {});
const host = new FakeHost();
const composite = composeLanguageRuntimes(primary, [namespace]);
assert.equal(languageRenderGate(host, composite, 'en'), 'ready', 'en of the namespace is static');
assert.deepEqual(loads, [], 'English never requests a namespace chunk');
// Live switch en → ru: the main catalog is ready, the namespace is not.
assert.equal(composite.state('ru'), 'pending');
assert.equal(languageRenderGate(host, composite, 'ru'), 'warm');
assert.equal(host.inert, true);
assert.equal(host.attrs.get('aria-busy'), 'true');
assert.equal(host.attrs.get('lang'), 'en', 'the previous frame keeps its language while pending');
namespaceLoad.resolve({ dictionary: { title: 'Настройки' }, fingerprint: 'build' });
await composite.ensure('ru');
await Promise.resolve();
assert.equal(host.updates, 1, 'the host re-renders once the namespace settled');
assert.equal(languageRenderGate(host, composite, 'ru'), 'ready');
assert.equal(host.inert, false);
assert.equal(host.attrs.get('lang'), 'ru');
assert.equal(namespace.dictionary('ru').title, 'Настройки');
assert.deepEqual(loads, [0], 'one request per page, deduplicated');
// A failed namespace settles as fallback: the host unblocks, and its `lang`
// stays the main catalog language — only the namespace strings are English.
const failing = new LanguageRuntime([
{ code: 'en', dictionary: {} },
{ code: 'ru', loadDictionary: async () => { throw new Error('offline'); } },
], 'build', () => {});
const failedHost = new FakeHost();
const failedComposite = composeLanguageRuntimes(primary, [failing]);
assert.equal(languageRenderGate(failedHost, failedComposite, 'ru'), 'cold');
await failedComposite.ensure('ru');
assert.equal(failing.state('ru'), 'fallback');
assert.equal(failedComposite.state('ru'), 'ready');
assert.equal(languageRenderGate(failedHost, failedComposite, 'ru'), 'ready');
assert.equal(failedHost.attrs.get('lang'), 'ru');
assert.equal(failedHost.hasAttribute('aria-busy'), false);
// The main catalog still decides the fallback `lang` on its own failure.
const mainFailing = new LanguageRuntime([
{ code: 'en', dictionary: {} },
{ code: 'de', loadDictionary: async () => { throw new Error('offline'); } },
], 'build', () => {});
const settledNamespace = new LanguageRuntime([
{ code: 'en', dictionary: {} }, { code: 'de', dictionary: {} },
], 'build');
const mainComposite = composeLanguageRuntimes(mainFailing, [settledNamespace]);
await mainComposite.ensure('de');
assert.equal(mainComposite.state('de'), 'fallback');
});
+8 -1
View File
@@ -500,7 +500,14 @@ test('словари topology несут один и тот же набор кл
});
test('кружок справки не рисуется без подписи для скринридера (#459 AC2)', async () => {
const { hasTopologyTranslation } = await import('../test-build/i18n/topology.js');
const { hasTopologyTranslation, topologyT, TOPOLOGY_LANGUAGE_RUNTIME } =
await import('../test-build/i18n/topology.js');
// #627: ru/de are lazy chunks. Without `ensure` this would prove the English
// fallback layer, not the Russian dictionary.
await TOPOLOGY_LANGUAGE_RUNTIME.ensure('ru');
await TOPOLOGY_LANGUAGE_RUNTIME.ensure('de');
assert.equal(TOPOLOGY_LANGUAGE_RUNTIME.state('ru'), 'ready');
assert.notEqual(topologyT('ru', 'help_aria'), topologyT('en', 'help_aria'));
// Проверка идёт по СЛОВАРЮ, а не по строке: topologyT на отсутствующий ключ
// отвечает именем ключа, и «help» — вполне непустая строка.
assert.equal(hasTopologyTranslation('ru', 'help'), true);
+1 -1
View File
@@ -10,7 +10,7 @@
"tsBuildInfoFile": "test-build/.tsbuildinfo"
},
"include": [
"src/color.ts", "src/styles.ts", "src/styles/*.styles.ts", "src/logic.ts", "src/i18n.ts", "src/i18n/registry.ts", "src/i18n/language-runtime.ts", "src/glow-blend.ts", "src/grid-scale.ts", "src/device-visual.ts", "src/device-pulse.ts", "src/device-presentation.ts", "src/device-marker-geometry.ts",
"src/color.ts", "src/styles.ts", "src/styles/*.styles.ts", "src/logic.ts", "src/i18n.ts", "src/i18n/registry.ts", "src/i18n/language-runtime.ts", "src/i18n/namespace-language.ts", "src/i18n/settings.ts", "src/i18n/support.ts", "src/i18n/topology.ts", "src/glow-blend.ts", "src/grid-scale.ts", "src/device-visual.ts", "src/device-pulse.ts", "src/device-presentation.ts", "src/device-marker-geometry.ts",
"src/device-face.ts", "src/device-hit-owner.ts", "src/device-toggle.ts", "src/marker-toggle-entity.ts", "src/activity-runtime.ts", "src/summary-runtime-loader.ts", "src/card-version.ts", "src/device-area-relocation.ts",
"src/ha-binding-status.ts",
"src/zigbee-topology-settings.ts", "src/zigbee-topology.ts", "src/zigbee-topology-geometry.ts", "src/zigbee-topology-runtime.ts",