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