mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
302 lines
16 KiB
JavaScript
302 lines
16 KiB
JavaScript
// #616: компактная шапка на телефоне (≤ 480 px). В одной строке ≤ 56 px —
|
|
// вкладки пространств, зум и одна шестерёнка; всё, что строка убрала, — пункты
|
|
// её меню с теми же действиями, что у прежних кнопок. Тап вне меню поглощается,
|
|
// Escape возвращает фокус на шестерёнку, активная вкладка видна в прокрутке.
|
|
// На > 480 px шапка прежняя, в киоске меню нет. Клики — настоящие (Playwright
|
|
// по координатам), приватные поля карточки только читаются.
|
|
import { launch, check, finish } from './serve.mjs';
|
|
|
|
const { page, browser } = await launch({ width: 390, height: 844 }, 1);
|
|
// Как в golden: карточка во всю ширину окна, а не фиксированные 780 px стенда.
|
|
await page.addStyleTag({ content: '#host{width:min(100%,1120px)!important;margin:0 auto!important;padding:0!important}' });
|
|
const out = {};
|
|
const settle = () => page.evaluate(() => window.__hpTest.settled());
|
|
const size = async (width) => { await page.setViewportSize({ width, height: 844 }); await settle(); };
|
|
|
|
// Снимок шапки: что видно, одна ли строка, высота.
|
|
const header = () => page.evaluate(() => {
|
|
const sr = window.__card.shadowRoot;
|
|
const head = sr.querySelector('.hdr > .head');
|
|
const shown = (el) => !!el && el.getClientRects().length > 0 && getComputedStyle(el).visibility !== 'hidden';
|
|
const kids = [...head.children].filter((el) => shown(el) && el.getBoundingClientRect().width > 0);
|
|
const mids = kids.map((el) => { const r = el.getBoundingClientRect(); return r.top + r.height / 2; });
|
|
const gear = sr.querySelector('[data-hp="header-menu"]');
|
|
const g = gear?.getBoundingClientRect();
|
|
return {
|
|
height: head.getBoundingClientRect().height,
|
|
oneRow: mids.length > 0 && Math.max(...mids) - Math.min(...mids) <= 6,
|
|
title: shown(head.querySelector(':scope > .title')),
|
|
tabs: shown(head.querySelector(':scope > .tabs')),
|
|
zoom: shown(head.querySelector(':scope > .zoomctl')),
|
|
modeSlot: shown(head.querySelector(':scope > .modes'))
|
|
&& Math.abs(head.querySelector(':scope > .modes').getBoundingClientRect().width - 24) <= 0.5,
|
|
cross: shown(head.querySelector('[data-hp="editor-close"]')),
|
|
gear: shown(gear),
|
|
gear44: !!g && g.width >= 44 && g.height >= 44,
|
|
hiddenInline: ['.modes .modetab', '.header-action', '.summary-control', '.tabedit', '.tabadd']
|
|
.filter((selector) => [...head.querySelectorAll(selector)].some(shown)),
|
|
tabEditInDom: !!head.querySelector('.tabedit, .tabadd'),
|
|
inlineVisible: ['.header-action', '.summary-control'].filter((selector) => [...head.querySelectorAll(selector)].some(shown)),
|
|
};
|
|
});
|
|
const menuState = () => page.evaluate(() => {
|
|
const sr = window.__card.shadowRoot;
|
|
const menu = sr.querySelector('#hp-header-menu');
|
|
const gear = sr.querySelector('[data-hp="header-menu"]');
|
|
const items = [...sr.querySelectorAll('[data-hp="header-menu-item"]')];
|
|
return {
|
|
open: !!menu, expanded: gear?.getAttribute('aria-expanded'),
|
|
ids: items.map((el) => el.dataset.id),
|
|
pressed: Object.fromEntries(items.filter((el) => el.hasAttribute('aria-pressed')).map((el) => [el.dataset.id, el.getAttribute('aria-pressed')])),
|
|
current: items.filter((el) => el.getAttribute('aria-current') === 'true').map((el) => el.dataset.id),
|
|
items44: items.every((el) => el.getBoundingClientRect().height >= 44),
|
|
inViewport: !menu || (() => { const r = menu.getBoundingClientRect(); return r.left >= 0 && r.right <= innerWidth + 0.5 && r.bottom <= innerHeight + 0.5; })(),
|
|
};
|
|
});
|
|
const clickAt = async (selector) => {
|
|
const box = await page.evaluate((s) => {
|
|
const el = window.__card.shadowRoot.querySelector(s);
|
|
if (!el) return null;
|
|
el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
const r = el.getBoundingClientRect();
|
|
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
|
}, selector);
|
|
if (!box) throw new Error(`нет ${selector}`);
|
|
await page.mouse.click(box.x, box.y);
|
|
await settle();
|
|
};
|
|
const openMenu = async () => { if (!(await menuState()).open) await clickAt('[data-hp="header-menu"]'); };
|
|
const dialogKinds = () => page.evaluate(() => {
|
|
const kinds = [];
|
|
const walk = (root) => {
|
|
for (const d of root.querySelectorAll('hp-dialog[data-kind]')) kinds.push(d.getAttribute('data-kind'));
|
|
for (const el of root.querySelectorAll('*')) if (el.shadowRoot) walk(el.shadowRoot);
|
|
};
|
|
walk(window.__card.shadowRoot);
|
|
return kinds.sort();
|
|
});
|
|
const closeDialogs = async () => {
|
|
for (let i = 0; i < 4 && (await dialogKinds()).length; i++) {
|
|
await page.evaluate(() => window.__hpTest.close());
|
|
}
|
|
};
|
|
const mode = () => page.evaluate(() => window.__card.shadowRoot.querySelector('ha-card[data-hp-mode]')?.getAttribute('data-hp-mode'));
|
|
|
|
// ---- администратор (стенд) --------------------------------------------------
|
|
const ADMIN_VIEW = ['mode-plan', 'mode-devices', 'mode-decor', 'space-settings', 'space-add', 'settings', 'pdf', 'support', 'summary-settings', 'summary-toggle'];
|
|
for (const width of [390, 320]) {
|
|
await size(width);
|
|
const h = await header();
|
|
out[`admin${width}_oneRow56`] = h.oneRow && h.height <= 56;
|
|
out[`admin${width}_rowContent`] = !h.title && h.tabs && h.modeSlot && h.zoom && h.gear && h.gear44;
|
|
out[`admin${width}_inlineHidden`] = h.hiddenInline.length === 0 ? true : h.hiddenInline.join(',');
|
|
await openMenu();
|
|
const m = await menuState();
|
|
out[`admin${width}_menuItems`] = m.ids.join(',') === ADMIN_VIEW.join(',') ? true : m.ids.join(',');
|
|
out[`admin${width}_menuTargets`] = m.items44 && m.inViewport && m.expanded === 'true';
|
|
await clickAt('[data-hp="header-menu"]');
|
|
out[`admin${width}_gearToggles`] = !(await menuState()).open;
|
|
}
|
|
|
|
// Каждый пункт делает то же, что прежняя кнопка: сначала прежняя кнопка на
|
|
// 1400 px (эталон эффекта), потом пункт меню на 390 px.
|
|
const same = {};
|
|
for (const [id, inline] of [['settings', '[data-hp="settings"]'], ['pdf', '[data-hp="pdf"]'], ['support', '[data-hp="support"]'],
|
|
['space-settings', '[data-hp="space-settings"]'], ['space-add', '[data-hp="space-add"]'], ['summary-settings', '.summary-control > button:first-child']]) {
|
|
await size(1400);
|
|
await clickAt(inline);
|
|
await page.waitForTimeout(250);
|
|
const expected = await dialogKinds();
|
|
await closeDialogs();
|
|
await size(390);
|
|
await openMenu();
|
|
await clickAt(`[data-hp="header-menu-item"][data-id="${id}"]`);
|
|
await page.waitForTimeout(250);
|
|
const actual = await dialogKinds();
|
|
const closed = !(await menuState()).open;
|
|
// Открытое меню перехватывает Escape — диалог тогда не закрыть; это провал пункта, а не смока.
|
|
const closeError = await closeDialogs().then(() => '', (error) => String(error.message || error).slice(0, 80));
|
|
if ((await menuState()).open) await clickAt('[data-hp="header-menu"]');
|
|
if (closeError) await closeDialogs().catch(() => {});
|
|
same[id] = expected.length > 0 && expected.join() === actual.join() && closed && !closeError
|
|
? true : `${expected}|${actual}|closed=${closed}|${closeError}`;
|
|
}
|
|
out.itemsSameEffect = Object.values(same).every((v) => v === true) ? true : JSON.stringify(same);
|
|
|
|
// Сводная панель: переключатель меняет то же локальное состояние, что и кнопка.
|
|
await openMenu();
|
|
const before = (await menuState()).pressed['summary-toggle'];
|
|
await clickAt('[data-hp="header-menu-item"][data-id="summary-toggle"]');
|
|
await openMenu();
|
|
const after = (await menuState()).pressed['summary-toggle'];
|
|
await clickAt('[data-hp="header-menu-item"][data-id="summary-toggle"]');
|
|
out.summaryToggleFlips = before !== after && ['true', 'false'].includes(after);
|
|
|
|
// Редактор с телефона: вход из меню, строка одна, × виден и закрывает.
|
|
await openMenu();
|
|
await clickAt('[data-hp="header-menu-item"][data-id="mode-plan"]');
|
|
await page.waitForTimeout(300);
|
|
out.editorFromMenu = (await mode()) === 'plan';
|
|
const inEditor = await header();
|
|
out.editorOneRow56 = inEditor.oneRow && inEditor.height <= 56 && inEditor.modeSlot
|
|
&& inEditor.cross && inEditor.hiddenInline.length === 0;
|
|
await openMenu();
|
|
const editorMenu = await menuState();
|
|
out.editorMenuMarksCurrent = editorMenu.current.join() === 'mode-plan' && !editorMenu.ids.includes('summary-toggle');
|
|
await clickAt('[data-hp="header-menu"]');
|
|
await clickAt('[data-hp="editor-close"]');
|
|
await page.waitForTimeout(300);
|
|
out.crossClosesOnPhone = (await mode()) === 'view';
|
|
|
|
// Escape закрывает меню и возвращает фокус на шестерёнку.
|
|
await openMenu();
|
|
await page.keyboard.press('Escape');
|
|
await settle();
|
|
out.escapeClosesAndFocuses = await page.evaluate(() => {
|
|
const sr = window.__card.shadowRoot;
|
|
return !sr.querySelector('#hp-header-menu') && sr.activeElement === sr.querySelector('[data-hp="header-menu"]');
|
|
});
|
|
|
|
// Тап вне меню поглощается: под пальцем маркер устройства, но до него тап не доходит.
|
|
const target = await page.evaluate(() => {
|
|
const sr = window.__card.shadowRoot;
|
|
const top = sr.querySelector('.hdr').getBoundingClientRect().bottom + 40;
|
|
for (const el of sr.querySelectorAll('[data-hp="device"]')) {
|
|
const r = el.getBoundingClientRect();
|
|
const x = r.left + r.width / 2; const y = r.top + r.height / 2;
|
|
if (r.width > 0 && y > top && y < innerHeight - 20 && x > 20 && x < innerWidth - 20) {
|
|
const hit = sr.elementFromPoint(x, y);
|
|
if (hit && (hit === el || el.contains(hit))) return { x, y, id: el.getAttribute('data-id') };
|
|
}
|
|
}
|
|
return null;
|
|
});
|
|
if (target) {
|
|
await openMenu();
|
|
const shield = await page.evaluate(({ x, y }) => window.__card.shadowRoot.elementFromPoint(x, y)?.getAttribute('data-hp'), target);
|
|
const states = () => page.evaluate(() => JSON.stringify(window.__card.hass.states));
|
|
const dialogsBefore = await dialogKinds();
|
|
const statesBefore = await states();
|
|
await page.mouse.click(target.x, target.y);
|
|
await settle();
|
|
await page.waitForTimeout(250);
|
|
out.outsideTapClosesMenu = !(await menuState()).open;
|
|
out.outsideTapSwallowed = shield === 'header-menu-scrim' && (await states()) === statesBefore
|
|
&& (await dialogKinds()).join() === dialogsBefore.join();
|
|
} else {
|
|
out.outsideTapClosesMenu = 'нет маркера под шапкой';
|
|
out.outsideTapSwallowed = 'нет маркера под шапкой';
|
|
}
|
|
|
|
// Много пространств с длинными названиями: строка одна, активная вкладка видна.
|
|
await page.evaluate(() => window.__hpTest.setServerConfig((cfg) => {
|
|
const base = cfg.spaces[0];
|
|
for (let i = 1; i <= 4; i++) {
|
|
cfg.spaces.push({ ...structuredClone(base), id: `smoke-long-${i}`, title: `Очень длинное пространство ${i}`, floor: undefined });
|
|
}
|
|
return cfg;
|
|
}));
|
|
await size(390);
|
|
const lastId = await page.evaluate(() => [...window.__card.shadowRoot.querySelectorAll('[data-hp="space-tab"]')].at(-1)?.dataset.id);
|
|
await page.evaluate((id) => window.__hpTest.switchSpace(id), lastId);
|
|
await page.waitForTimeout(200);
|
|
const tabVisible = () => page.evaluate(() => {
|
|
const sr = window.__card.shadowRoot;
|
|
const nav = sr.querySelector('.head .tabs').getBoundingClientRect();
|
|
const tab = sr.querySelector('.head .tab.active').getBoundingClientRect();
|
|
return tab.left >= nav.left - 0.5 && tab.right <= nav.right + 0.5;
|
|
});
|
|
const many = await header();
|
|
out.manySpacesOneRow = many.oneRow && many.height <= 56;
|
|
out.activeTabRevealedOnSwitch = await tabVisible();
|
|
const firstId = await page.evaluate(() => window.__card.shadowRoot.querySelector('[data-hp="space-tab"]').dataset.id);
|
|
await page.evaluate((id) => window.__hpTest.switchSpace(id), firstId);
|
|
await page.waitForTimeout(200);
|
|
out.activeTabRevealedBack = await tabVisible();
|
|
|
|
// > 480 px: прежняя шапка, кнопки меню не видно.
|
|
for (const width of [481, 768, 1400]) {
|
|
await size(width);
|
|
const w = await header();
|
|
out[`admin${width}_unchanged`] = !w.gear && w.inlineVisible.length === 2 && w.title ? true : JSON.stringify(w);
|
|
}
|
|
|
|
// ---- не-админ ---------------------------------------------------------------
|
|
await page.evaluate(async () => {
|
|
const base = window.__mkHass();
|
|
const hass = {
|
|
...base, user: { id: 'household', name: 'Household', is_admin: false },
|
|
callWS: async (m) => {
|
|
const r = await base.callWS(m);
|
|
return m.type === 'houseplan/config/get' ? { ...r, can_write: false } : r;
|
|
},
|
|
};
|
|
const host = document.getElementById('host');
|
|
host.replaceChildren();
|
|
const card = document.createElement('houseplan-card');
|
|
card.setConfig({ type: 'custom:houseplan-card', title: 'House Plan' });
|
|
host.append(card);
|
|
card.hass = hass;
|
|
window.__card = card;
|
|
const until = Date.now() + 8000;
|
|
while (Date.now() < until && !card.shadowRoot?.querySelector('[data-hp="space-tab"]')) await new Promise((r) => setTimeout(r, 50));
|
|
await new Promise((r) => setTimeout(r, 600));
|
|
});
|
|
for (const width of [390, 320]) {
|
|
await size(width);
|
|
const h = await header();
|
|
out[`household${width}_oneRow56`] = h.oneRow && h.height <= 56;
|
|
out[`household${width}_rowContent`] = !h.title && h.tabs && h.zoom && h.gear && !h.tabEditInDom;
|
|
await openMenu();
|
|
const m = await menuState();
|
|
out[`household${width}_menuItems`] = m.ids.join(',') === 'summary-settings,summary-toggle' ? true : m.ids.join(',');
|
|
await page.keyboard.press('Escape');
|
|
await settle();
|
|
}
|
|
// «После загрузки на последнем»: вкладка запомнена, новая карточка открывает
|
|
// её же — и докручивает в видимую область без участия человека.
|
|
await size(390);
|
|
const lastHouseholdId = await page.evaluate(() => [...window.__card.shadowRoot.querySelectorAll('[data-hp="space-tab"]')].at(-1)?.dataset.id);
|
|
await page.evaluate((id) => window.__hpTest.switchSpace(id), lastHouseholdId);
|
|
await page.evaluate(async () => {
|
|
const old = window.__card;
|
|
const hass = old.hass;
|
|
const host = document.getElementById('host');
|
|
host.replaceChildren();
|
|
const card = document.createElement('houseplan-card');
|
|
card.setConfig({ type: 'custom:houseplan-card', title: 'House Plan' });
|
|
host.append(card);
|
|
card.hass = hass;
|
|
window.__card = card;
|
|
const until = Date.now() + 8000;
|
|
while (Date.now() < until && !card.shadowRoot?.querySelector('.head .tab.active')) await new Promise((r) => setTimeout(r, 50));
|
|
await new Promise((r) => setTimeout(r, 800));
|
|
});
|
|
out.reloadOpensLastSpace = await page.evaluate((id) => window.__card.shadowRoot.querySelector('.head .tab.active')?.dataset.id === id, lastHouseholdId);
|
|
out.activeTabRevealedOnLoad = await tabVisible();
|
|
await size(1400);
|
|
const wide = await header();
|
|
out.household1400_noTabEditing = !wide.tabEditInDom && !wide.gear;
|
|
|
|
// ---- киоск: шапки нет, меню не рендерится ------------------------------------
|
|
await size(390);
|
|
await page.evaluate(async () => {
|
|
const host = document.getElementById('host');
|
|
host.replaceChildren();
|
|
const card = document.createElement('houseplan-card');
|
|
card.setConfig({ type: 'custom:houseplan-card', kiosk: true });
|
|
host.append(card);
|
|
card.hass = window.__mkHass();
|
|
window.__card = card;
|
|
const until = Date.now() + 8000;
|
|
while (Date.now() < until && !card.shadowRoot?.querySelector('.hdr')) await new Promise((r) => setTimeout(r, 50));
|
|
await new Promise((r) => setTimeout(r, 400));
|
|
});
|
|
out.kioskNoMenu = await page.evaluate(() => {
|
|
const sr = window.__card.shadowRoot;
|
|
return getComputedStyle(sr.querySelector('.hdr')).display === 'none' && !sr.querySelector('[data-hp="header-menu"]');
|
|
});
|
|
|
|
for (const [name, value] of Object.entries(out)) check(name, value);
|
|
await finish(browser, out);
|