mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
- ru/de/fr трёх словарей пространств — девять ленивых чанков (по одному на пару «пространство × язык»), английский статически как слой отката; рантайм — тот же LanguageRuntime (две попытки, отпечаток сборки, fallback, общий тост отказа), src/i18n/namespace-language.ts. - Загрузчики рантаймов онбординга и редактора отдают поверхность в рендер только после того, как словарь текущего языка осел; гейт хоста учитывает словари загруженной на нём поверхности (смена языка на лету — ветка warm). Оверлей Zigbee ждёт topology до первой отрисовки. - bundle-manifest: граф lazyNamespaceLocaleFiles, девять retry-токенов со строгим счётом «ровно один». bundle-budget: потолок онбординга 28 500 (замер 27 470), редактор 245 400 → 228 900 (замер 227 869), проверки ленивости и владения по содержимому. - Смок smoke_lazy_admin_locale (AC4–AC7); регресс-смоки, переключавшие язык синхронно, ждут гейт вместо ослабления проверок; пять мутантов. - monolith-baseline bundleBytes 2 500 387 → 2 508 676: +10 чанков (rollup intro и export на каждый) и записи манифеста; gzip каждой поверхности при этом уменьшился. Issue: #627 User-Visible: no
359 lines
18 KiB
JavaScript
359 lines
18 KiB
JavaScript
// #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);
|