Files
houseplan-card/demo/smoke_styling_hooks.mjs
2026-09-08 21:26:29 +03:00

276 lines
16 KiB
JavaScript

// Хуки для card-mod (docs/STYLING-HOOKS.md): на каждом значимом объекте плана
// есть стабильные data-атрибуты, и по документированным селекторам элементы
// НАХОДЯТСЯ через querySelector — ровно так, как их будет искать чужой CSS.
// 1) устройство: data-hp="device" + data-id + data-entity + data-area;
// 2) комната: data-hp="room" + data-id + data-area;
// 3) видимая подпись комнаты: data-hp="room-label" + data-id на HTML-карточке;
// 4) проём: data-hp="opening" + data-id + data-kind (door/window/gate);
// 5) декор: data-hp="decor" + data-id + data-kind (line/rect/ellipse/text);
// 6) вкладка пространства: data-hp="space-tab" + data-id;
// 7) «нет значения — нет атрибута»: у виртуального маркера нет data-entity,
// у комнаты без area нет data-area (никаких "undefined" в DOM);
// 8) те же хуки в статичной карточке houseplan-space-card.
// ПАДАЕТ на сборке до этих правок: data-hp нет вообще, а data-id есть только
// у текстовой надписи декора.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const q = (sel) => sr().querySelector(sel);
const qa = (sel) => [...sr().querySelectorAll(sel)];
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ============ 0. public test contract (#489) ============
const rootCard = q('ha-card');
out.rootPublishesReadyView = rootCard?.getAttribute('data-hp-state') === 'ready'
&& rootCard?.getAttribute('data-hp-mode') === 'view';
out.headerActionsHaveStableHooks = ['settings', 'pdf', 'support', 'zoom-in', 'zoom-out', 'zoom-fit']
.every((hook) => !!q(`[data-hp="${hook}"]`));
out.spaceActionsHaveStableHooks = !!q('[data-hp="space-add"]')
&& qa('[data-hp="space-settings"]').length === c._model.length;
q('[data-hp="settings"]')?.click();
await c.updateComplete; await sleep(30); await c.updateComplete;
const settingsDialog = q('hp-dialog[data-hp="dialog"][data-kind="settings"]');
out.dialogHostAndActionsAreHooked = !!settingsDialog
&& !!settingsDialog.querySelector('[data-hp="dialog-confirm"]')
&& !!settingsDialog.querySelector('[data-hp="dialog-cancel"]');
settingsDialog?.querySelector('[data-hp="dialog-cancel"]')?.click();
await c.updateComplete;
c._showToast('contract smoke');
await c.updateComplete;
out.toastHasStableKind = q('[data-hp="toast"][data-kind="message"]')?.textContent === 'contract smoke';
c._toast = null;
await c.updateComplete;
const modeTools = {
plan: ['select', 'draw', 'column', 'merge', 'split', 'resize', 'opening', 'wall-thickness', 'delete-room'],
devices: ['add-device', 'device-inbox', 'icon-rules'],
decor: ['select', 'backdrop', 'line', 'rect', 'ellipse', 'text', 'furniture', 'erase'],
};
for (const [mode, tools] of Object.entries(modeTools)) {
c._setMode(mode, false);
await c.updateComplete; await sleep(30); await c.updateComplete;
const card = q('ha-card');
const toolbar = q(`[data-hp="toolbar"][data-kind="${mode === 'devices' ? 'device' : mode}"]`);
out[`mode_${mode}_isPublished`] = card?.getAttribute('data-hp-mode') === mode;
out[`toolbar_${mode}_isPublished`] = !!toolbar && !!toolbar.querySelector('[data-hp="editor-close"]');
out[`tools_${mode}_arePublished`] = tools.every((tool) =>
!!toolbar?.querySelector(`[data-hp="tool"][data-tool="${tool}"]`));
if (mode === 'plan') {
toolbar?.querySelector('[data-hp="tool"][data-tool="opening"]')?.click();
await c.updateComplete; await sleep(30); await c.updateComplete;
out.planGroupOpensPublishedTray = !!q('[data-hp="tray"][data-kind="opening"]');
}
}
c._setMode('view', false);
await c.updateComplete; await sleep(30); await c.updateComplete;
out.returnToViewIsPublished = q('ha-card')?.getAttribute('data-hp-mode') === 'view';
// ============ 1. устройства ============
const devs = qa('[data-hp="device"]');
out.deviceHookFindsEveryMarker = devs.length > 0 && devs.length === qa('.dev').length;
out.deviceKeepsItsClass = devs.every((e) => e.classList.contains('dev'));
out.deviceHasId = devs.every((e) => !!e.getAttribute('data-id'));
// id — тот же, что у устройства в модели карточки, а не порядковый номер в DOM
const modelIds = new Set(c._devices.filter((d) => d.space === c._space && !d.hidden).map((d) => d.id));
out.deviceIdIsTheConfigId = devs.every((e) => modelIds.has(e.getAttribute('data-id')));
// селектор из документации находит конкретное устройство по СУЩНОСТИ
const lock = q('[data-hp="device"][data-entity="lock.front_door"]');
out.deviceFoundByEntity = !!lock;
out.deviceEntityMatchesPrimary = !!lock
&& c._devices.find((d) => d.id === lock.getAttribute('data-id'))?.primary === 'lock.front_door';
out.deviceHasArea = lock?.getAttribute('data-area') === 'hallway';
// и по ЗОНЕ: все маркеры кухни разом
out.devicesFoundByArea = qa('[data-hp="device"][data-area="kitchen"]').length > 0;
out.noUndefinedAnywhere = !sr().innerHTML.includes('="undefined"');
// ============ 2. комнаты ============
const rooms = qa('[data-hp="room"]');
out.roomHookFindsEveryRoom = rooms.length === c._curSpaceCfg.rooms.length;
out.roomKeepsItsClass = rooms.every((e) => e.classList.contains('room'));
const kitchen = q('[data-hp="room"][data-area="kitchen"]');
out.roomFoundByArea = !!kitchen && kitchen.getAttribute('data-id') === 'r2';
out.roomFoundById = !!q('[data-hp="room"][data-id="r3"]');
out.roomIsAnSvgShape = !!kitchen && ['polygon', 'rect', 'path'].includes(kitchen.tagName.toLowerCase());
// ============ 3. подписи комнат ============
// #203: false означает отсутствие подписи, а не вторую SVG-реализацию.
// При true единственный публичный hook живёт на HTML-карточке.
const disp = c._curSpaceCfg.settings || (c._curSpaceCfg.settings = {});
const wasNames = disp.show_names;
const planUrl = c._curSpaceCfg.plan_url;
const redraw = async () => {
c._cfgEpoch++; c._regSignature = ''; c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
};
delete c._curSpaceCfg.plan_url; disp.show_names = false; await redraw();
out.hiddenRoomLabelsAreAbsent = qa('[data-hp="room-label"]').length === 0
&& qa('text.rlabel').length === 0;
c._curSpaceCfg.plan_url = planUrl; disp.show_names = true; await redraw();
const htmlLabel = q('div.roomlabel[data-hp="room-label"][data-id="r2"]');
out.htmlRoomLabelHooked = !!htmlLabel;
out.htmlRoomLabelHasArea = htmlLabel?.getAttribute('data-area') === 'kitchen';
disp.show_names = wasNames; await redraw();
// ============ 4. проёмы ============
c._curSpaceCfg.openings = [
{ id: 'op_d', type: 'door', x: 0.55, y: 0.3, angle: 90, length: 0.09 },
{ id: 'op_w', type: 'window', x: 0.3, y: 0.14, angle: 0, length: 0.12 },
// bottom wall of r1: the room is above it, so an exterior gate must open
// toward +Y. The two compact leaves therefore rotate +10° / -10°.
{ id: 'op_g', type: 'gate', x: 0.3, y: 0.58, angle: 0, length: 0.3 },
];
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
out.openingHookFindsEveryType = qa('[data-hp="opening"]').length === 3;
const door = q('[data-hp="opening"][data-kind="door"]');
const win = q('[data-hp="opening"][data-kind="window"]');
const gate = q('[data-hp="opening"][data-kind="gate"]');
out.openingKindDoor = door?.getAttribute('data-id') === 'op_d';
out.openingKindWindow = win?.getAttribute('data-id') === 'op_w';
out.openingKindGate = gate?.getAttribute('data-id') === 'op_g';
out.openingKeepsItsClass = !!door && door.classList.contains('opening');
const gateLeaves = [...(gate?.querySelectorAll('.op-leaf') || [])];
out.gateHasTwoLeaves = gateLeaves.length === 2;
out.gateOpensOnlyTenDegreesOutward = gateLeaves[0]?.getAttribute('style')?.includes('rotate(10deg)')
&& gateLeaves[1]?.getAttribute('style')?.includes('rotate(-10deg)')
&& !gate?.querySelector('.op-arc');
// ============ 5. декор ============
c._curSpaceCfg.decor = [
{ id: 'dc_l', kind: 'line', x1: 0.1, y1: 0.9, x2: 0.4, y2: 0.9, color: '#123456', width: 3 },
{ id: 'dc_r', kind: 'rect', x: 0.1, y: 0.2, w: 0.1, h: 0.08, color: '#123456', width: 2 },
{ id: 'dc_e', kind: 'ellipse', x: 0.3, y: 0.2, w: 0.1, h: 0.08, color: '#123456', width: 2 },
{ id: 'dc_t', kind: 'text', x: 0.5, y: 0.92, text: 'Подпись', color: '#123456' },
];
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
out.decorHookFindsEveryShape = qa('[data-hp="decor"]').length === 4;
out.decorKindLine = q('[data-hp="decor"][data-kind="line"]')?.getAttribute('data-id') === 'dc_l';
out.decorKindRect = q('[data-hp="decor"][data-kind="rect"]')?.getAttribute('data-id') === 'dc_r';
out.decorKindEllipse = q('[data-hp="decor"][data-kind="ellipse"]')?.getAttribute('data-id') === 'dc_e';
out.decorKindText = q('[data-hp="decor"][data-kind="text"]')?.getAttribute('data-id') === 'dc_t';
out.decorKeepsItsClasses = qa('[data-hp="decor"]').length === 4
&& qa('[data-hp="decor"]').every((e) => e.classList.contains('dshape'))
&& !!q('[data-hp="decor"][data-kind="text"]')?.classList.contains('dtext');
// старый селектор текстовой надписи (его уже использует smoke_decor_text) цел
out.legacyDtextSelectorStillWorks = !!q('text.dtext[data-id="dc_t"]');
// ============ 6. вкладки пространств ============
const tabs = qa('[data-hp="space-tab"]');
out.spaceTabHookFindsEveryFloor = tabs.length === c._model.length;
out.spaceTabHasId = tabs.map((e) => e.getAttribute('data-id')).join(',') === c._model.map((s) => s.id).join(',');
out.spaceTabKeepsItsClass = tabs.every((e) => e.classList.contains('tab'));
// кнопка «+» имеет собственный контракт и вкладкой пространства не является
out.addButtonIsNotASpaceTab = !!q('.tabadd[data-hp="space-add"]')
&& !q('.tabadd[data-hp="space-tab"]');
// ============ 7. «нет значения — нет атрибута» ============
c._serverCfg = { ...c._serverCfg, markers: [
{ id: 'm_virt', binding: 'virtual', space: c._space, name: 'Вентиль' },
] };
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
const virt = q('[data-hp="device"][data-id="m_virt"]');
out.virtualMarkerRendered = !!virt;
out.virtualHasNoEntityAttr = !!virt && !virt.hasAttribute('data-entity');
out.virtualHasNoUndefinedText = !!virt && virt.getAttribute('data-entity') === null;
// комната без HA-области (подкомната): data-hp есть, data-area нет.
// Она рисуется только когда включены границы — это её обычное условие показа.
c._curSpaceCfg.rooms = [...c._curSpaceCfg.rooms,
{ id: 'r_sub', name: 'Кладовка', area: null, poly: [[0.06, 0.6], [0.2, 0.6], [0.2, 0.7], [0.06, 0.7]] }];
disp.show_borders = true;
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
const sub = q('[data-hp="room"][data-id="r_sub"]');
out.roomWithoutAreaRendered = !!sub;
out.roomWithoutAreaHasNoAreaAttr = !!sub && !sub.hasAttribute('data-area');
out.stillNoUndefinedAnywhere = !sr().innerHTML.includes('="undefined"');
// ============ 8. ограничение shadow DOM (docs/STYLING-HOOKS.md §5) =========
// хост ha-icon доступен селектору, его внутренности — нет: именно поэтому
// хуки висят на НАШИХ обёртках
const anyIcon = q('[data-hp="device"] ha-icon');
out.iconHostReachable = !!anyIcon;
out.iconInternalsAreBehindItsOwnRoot = !!anyIcon && !!anyIcon.shadowRoot
&& !sr().querySelector('[data-hp="device"] ha-icon svg');
return out;
});
checkAll(res, { iconInternalsAreBehindItsOwnRoot: !!res.iconInternalsAreBehindItsOwnRoot });
// ============ 9. writable/read-only empty state ============
const empty = await page.evaluate(async () => {
const make = async (canWrite) => {
const el = document.createElement('houseplan-card');
el.setConfig({ type: 'custom:houseplan-card', title: 'Empty contract fixture' });
document.body.appendChild(el);
const base = window.__mkHass();
const emptyConfig = { ...window.__card._serverCfg, spaces: [] };
el.hass = { ...base, callWS: async (message) => {
if (message.type === 'houseplan/config/get')
return { config: emptyConfig, rev: 1, can_write: canWrite };
if (message.type === 'houseplan/layout/get') return { layout: {}, rev: 1 };
return base.callWS(message);
} };
const started = Date.now();
while ((!el.renderRoot?.querySelector('[data-hp="empty"]')
|| el.renderRoot.querySelector('ha-card')?.getAttribute('data-hp-state') !== 'ready')
&& Date.now() - started < 6000) await new Promise((resolve) => setTimeout(resolve, 40));
const root = el.renderRoot;
const result = {
ready: root?.querySelector('ha-card')?.getAttribute('data-hp-state') === 'ready',
empty: !!root?.querySelector('[data-hp="empty"]'),
create: !!root?.querySelector('[data-hp="create-space"]'),
};
el.remove();
return result;
};
const writable = await make(true);
const readOnly = await make(false);
return {
writableEmptyIsReady: writable.ready && writable.empty,
writableEmptyHasCreateAction: writable.create,
readOnlyEmptyIsReady: readOnly.ready && readOnly.empty,
readOnlyEmptyHasNoCreateAction: !readOnly.create,
};
});
checkAll(empty);
// ============ 10. те же хуки в статичной карточке ============
const stat = await page.evaluate(async () => {
const out = {};
await customElements.whenDefined('houseplan-space-card');
const host = document.createElement('div');
document.body.appendChild(host);
const el = document.createElement('houseplan-space-card');
el.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
// своя копия конфига с включёнными именами: у статичной карточки подпись
// комнаты — это HTML-плашка, и она должна нести тот же хук
const base = window.__card.hass;
const cfg = JSON.parse(JSON.stringify(window.__card._serverCfg));
cfg.spaces[0].settings = { ...(cfg.spaces[0].settings || {}), show_names: true };
el.hass = { ...base, callWS: async (m) => (m.type === 'houseplan/config/get'
? { config: cfg, rev: 9 } : base.callWS(m)) };
host.appendChild(el);
const t0 = Date.now();
while (!el.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) {
await new Promise((r) => setTimeout(r, 80));
}
await el.updateComplete;
const rr = el.renderRoot;
out.staticRoomsHooked = rr.querySelectorAll('[data-hp="room"]').length > 0;
out.staticRoomFoundByArea = !!rr.querySelector('[data-hp="room"][data-area="kitchen"]');
out.staticRoomFoundById = !!rr.querySelector('[data-hp="room"][data-id="r1"]');
out.staticDevicesHooked = rr.querySelectorAll('[data-hp="device"]').length > 0;
out.staticDeviceFoundByEntity = !!rr.querySelector('[data-hp="device"][data-entity="lock.front_door"]');
out.staticNoUndefined = !rr.innerHTML.includes('="undefined"');
out.staticRoomLabelHooked = rr.querySelectorAll('div.roomlabel[data-hp="room-label"]').length > 0;
out.staticRoomLabelFoundByArea = !!rr.querySelector('[data-hp="room-label"][data-area="bedroom"]');
// а вот проёмов и декора она не рисует вовсе (§5) — и это не регрессия
out.staticDrawsNoOpenings = rr.querySelectorAll('[data-hp="opening"]').length === 0;
out.staticDrawsNoDecor = rr.querySelectorAll('[data-hp="decor"]').length === 0;
return out;
});
checkAll(stat);
await finish(browser, { ...res, ...empty, ...stat });