Files
houseplan-card/demo/smoke_free_walls.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

213 lines
9.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Browser contract for active wall chains, independent partitions and columns.
// This deliberately exercises the card's editor transactions rather than
// injecting finished records: create, drag, edit and Delete must all pass
// through the same paths a pointer/keyboard gesture uses.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1000, height: 820 }, 1);
const out = await page.evaluate(async () => {
const o = {};
const c = window.__card;
const PITCH = 1000 / 240;
// The product owns dangerous confirmations through hp-confirm now. This
// smoke covers geometry transactions, while smoke_danger_confirmation
// exercises the real dialog; keep the decision deterministic here.
// Exercise hp-dialog's HA branch without depending on the Home Assistant
// frontend in the standalone demo. The fixed one-line fallback below
// reproduces ha-dialog-header's public custom-property contract: Houseplan
// must explicitly opt into content-sized localized titles.
if (!customElements.get('ha-dialog')) {
customElements.define('ha-dialog', class extends HTMLElement {
constructor() {
super();
const root = this.attachShadow({ mode: 'open' });
root.innerHTML = `<style>
:host { display:block; width:300px; }
.surface { width:300px; box-sizing:border-box; overflow:hidden; }
.mock-header { padding:8px; overflow:hidden; }
.mock-title {
height:var(--ha-dialog-header-title-height, 35px);
overflow:hidden;
font-size:28px;
line-height:1.25;
}
.mock-footer { display:flex; width:100%; box-sizing:border-box; }
</style>
<div class="surface">
<div class="mock-header"><div class="mock-title"><slot name="headerTitle"></slot></div></div>
<slot></slot>
<div class="mock-footer"><slot name="footer"></slot></div>
</div>`;
}
});
}
c.hass = { ...c.hass, locale: { ...(c.hass.locale || {}), language: 'ru' } };
c._serverCfg = {
model_version: 10,
spaces: [{ id: 'physical', title: 'Physical', cell_cm: 5,
view_box: [0, 0, 1, 1], rooms: [], wall_segments: [] }],
markers: [], settings: {},
};
c._space = 'physical';
c._mode = 'plan';
c._modelCache = null;
// Keep the smoke local: editor transactions still mutate the real config,
// invalidate derived models and history, but do not need a websocket write.
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) => {
const rect = stage.getBoundingClientRect();
const view = c._viewOr(c._baseVb());
return new MouseEvent('click', {
clientX: rect.left + ((x - view.x) / view.w) * rect.width,
clientY: rect.top + ((y - view.y) / view.h) * rect.height,
bubbles: true,
});
};
const drawAndFinish = (a, b, cm) => {
c._activateMarkupTool('draw');
c._drawWallField = String(cm);
c._markupClick(clickAt(...a));
c._markupClick(clickAt(...b));
c._activateMarkupTool('select');
};
drawAndFinish([100, 100], [300, 100], 15);
let sp = c._serverCfg.spaces[0];
o.partitionCreated = sp.partitions?.length === 1 && sp.partitions[0].cm === 15;
c._tool = 'column';
c._drawWallField = '30';
c._columnClick([200, 200]);
sp = c._serverCfg.spaces[0];
o.columnCreatedSquare = sp.wall_columns?.length === 1
&& sp.wall_columns[0].shape === 'square' && sp.wall_columns[0].cm === 30;
// Незавершённая цепочка сохраняется сразу обычной partition, вместе с
// толщиной отрезка.
// Путь набирается кликами, а не присваиванием `_path`: с #234 толщина
// пишется в тот же момент, что и точка, и подстановка пути в обход клика
// опирается на снятый там fallback — тест проверял бы поведение, которого
// в продукте больше нет.
c._activateMarkupTool('draw');
c._drawWallField = '12';
c._activeWallChainId = null;
c._activeWallChainPartitionIds = [];
c._markupClick(clickAt(100, 300));
c._markupClick(clickAt(200, 300));
o.wallChainRecordedThickness = c._wallChainSegmentCms.length === c._path.length - 1;
sp = c._serverCfg.spaces[0];
const activeWallId = c._activeWallChainPartitionIds[0];
o.wallPersistedImmediately = !!activeWallId
&& sp.partitions?.some((item) => item.id === activeWallId && item.cm === 12);
const part = c._spaceModel().partitions[0];
c._physicalDrag = {
pid: 71, kind: 'partition', id: part.id, start: [...part.a],
startClient: [0, 0], before: c._geometrySnapshot(), moved: true,
base: JSON.parse(JSON.stringify(part)), delta: [PITCH, PITCH],
};
c._physicalUp(new PointerEvent('pointerup', { pointerId: 71, bubbles: true }));
sp = c._serverCfg.spaces[0];
o.partitionDragIsRigidAndGridBound = Math.abs(sp.partitions[0].a[0] - (0.1 + PITCH / 1000)) < 1e-9
&& Math.abs((sp.partitions[0].b[0] - sp.partitions[0].a[0]) - 0.2) < 1e-9
&& Math.abs(sp.partitions[0].a[1] - sp.partitions[0].b[1]) < 1e-9;
const actionLayout = () => {
const root = c.shadowRoot || c.renderRoot;
const dialog = root.querySelector('hp-dialog');
const footer = dialog?.querySelector('.physicalfooter');
const danger = footer?.querySelector('.dialog-action-danger');
const commit = footer?.querySelector('.dialog-action-commit');
const footerRect = footer?.getBoundingClientRect();
const footerStyle = footer ? getComputedStyle(footer) : null;
const innerLeft = footerRect && footerStyle
? footerRect.left + parseFloat(footerStyle.paddingLeft) : 0;
const innerRight = footerRect && footerStyle
? footerRect.right - parseFloat(footerStyle.paddingRight) : 0;
const buttons = [...(footer?.querySelectorAll('button') || [])];
return {
dialog, footer, danger, commit, footerRect, innerLeft, innerRight, buttons,
contained: !!footerRect && buttons.every((button) => {
const rect = button.getBoundingClientRect();
return rect.left >= innerLeft - 1 && rect.right <= innerRight + 1
&& rect.top >= footerRect.top - 1 && rect.bottom <= footerRect.bottom + 1;
}),
};
};
c._openPhysicalDialog('partition', sp.partitions[0].id);
await c.updateComplete;
await new Promise((resolve) => requestAnimationFrame(resolve));
const partitionLayout = actionLayout();
const haDialog = partitionLayout.dialog?.shadowRoot?.querySelector('ha-dialog');
const title = partitionLayout.dialog?.shadowRoot?.querySelector('.title-text');
const mockTitle = haDialog?.shadowRoot?.querySelector('.mock-title');
const titleRect = title?.getBoundingClientRect();
const mockTitleRect = mockTitle?.getBoundingClientRect();
const dangerRect = partitionLayout.danger?.getBoundingClientRect();
const commitRect = partitionLayout.commit?.getBoundingClientRect();
o.physicalTitleWrapsAndIsContained = !!titleRect && !!mockTitleRect
&& titleRect.height > 35
&& titleRect.top >= mockTitleRect.top - 1
&& titleRect.bottom <= mockTitleRect.bottom + 1;
o.partitionFooterActionsContained = partitionLayout.contained
&& partitionLayout.buttons.length === 3;
o.partitionFooterGroupsWrapCleanly = !!dangerRect && !!commitRect
&& commitRect.top >= dangerRect.bottom - 1
&& Math.abs(commitRect.right - partitionLayout.innerRight) <= 1;
o.partitionFooterTouchTargets = partitionLayout.buttons.every((button) =>
button.getBoundingClientRect().height >= 44);
const partitionId = sp.partitions[0].id;
sp.openings = [{
id: 'hosted-opening', type: 'door', width_cm: 90,
host: { kind: 'partition', id: partitionId, t: 0.5 },
}];
c._physicalSel = { kind: 'partition', id: partitionId };
const hostedDelete = c._deletePhysicalSelection();
o.hostedPartitionDeleteReturnsPromise = typeof hostedDelete?.then === 'function';
await hostedDelete;
o.hostedPartitionWaitsForDecision = !!c._partitionDeleteDialog
&& c._serverCfg.spaces[0].partitions?.some((partition) => partition.id === partitionId);
c._partitionDeleteDialog = null;
delete sp.openings;
c._physicalSel = { kind: 'partition', id: partitionId };
const partitionDelete = c._deletePhysicalSelection();
o.partitionDeleteReturnsPromise = typeof partitionDelete?.then === 'function';
await partitionDelete;
o.deleteRemovesOnlySelectedPartition = !c._serverCfg.spaces[0].partitions
?.some((partition) => partition.id === partitionId)
&& c._serverCfg.spaces[0].partitions?.some((partition) => partition.id === activeWallId);
const columnId = c._serverCfg.spaces[0].wall_columns[0].id;
c._physicalSel = { kind: 'column', id: columnId };
const columnDelete = c._deletePhysicalSelection();
o.columnDeleteReturnsPromise = typeof columnDelete?.then === 'function';
await columnDelete;
o.deleteRemovesColumn = !c._serverCfg.spaces[0].wall_columns;
c._physicalSel = null;
const emptyDelete = c._deletePhysicalSelection();
o.emptyDeleteReturnsPromise = typeof emptyDelete?.then === 'function';
await emptyDelete;
const beforeInvalid = c._serverCfg.spaces[0].partitions?.length || 0;
drawAndFinish([400, 100], [500, 100], 120);
o.invalidThicknessCreatesNothing = (c._serverCfg.spaces[0].partitions?.length || 0)
=== beforeInvalid;
return o;
});
await finish(browser, checkAll(out));