Files
houseplan-card/demo/smoke_household_journeys.mjs
T

481 lines
23 KiB
JavaScript

// Household-сценарии основного View (#560).
//
// Шесть путей домашнего пользователя, а не шесть проверок функций: открыть
// карточку на проснувшемся экране, найти тревогу, прочитать значение, выполнить
// разрешённое действие, переключить этаж, вернуться после обновления. У каждого
// названы персона, вход, действие и НЕЗАВИСИМЫЙ наблюдаемый оракул: где можно —
// не «класс появился», а вызов сервиса, доступное имя или содержимое модели.
//
// Две ширины, потому что домашний пользователь живёт в двух разных местах:
// настенный планшет (штатная ширина демо-стенда, 780 px) и узкая колонка
// телефона (390 px) — та самая, на которой ломается первый контакт.
//
// Граница честности: это синтетический стенд. Реальная установка владельца в
// этом заходе читалась только на чтение (профиль в docs/QUALITY-560.md),
// добровольцев и физического Companion в проверке не было.
import { launch, check, finish, watchPage } from './serve.mjs';
const { page, browser } = await launch();
const out = {};
const settle = () => page.evaluate(() => new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve))));
/** Геометрия карточки успокоилась: два одинаковых замера подряд (#533). */
const settledBox = async (id, attempts = 25) => {
let previous = null;
for (let i = 0; i < attempts; i++) {
await page.waitForTimeout(120);
const now = await page.evaluate((markerId) => {
const node = window.__card.renderRoot.querySelector(`[data-hp="device"][data-id="${markerId}"]`);
if (!node) return null;
const rect = node.getBoundingClientRect();
return [Number((rect.x + rect.width / 2).toFixed(2)), Number((rect.y + rect.height / 2).toFixed(2)),
Number(rect.width.toFixed(2))];
}, id);
if (previous && now && previous[0] === now[0] && previous[1] === now[1]) return now;
previous = now;
}
return previous;
};
const setWidth = async (px) => {
await page.evaluate(async (width) => {
const card = window.__card;
card.style.width = `${width}px`;
card._lastValidStageSize = null;
card._refitView?.();
await card.updateComplete;
}, px);
await settle();
};
// --- J1. «Экран проснулся» ---------------------------------------------------
// Персона: домашний пользователь у настенного планшета. Вход: вкладка, которая
// никогда не загружала редакторский чанк. Оракул: план показан (вуаль снята),
// комнаты и устройства нарисованы, редакторский чанк не запрашивался.
const requested = [];
page.on('request', (request) => requested.push(new URL(request.url()).pathname));
const j1 = await page.evaluate(() => {
const card = window.__card;
const root = card.renderRoot;
return {
booting: card._booting,
rooms: root.querySelectorAll('[data-hp="room"]').length,
devices: root.querySelectorAll('[data-hp="device"]').length,
stageVisible: getComputedStyle(root.querySelector('.stage')).visibility,
};
});
out.j1 = j1;
check('j1.plan_revealed', j1.booting === false && j1.stageVisible === 'visible', true);
check('j1.rooms_drawn', j1.rooms > 0, true);
check('j1.devices_drawn', j1.devices > 0, true);
// --- J2. «Где протечка» ------------------------------------------------------
// Вход: датчик протечки уходит в тревогу. Оракул независимый: доступное имя
// маркера называет состояние, а не только цвет пятна.
const j2 = await page.evaluate(async () => {
const card = window.__card;
const leak = card._devices.find((device) => device.id === 'd_leak') || card._devices[0];
const entity = leak.primary || Object.keys(card.hass.states)[0];
card.hass = { ...card.hass, states: { ...card.hass.states, [entity]: { ...card.hass.states[entity], state: 'on' } } };
await card.updateComplete;
await new Promise((resolve) => setTimeout(resolve, 150));
const node = card.renderRoot.querySelector(`[data-hp="device"][data-id="${leak.id}"]`);
return {
id: leak.id,
label: node?.getAttribute('aria-label') || '',
state: node?.dataset.state || null,
alarmClass: node?.classList.contains('alarm') || false,
role: node?.getAttribute('role') || null,
segments: (node?.getAttribute('aria-label') || '').split(', ')
.map((value) => value.trim().toLowerCase()).filter(Boolean),
};
});
out.j2 = j2;
check('j2.alarm_has_accessible_state', /\S/.test(j2.label) && j2.state !== null, true);
check('j2.alarm_not_colour_only', j2.label.split(',').length >= 2, true);
check('j2.alarm_fact_is_not_repeated', new Set(j2.segments).size, j2.segments.length);
check('j2.alarm_is_spoken_once', j2.segments.filter((part) => part === 'alarm').length, 1);
// --- J3. «Сколько в спальне» -------------------------------------------------
// Оракул: числовое значение попадает в ДОСТУПНОЕ ИМЯ, а не только в рисунок.
const j3 = await page.evaluate(() => {
const card = window.__card;
const nodes = [...card.renderRoot.querySelectorAll('[data-hp="device"]')];
const withValue = nodes
.map((node) => ({ id: node.dataset.id, label: node.getAttribute('aria-label') || '' }))
.filter((item) => /\d/.test(item.label));
return { total: nodes.length, withValue: withValue.length, sample: withValue.slice(0, 2) };
});
out.j3 = j3;
check('j3.values_are_readable_by_name', j3.withValue > 0, true);
// --- J4. «Выключить свет с клавиатуры» ---------------------------------------
// Вход: Tab до маркера на плане, затем Enter. Оракул: вызов сервиса, а не
// внутренний флаг карточки.
const tabStops = await (async () => {
let stops = 0;
for (let i = 0; i < 40; i++) {
await page.keyboard.press('Tab');
stops += 1;
const reached = await page.evaluate(() => {
const leaf = (() => {
let node = document.activeElement;
while (node?.shadowRoot?.activeElement) node = node.shadowRoot.activeElement;
return node;
})();
return leaf?.dataset?.hp === 'device' ? leaf.dataset.id : null;
});
if (reached) return { stops, id: reached };
}
return { stops, id: null };
})();
const j4 = await page.evaluate(async () => {
const card = window.__card;
const calls = [];
const hass = card.hass;
const original = hass.callService?.bind(hass);
card.hass = { ...hass, callService: (...args) => { calls.push(`${args[0]}.${args[1]}`); return original?.(...args); } };
await card.updateComplete;
const leaf = (() => {
let node = document.activeElement;
while (node?.shadowRoot?.activeElement) node = node.shadowRoot.activeElement;
return node;
})();
const focusVisible = leaf?.matches?.(':focus-visible') || false;
const ring = leaf ? getComputedStyle(leaf).getPropertyValue('--device-ring-color').trim() : null;
const idle = card.renderRoot.querySelector('[data-hp="device"]:not(:focus-visible)');
const idleRing = idle ? getComputedStyle(idle).getPropertyValue('--device-ring-color').trim() : null;
const tip = card.renderRoot.querySelector('[data-hp-live-tip]');
const tipBox = tip?.getBoundingClientRect();
const focusedDevice = card._devices.find((device) => device.id === leaf?.dataset?.id);
const focusTooltip = {
visible: !!tip && !tip.hidden,
title: tip?.querySelector('b')?.textContent?.trim() || '',
text: tip?.textContent?.trim() || '',
expectedTitle: focusedDevice?.name || '',
inViewport: !!tipBox && tipBox.left >= 0 && tipBox.top >= 0
&& tipBox.right <= innerWidth && tipBox.bottom <= innerHeight,
source: card._tip?.source || null,
};
leaf?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
await new Promise((resolve) => setTimeout(resolve, 150));
return { calls, focusVisible, ring, idleRing, focusTooltip };
});
out.j4 = { ...j4, ...tabStops };
check('j4.marker_reachable_by_tab', tabStops.id !== null, true);
check('j4.focus_is_visible', j4.focusVisible && j4.ring !== j4.idleRing, true);
check('j4.focus_shows_same_device_tooltip',
j4.focusTooltip.visible && j4.focusTooltip.title === j4.focusTooltip.expectedTitle, true);
check('j4.focus_tooltip_is_anchored_in_viewport',
j4.focusTooltip.source === 'focus' && j4.focusTooltip.inViewport, true);
check('j4.enter_calls_service', j4.calls.length > 0, true);
const j4DualInput = await page.evaluate((focusId) => {
const card = window.__card;
const root = card.renderRoot;
const tip = root.querySelector('[data-hp-live-tip]');
const focusText = tip?.textContent?.trim() || '';
const room = root.querySelector('[data-hp="room"]');
const roomBox = room?.getBoundingClientRect();
room?.dispatchEvent(new PointerEvent('pointermove', {
bubbles: true, composed: true, pointerType: 'mouse',
clientX: roomBox?.left || 0, clientY: roomBox?.top || 0,
}));
const afterRoom = {
source: card._tip?.source || null,
deviceId: card._tip?.deviceId || null,
text: tip?.textContent?.trim() || '',
};
const other = [...root.querySelectorAll('[data-hp="device"]')]
.find((marker) => marker.dataset.id !== focusId);
const otherBox = other?.getBoundingClientRect();
const otherName = card._devices.find((device) => device.id === other?.dataset.id)?.name || '';
other?.dispatchEvent(new PointerEvent('pointerover', {
bubbles: true, composed: true, pointerType: 'mouse',
clientX: (otherBox?.left || 0) + (otherBox?.width || 0) / 2,
clientY: (otherBox?.top || 0) + (otherBox?.height || 0) / 2,
}));
const afterDevice = {
source: card._tip?.source || null,
title: tip?.querySelector('b')?.textContent?.trim() || '',
expectedTitle: otherName,
};
other?.dispatchEvent(new PointerEvent('pointerleave', {
bubbles: false, composed: true, pointerType: 'mouse',
clientX: otherBox?.right || 0, clientY: otherBox?.bottom || 0,
}));
const restored = {
source: card._tip?.source || null,
deviceId: card._tip?.deviceId || null,
text: tip?.textContent?.trim() || '',
};
return { focusText, afterRoom, afterDevice, restored };
}, tabStops.id);
out.j4_dual_input = j4DualInput;
check('j4.room_hover_does_not_replace_keyboard_tooltip', j4DualInput.afterRoom,
{ source: 'focus', deviceId: tabStops.id, text: j4DualInput.focusText });
check('j4.other_device_keeps_ordinary_pointer_hover',
j4DualInput.afterDevice.source === 'pointer'
&& j4DualInput.afterDevice.title === j4DualInput.afterDevice.expectedTitle, true);
check('j4.pointer_leave_restores_keyboard_tooltip', j4DualInput.restored,
{ source: 'focus', deviceId: tabStops.id, text: j4DualInput.focusText });
await page.keyboard.press('Tab');
const j4Next = await page.evaluate(() => {
const card = window.__card;
const tip = card.renderRoot.querySelector('[data-hp-live-tip]');
const leaf = (() => {
let node = document.activeElement;
while (node?.shadowRoot?.activeElement) node = node.shadowRoot.activeElement;
return node;
})();
return {
visible: !!tip && !tip.hidden,
activeTag: leaf?.tagName || null,
activeHp: leaf?.dataset?.hp || null,
activeId: leaf?.dataset?.id || null,
tipSource: card._tip?.source || null,
tipDevice: card._tip?.deviceId || null,
};
});
out.j4_next = j4Next;
check('j4.tab_moves_focus_tooltip_to_next_device',
j4Next.visible && j4Next.activeHp === 'device' && j4Next.activeId === j4Next.tipDevice
&& j4Next.tipSource === 'focus', true);
const j4Blur = await page.evaluate(() => {
const card = window.__card;
let leaf = document.activeElement;
while (leaf?.shadowRoot?.activeElement) leaf = leaf.shadowRoot.activeElement;
leaf?.blur?.();
const tip = card.renderRoot.querySelector('[data-hp-live-tip]');
return { hidden: !tip || tip.hidden, stateCleared: card._tip === null };
});
out.j4_blur = j4Blur;
check('j4.blur_hides_focus_tooltip', j4Blur.hidden && j4Blur.stateCleared, true);
const j4HoverParity = await page.evaluate((deviceId) => {
const card = window.__card;
const marker = card.renderRoot.querySelector(`[data-hp="device"][data-id="${deviceId}"]`);
const rect = marker?.getBoundingClientRect();
marker?.dispatchEvent(new PointerEvent('pointerover', {
bubbles: true, composed: true, pointerType: 'mouse',
clientX: rect?.left || 0, clientY: rect?.top || 0,
}));
const tip = card.renderRoot.querySelector('[data-hp-live-tip]');
const hoverText = tip?.textContent?.trim() || '';
card._showTip(new PointerEvent('pointermove', {
pointerType: 'mouse', clientX: innerWidth, clientY: innerHeight,
}), 'Viewport edge', 'clamp probe');
const edgeBox = tip?.getBoundingClientRect();
const edgeInViewport = !!edgeBox && edgeBox.left >= 0 && edgeBox.top >= 0
&& edgeBox.right <= innerWidth && edgeBox.bottom <= innerHeight;
card._clearTransientHover();
return { hoverText, edgeInViewport };
}, tabStops.id);
out.j4_hover_parity = j4HoverParity;
check('j4.focus_and_mouse_tooltip_content_match',
j4HoverParity.hoverText, j4.focusTooltip.text);
check('j4.tooltip_clamps_at_viewport_edge', j4HoverParity.edgeInViewport, true);
// --- J5. «Переключить этаж» --------------------------------------------------
const j5 = await page.evaluate(async () => {
const card = window.__card;
const root = card.renderRoot;
const tabs = [...root.querySelectorAll('[data-hp="space-tab"]')];
const before = card._space;
const other = tabs.find((tab) => tab.dataset.id !== before);
const beforeRooms = [...root.querySelectorAll('[data-hp="room"]')].map((node) => node.dataset.id).join(',');
other.click();
await card.updateComplete;
await new Promise((resolve) => setTimeout(resolve, 250));
const afterRooms = [...root.querySelectorAll('[data-hp="room"]')].map((node) => node.dataset.id).join(',');
const activeTab = root.querySelector('[data-hp="space-tab"].active');
const navigation = root.querySelector('nav.tabs');
const currentTabs = [...root.querySelectorAll('[data-hp="space-tab"][aria-current]')];
const add = root.querySelector('[data-hp="space-add"]');
return {
tabs: tabs.length, before, after: card._space,
roomsChanged: beforeRooms !== afterRooms,
activeTabIsCurrent: activeTab?.dataset.id === card._space,
// Состояние вкладки для программы: есть ли он вообще, кроме класса.
ariaCurrent: activeTab?.getAttribute('aria-current'),
ariaPressed: activeTab?.getAttribute('aria-pressed'),
ariaSelected: activeTab?.getAttribute('aria-selected'),
navigationName: navigation?.getAttribute('aria-label') || '',
currentCount: currentTabs.length,
inactiveHaveNoCurrent: [...root.querySelectorAll('[data-hp="space-tab"]')]
.filter((tab) => tab !== activeTab).every((tab) => !tab.hasAttribute('aria-current')),
addHasNoCurrent: !add?.hasAttribute('aria-current'),
hasTablistRoles: !!root.querySelector('[role="tablist"], [role="tab"]'),
};
});
out.j5 = j5;
check('j5.floor_switched', j5.after !== j5.before, true);
check('j5.plan_rerendered', j5.roomsChanged, true);
check('j5.active_tab_matches_space', j5.activeTabIsCurrent, true);
check('j5.spaces_are_named_navigation', /\S/.test(j5.navigationName), true);
check('j5.only_current_space_is_programmatic',
j5.ariaCurrent === 'page' && j5.currentCount === 1 && j5.inactiveHaveNoCurrent
&& j5.addHasNoCurrent, true);
check('j5.native_button_navigation_is_kept', j5.hasTablistRoles, false);
const j5Keyboard = {};
for (const [key, expected] of [['Enter', j5.before], [' ', j5.after]]) {
const before = await page.evaluate(() => window.__card._space);
await page.evaluate((id) => {
window.__card.renderRoot.querySelector(`[data-hp="space-tab"][data-id="${id}"]`)?.focus();
}, expected);
await page.keyboard.press(key === ' ' ? 'Space' : key);
await settle();
const after = await page.evaluate(() => window.__card._space);
j5Keyboard[key === ' ' ? 'space' : 'enter'] = { before, after, expected };
}
out.j5_keyboard = j5Keyboard;
check('j5.enter_switches_space', j5Keyboard.enter.after, j5Keyboard.enter.expected);
check('j5.space_switches_space', j5Keyboard.space.after, j5Keyboard.space.expected);
// --- J6. «Вернулся после обновления» ----------------------------------------
const j6 = await page.evaluate(async () => {
const card = window.__card;
const chosen = card._space;
card._serverCfg = structuredClone(card._serverCfg);
card._cfgEpoch++;
card.requestUpdate();
await card.updateComplete;
await new Promise((resolve) => setTimeout(resolve, 200));
return {
chosen, after: card._space,
rooms: card.renderRoot.querySelectorAll('[data-hp="room"]').length,
devices: card.renderRoot.querySelectorAll('[data-hp="device"]').length,
};
});
out.j6 = j6;
check('j6.space_kept', j6.after === j6.chosen, true);
check('j6.plan_redrawn', j6.rooms > 0 && j6.devices > 0, true);
// --- J7. Цель нажатия при плотной расстановке --------------------------------
// Вход: пять маркеров в колонке с шагом 0,0333 нормированной единицы — самая
// плотная расстановка, найденная на реальной установке владельца.
// Оракул: в центре ВИДИМОГО маркера нажатие достаётся ему же.
const dense = await page.evaluate(async () => {
const card = window.__card;
const space = card._serverCfg.spaces.find((candidate) => candidate.id === card._space);
const room = space.rooms.find((candidate) => candidate.id && candidate.area) || space.rooms[0];
const ids = ['dense1', 'dense2', 'dense3', 'dense4', 'dense5'];
card._serverCfg.markers = [...(card._serverCfg.markers || []), ...ids.map((id, index) => ({
id, name: `Dense ${index + 1}`, binding: 'virtual', is_light: true, tap_action: 'info',
space: space.id, area: room.area, room_id: room.id,
}))];
const layout = { ...card._layout };
ids.forEach((id, index) => { layout[id] = { s: space.id, x: 0.5, y: 0.3 + index * 0.0333 }; });
card._layout = layout;
card._cfgEpoch++;
card._regSignature = '';
card._maybeRebuildDevices();
card.requestUpdate();
await card.updateComplete;
return { ids };
});
const ownerOfCentre = async (id) => {
const centre = await settledBox(id);
if (!centre) return null;
return page.evaluate(([cx, cy]) => {
let node = document.elementFromPoint(cx, cy);
while (node?.shadowRoot?.elementFromPoint) {
const deeper = node.shadowRoot.elementFromPoint(cx, cy);
if (!deeper || deeper === node) break;
node = deeper;
}
return node?.closest?.('[data-hp="device"]')?.dataset?.id || null;
}, [centre[0], centre[1]]);
};
const semanticOwnerOfCentre = async (id) => {
const centre = await settledBox(id);
if (!centre) return null;
return page.evaluate(([cx, cy]) => window.__card._deviceHitOwnerAt(cx, cy)?.id || null,
[centre[0], centre[1]]);
};
const clickAndReadInfoOwner = async (centre) => {
await page.mouse.click(centre[0], centre[1]);
await page.waitForTimeout(100);
return page.evaluate(async () => {
const card = window.__card;
const id = card._infoCard?.id || null;
card._infoCard = null;
card.requestUpdate();
await card.updateComplete;
return id;
});
};
const tablet = {
size: (await settledBox('dense1'))?.[2] ?? null, owners: [], semanticOwners: [],
};
for (const id of dense.ids) {
tablet.owners.push(await ownerOfCentre(id));
tablet.semanticOwners.push(await semanticOwnerOfCentre(id));
}
const tabletCentre = await settledBox('dense1');
tablet.clickWentTo = await clickAndReadInfoOwner(tabletCentre);
out.j7_tablet = tablet;
// Настенный планшет: центр видимого маркера принадлежит ему. Это то свойство,
// которое правка плотных целей обязана сохранить.
check('j7.tablet_centre_belongs_to_its_marker',
tablet.owners.every((owner, index) => owner === dense.ids[index]), true);
check('j7.tablet_semantic_owner_is_its_marker',
tablet.semanticOwners.every((owner, index) => owner === dense.ids[index]), true);
check('j7.tablet_click_reaches_its_marker', tablet.clickWentTo, 'dense1');
// Узкая колонка телефона — исходная регрессия #564. И native hit owner, и
// семантический владелец действия обязаны совпасть с видимым маркером.
await setWidth(390);
const phone = {
size: (await settledBox('dense1'))?.[2] ?? null, owners: [], semanticOwners: [],
};
for (const id of dense.ids) {
phone.owners.push(await ownerOfCentre(id));
phone.semanticOwners.push(await semanticOwnerOfCentre(id));
}
const phoneCentre = await settledBox('dense1');
phone.clickWentTo = await clickAndReadInfoOwner(phoneCentre);
phone.latchedOwner = await page.evaluate(async () => {
const card = window.__card;
const first = card.renderRoot.querySelector('[data-hp="device"][data-id="dense1"]');
const second = card.renderRoot.querySelector('[data-hp="device"][data-id="dense2"]');
const a = first.getBoundingClientRect();
const b = second.getBoundingClientRect();
const pointerId = 564;
const event = (type, rect, buttons) => new PointerEvent(type, {
pointerId, pointerType: 'touch', buttons, bubbles: true, cancelable: true,
clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
});
const d1 = card._devices.find((device) => device.id === 'dense1');
const d2 = card._devices.find((device) => device.id === 'dense2');
card._pointerDown(event('pointerdown', a, 1), d1);
const moved = card._pointerMove(event('pointermove', b, 1), d2)?.id || null;
card._pointerUp(event('pointerup', b, 0), d2);
card._clickDevice(event('click', b, 0), d2);
await card.updateComplete;
const clicked = card._infoCard?.id || null;
card._infoCard = null;
card.requestUpdate();
await card.updateComplete;
return { moved, clicked };
});
out.j7_phone = phone;
check('j7.phone_centre_belongs_to_its_marker',
phone.owners.every((owner, index) => owner === dense.ids[index]), true);
check('j7.phone_semantic_owner_is_its_marker',
phone.semanticOwners.every((owner, index) => owner === dense.ids[index]), true);
check('j7.phone_click_reaches_its_marker', phone.clickWentTo, 'dense1');
check('j7.pointer_owner_is_latched_through_terminal_click',
phone.latchedOwner, { moved: 'dense1', clicked: 'dense1' });
await finish(browser, out);