mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +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
213 lines
9.7 KiB
JavaScript
213 lines
9.7 KiB
JavaScript
// 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));
|