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

Issue: #627
User-Visible: no
2026-09-24 14:51:18 +03:00

157 lines
8.2 KiB
JavaScript

import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 640, height: 980 }, 2);
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
c.hass = {
...c.hass,
locale: { language: 'ru' },
states: {
...c.hass.states,
'sensor.living_hum': {
entity_id: 'sensor.living_hum',
state: '47.6',
attributes: {
friendly_name: 'Living humidity',
device_class: 'humidity',
unit_of_measurement: '%',
},
},
},
entities: {
...c.hass.entities,
'sensor.living_hum': {
entity_id: 'sensor.living_hum',
device_id: 'd_temp',
platform: 'demo',
disabled_by: null,
},
},
};
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 } })) };
c._regSignature=''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
out.filledClass = sr().querySelectorAll('.room.styled.filled').length; // только living (термометр)
out.unfilled = sr().querySelectorAll('.room.styled:not(.filled)').length;
// тултип комнаты: площадь и агрегаты температуры/влажности в стабильном порядке
const rooms = [...sr().querySelectorAll('.room')];
const room = rooms[0];
room.dispatchEvent(new PointerEvent('pointermove', {
pointerType: 'mouse', bubbles: true, composed: true, clientX: 200, clientY: 200,
}));
await c.updateComplete;
out.tipTemp = c._tip?.temp;
out.tipHum = c._tip?.hum;
out.tipHasTempLine = (sr().querySelector('.tip')?.textContent || '').includes('средняя температура');
out.tipHasHumLine = (sr().querySelector('.tip')?.textContent || '').includes('средняя влажность: 48%');
const metricLines = [...sr().querySelectorAll('.tip .m')].map((line) => line.textContent.trim());
const tempLine = metricLines.findIndex((line) => line.includes('средняя температура'));
const humLine = metricLines.findIndex((line) => line.includes('средняя влажность'));
const lqiLine = metricLines.findIndex((line) => line.includes(c._t('tip.lqi')));
out.tipMetricOrder = tempLine >= 0 && humLine === tempLine + 1 && lqiLine === humLine + 1;
out.tipPositionUnchanged = sr().querySelector('.tip')?.style.left === '212px'
&& sr().querySelector('.tip')?.style.top === '212px';
// _roomArea consumes the rendered room geometry (0..1000), not the stored
// normalized config polygon (0..1).
const expectedArea = c._roomArea(c._spaceModel().rooms[0]);
out.tipHasArea = !!expectedArea && c._tip?.meta === c._t('tip.area', { value: expectedArea });
out.tipHasAreaLine = (sr().querySelector('.tip')?.textContent || '').includes(expectedArea);
// Комната без источника влажности не получает пустую строку.
rooms[1].dispatchEvent(new PointerEvent('pointermove', {
pointerType: 'mouse', bubbles: true, composed: true, clientX: 210, clientY: 210,
}));
await c.updateComplete;
out.noHumidityOmitted = c._tip?.hum == null
&& !(sr().querySelector('.tip')?.textContent || '').includes('средняя влажность');
// Общий device tooltip остаётся без room humidity.
sr().querySelector('.dev').dispatchEvent(new PointerEvent('pointermove', {
pointerType: 'mouse', bubbles: true, composed: true, clientX: 220, clientY: 220,
}));
await c.updateComplete;
out.deviceTooltipHasNoHumidity = c._tip?.hum == null
&& !(sr().querySelector('.tip')?.textContent || '').includes('средняя влажность');
// Touch/pen input never creates hover-only tooltips.
room.dispatchEvent(new PointerEvent('pointerdown', {
pointerType: 'touch', pointerId: 91, bubbles: true, composed: true,
clientX: 230, clientY: 230,
}));
c._tip = null;
room.dispatchEvent(new PointerEvent('pointermove', {
pointerType: 'touch', pointerId: 91, bubbles: true, composed: true,
clientX: 230, clientY: 230,
}));
await c.updateComplete;
out.noHoverSuppressesTooltip = c._tip === null;
room.dispatchEvent(new PointerEvent('pointerup', {
pointerType: 'touch', pointerId: 91, bubbles: true, composed: true,
clientX: 230, clientY: 230,
}));
// диалог: радио заливки, компактные поля, ширина
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
// #600 §4.2: заливка — сегмент (радиогруппа `space-fill-mode`), свечение —
// полная строка-тумблер, границы температуры — поля с единицей внутри рамки.
out.fillRadios = sr().querySelectorAll('input[name="space-fill-mode"]').length;
out.fillLabels = [...sr().querySelectorAll('input[name="space-fill-mode"]')]
.map((input) => input.closest('label')?.querySelector('span:last-of-type')?.textContent?.trim());
out.fillHasNone = out.fillLabels.includes(c._t('fill.none'));
out.glowToggle = [...sr().querySelectorAll('hp-dialog .hpf-toggle')].some((label) =>
label.querySelector('.hpf-toggle-title')?.textContent.trim() === c._t('space.glow_enabled')
&& !!label.querySelector('input[type="checkbox"]'));
out.tempInputs = sr().querySelectorAll('#space-temp-min, #space-temp-max').length;
const hpDialog = sr().querySelector('hp-dialog');
out.dialogWide = !!hpDialog?.hasAttribute('wide') && !!hpDialog.querySelector('.hpf-toggle');
out.dialogWidth = Math.round(hpDialog.shadowRoot.querySelector('.surface').getBoundingClientRect().width);
// NaN-защита: пустой ввод не ломает границы
const before = c._spaceDialog.tempMax;
const fakeEv = { target: { value: '' } };
// симулируем input с пустым значением через обработчик радио-строки — парсер должен сохранить старое
const n = parseFloat('');
out.nanGuard = !Number.isFinite(n) && c._spaceDialog.tempMax === before;
// Space-level `none` remains readable, but the current dialog offers the
// ordinary selectable custom colour in its place.
c._spaceDialog = null;
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({
...s, settings: { ...(s.settings || {}), fill_mode: 'none', glow_enabled: true },
})) };
c._openSpaceDialog('edit', 'f1');
await c.updateComplete;
out.legacyNoneProjection = c._spaceDialog.fillMode;
out.legacyNoneAlpha = c._spaceDialog.customFill?.a;
out.legacyNoneGlowBases = sr().querySelectorAll('.glow-base-layer .glow-base').length;
return out;
});
// артефакт для глазами: путь берём у ОС, а не хардкодим unix-овый — на Windows
// '/tmp/...' указывает в несуществующий C:\tmp и смоук падал, не дойдя до
// ассертов (портируемость, ревью 2026-07-27)
await page.screenshot({ path: join(tmpdir(), 'houseplan_ux_dialog.png') }).catch(() => {});
// Geometry values were re-baselined for the shared border-box hp-dialog in
// v1.59.2; the usable wide-dialog width remained 500 px until #600 (Q7):
// the settings dialogs carry `form-shell` and the reference width of 560.
checkAll(res, {
"filledClass": 1,
"unfilled": 3,
"tipTemp": 22.4,
"tipHum": 48,
"fillRadios": 4,
"fillLabels": ["Свой цвет", "Zigbee", "Свет", "Температура"],
"fillHasNone": false,
"legacyNoneProjection": "custom",
"legacyNoneAlpha": 0,
"legacyNoneGlowBases": 4,
"tempInputs": 2,
"dialogWidth": 560,
});
await finish(browser, res);