diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 135b0e95..6b46eb76 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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//-.ts`, one `import()` with a +content-hashed retry token in `src/i18n/.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). diff --git a/demo/smoke_color_picker.mjs b/demo/smoke_color_picker.mjs index 2000968e..4784a5cb 100644 --- a/demo/smoke_color_picker.mjs +++ b/demo/smoke_color_picker.mjs @@ -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' diff --git a/demo/smoke_dialog_segments_i18n.mjs b/demo/smoke_dialog_segments_i18n.mjs index b5fb7904..d5aca84d 100644 --- a/demo/smoke_dialog_segments_i18n.mjs +++ b/demo/smoke_dialog_segments_i18n.mjs @@ -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); diff --git a/demo/smoke_free_walls.mjs b/demo/smoke_free_walls.mjs index 1376fcf5..224f210c 100644 --- a/demo/smoke_free_walls.mjs +++ b/demo/smoke_free_walls.mjs @@ -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) => { diff --git a/demo/smoke_help_affordance.mjs b/demo/smoke_help_affordance.mjs index be4314c3..01814e46 100644 --- a/demo/smoke_help_affordance.mjs +++ b/demo/smoke_help_affordance.mjs @@ -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 || ''); diff --git a/demo/smoke_lazy_admin_locale.mjs b/demo/smoke_lazy_admin_locale.mjs new file mode 100644 index 00000000..6877a324 --- /dev/null +++ b/demo/smoke_lazy_admin_locale.mjs @@ -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); diff --git a/demo/smoke_optimize_geometry_preflight.mjs b/demo/smoke_optimize_geometry_preflight.mjs index cc08b6e3..ba9f4c0b 100644 --- a/demo/smoke_optimize_geometry_preflight.mjs +++ b/demo/smoke_optimize_geometry_preflight.mjs @@ -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( diff --git a/demo/smoke_space_scale_defaults.mjs b/demo/smoke_space_scale_defaults.mjs index 5304b42c..8321be41 100644 --- a/demo/smoke_space_scale_defaults.mjs +++ b/demo/smoke_space_scale_defaults.mjs @@ -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`). diff --git a/demo/smoke_summary_panel.mjs b/demo/smoke_summary_panel.mjs index 8fbaa2ec..2e8e4236 100644 --- a/demo/smoke_summary_panel.mjs +++ b/demo/smoke_summary_panel.mjs @@ -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; diff --git a/demo/smoke_toggle_confirmation.mjs b/demo/smoke_toggle_confirmation.mjs index 9c6756ef..8b4d805a 100644 --- a/demo/smoke_toggle_confirmation.mjs +++ b/demo/smoke_toggle_confirmation.mjs @@ -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 = { diff --git a/demo/smoke_ux_fixes.mjs b/demo/smoke_ux_fixes.mjs index 8b61cfe5..1b1c0928 100644 --- a/demo/smoke_ux_fixes.mjs +++ b/demo/smoke_ux_fixes.mjs @@ -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 } })) }; diff --git a/scripts/bundle-budget.mjs b/scripts/bundle-budget.mjs index e11ebf05..03e694e3 100644 --- a/scripts/bundle-budget.mjs +++ b/scripts/bundle-budget.mjs @@ -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`, diff --git a/scripts/bundle-manifest.mjs b/scripts/bundle-manifest.mjs index 7d1876e3..588fde95 100644 --- a/scripts/bundle-manifest.mjs +++ b/scripts/bundle-manifest.mjs @@ -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'); + } }, }; } diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 9f15caa5..aeef2090 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -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', diff --git a/scripts/smoke-links.mjs b/scripts/smoke-links.mjs index c8b4cef3..ed73a575 100644 --- a/scripts/smoke-links.mjs +++ b/scripts/smoke-links.mjs @@ -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', diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 77e8988e..ced641be 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -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) => { diff --git a/src/houseplan-editor-runtime.ts b/src/houseplan-editor-runtime.ts index 35ff6bce..774e95cd 100644 --- a/src/houseplan-editor-runtime.ts +++ b/src/houseplan-editor-runtime.ts @@ -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(); diff --git a/src/houseplan-onboarding-runtime.ts b/src/houseplan-onboarding-runtime.ts index e66441ca..7d8dd1a1 100644 --- a/src/houseplan-onboarding-runtime.ts +++ b/src/houseplan-onboarding-runtime.ts @@ -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): TemplateResult | typeof nothing { diff --git a/src/hp-zigbee-topology-overlay.ts b/src/hp-zigbee-topology-overlay.ts index 503960a5..a22b2efb 100644 --- a/src/hp-zigbee-topology-overlay.ts +++ b/src/hp-zigbee-topology-overlay.ts @@ -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; diff --git a/src/i18n/editor-language.ts b/src/i18n/editor-language.ts new file mode 100644 index 00000000..0ba4b5d3 --- /dev/null +++ b/src/i18n/editor-language.ts @@ -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, +]); diff --git a/src/i18n/namespace-language.ts b/src/i18n/namespace-language.ts new file mode 100644 index 00000000..d06f4030 --- /dev/null +++ b/src/i18n/namespace-language.ts @@ -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; + +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); +} diff --git a/src/i18n/settings.ts b/src/i18n/settings.ts index 9ce793eb..75b24e18 100644 --- a/src/i18n/settings.ts +++ b/src/i18n/settings.ts @@ -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> = { 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 { - return subst(DICTIONARIES[lang]?.[key] ?? en[key] ?? key, vars); + return subst(entryOf(lang, key) ?? key, vars); } diff --git a/src/i18n/settings/settings-de.ts b/src/i18n/settings/settings-de.ts new file mode 100644 index 00000000..652bf554 --- /dev/null +++ b/src/i18n/settings/settings-de.ts @@ -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 }; diff --git a/src/i18n/settings/settings-fr.ts b/src/i18n/settings/settings-fr.ts new file mode 100644 index 00000000..a3b4eaca --- /dev/null +++ b/src/i18n/settings/settings-fr.ts @@ -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 }; diff --git a/src/i18n/settings/settings-ru.ts b/src/i18n/settings/settings-ru.ts new file mode 100644 index 00000000..1be9d7eb --- /dev/null +++ b/src/i18n/settings/settings-ru.ts @@ -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 }; diff --git a/src/i18n/support.ts b/src/i18n/support.ts index a428aa4d..028e0bab 100644 --- a/src/i18n/support.ts +++ b/src/i18n/support.ts @@ -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> = { - 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 { - return subst(SUPPORT_DICTIONARIES[lang]?.[key] ?? en[key] ?? key, vars); + return subst(SUPPORT_LANGUAGE_RUNTIME.dictionary(lang)?.[key] ?? en[key] ?? key, vars); } diff --git a/src/i18n/support/support-de.ts b/src/i18n/support/support-de.ts new file mode 100644 index 00000000..f0f19bef --- /dev/null +++ b/src/i18n/support/support-de.ts @@ -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 }; diff --git a/src/i18n/support/support-fr.ts b/src/i18n/support/support-fr.ts new file mode 100644 index 00000000..9c02a47b --- /dev/null +++ b/src/i18n/support/support-fr.ts @@ -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 }; diff --git a/src/i18n/support/support-ru.ts b/src/i18n/support/support-ru.ts new file mode 100644 index 00000000..20b49a8e --- /dev/null +++ b/src/i18n/support/support-ru.ts @@ -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 }; diff --git a/src/i18n/topology.ts b/src/i18n/topology.ts index e47d2d41..a03472c3 100644 --- a/src/i18n/topology.ts +++ b/src/i18n/topology.ts @@ -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> = { 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> = { 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 { - return subst(DICTIONARIES[lang]?.[key] ?? en[key] ?? key, vars); + return subst(TOPOLOGY_LANGUAGE_RUNTIME.dictionary(lang)?.[key] ?? en[key] ?? key, vars); } diff --git a/src/i18n/topology/topology-de.ts b/src/i18n/topology/topology-de.ts new file mode 100644 index 00000000..9cde1350 --- /dev/null +++ b/src/i18n/topology/topology-de.ts @@ -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 }; diff --git a/src/i18n/topology/topology-fr.ts b/src/i18n/topology/topology-fr.ts new file mode 100644 index 00000000..4da7a553 --- /dev/null +++ b/src/i18n/topology/topology-fr.ts @@ -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 }; diff --git a/src/i18n/topology/topology-ru.ts b/src/i18n/topology/topology-ru.ts new file mode 100644 index 00000000..f72a3c2b --- /dev/null +++ b/src/i18n/topology/topology-ru.ts @@ -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 }; diff --git a/test/bundle-assets.test.mjs b/test/bundle-assets.test.mjs index 8066b859..d5455860 100644 --- a/test/bundle-assets.test.mjs +++ b/test/bundle-assets.test.mjs @@ -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); +}); diff --git a/test/i18n-lazy-namespaces.test.mjs b/test/i18n-lazy-namespaces.test.mjs new file mode 100644 index 00000000..de3db0de --- /dev/null +++ b/test/i18n-lazy-namespaces.test.mjs @@ -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, а не двойник`); + } +}); diff --git a/test/i18n-runtime.test.mjs b/test/i18n-runtime.test.mjs index 1c9847a3..62a0961e 100644 --- a/test/i18n-runtime.test.mjs +++ b/test/i18n-runtime.test.mjs @@ -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'); +}); diff --git a/test/zigbee-topology.test.mjs b/test/zigbee-topology.test.mjs index 10aedadd..fb37c83f 100644 --- a/test/zigbee-topology.test.mjs +++ b/test/zigbee-topology.test.mjs @@ -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); diff --git a/tsconfig.test.json b/tsconfig.test.json index a993d2cb..b2a09593 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -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",