Files
houseplan-card/demo/smoke_plan_device_landmarks.mjs
Claude 128af2392a test(plan): Node witness for the landmark cascade; smoke via __hpTest
The three #687 mutants now name a Node suite over the compiled Plan
stylesheet (test/plan-device-landmarks.test.mjs), as the #683 stair
cursor mutant does: the reviewed browser-guard inventory is at its cap
(200/200, #659). The suite pins no hiding in .stage.markup, the
filter: opacity(0.35) fade without an opacity override, the pointer
boundary on the marker, its subtree and ::before (and Background's),
and a fade scoped to the Plan stage. Parity of the 35% with Background
is a computed-style fact and stays with the smoke (#624 forbids text
reads of the monolith). The smoke now enters modes and tools through
window.__hpTest instead of private writes (#629).

Issue: #687
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-28 09:59:52 +03:00

207 lines
11 KiB
JavaScript

// #687: in the Plan editor device markers are landmarks exactly as in the
// Background editor (#362) — the same markers as View, at the Background
// editor's effective opacity, and fully pointer-inert so every Plan tool
// receives the point below them. Room labels (same .devlayer) stay opaque.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const probe = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const settleMode = async () => {
const started = performance.now();
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
while (c._modeTransitionBusy && performance.now() - started < 1500);
await c.updateComplete;
};
// Entering a mode settles the camera after the stage resizes; wait until
// marker positions hold still so measured points are the clicked points.
const layoutKey = () => [...sr().querySelectorAll('.devlayer [data-hp="device"]')]
.map((node) => { const r = node.getBoundingClientRect(); return `${r.left.toFixed(1)},${r.top.toFixed(1)}`; })
.join('|');
const enter = async (mode) => {
if (c._mode !== mode) await window.__hpTest.setMode(mode);
await settleMode();
const started = performance.now();
let key = layoutKey(), still = 0;
while (still < 10 && performance.now() - started < 3000) {
await new Promise((resolve) => requestAnimationFrame(resolve));
const next = layoutKey();
still = next === key ? still + 1 : 0;
key = next;
}
await c.updateComplete;
};
const stage = () => sr().querySelector('.stage');
const markers = () => [...sr().querySelectorAll('.devlayer [data-hp="device"]')];
const visible = (node) => {
const r = node.getBoundingClientRect();
return getComputedStyle(node).display !== 'none' && r.width > 0 && r.height > 0;
};
const visibleIds = () => markers().filter(visible).map((node) => node.dataset.id).sort();
// Effective alpha of one marker: its own and every ancestor's opacity and
// filter: opacity(), up to and including the stage.
const effectiveOpacity = (node) => {
let alpha = 1;
for (let el = node; el; el = el.parentElement) {
const cs = getComputedStyle(el);
alpha *= Number(cs.opacity);
for (const m of cs.filter.matchAll(/opacity\(([^)]+)\)/g)) alpha *= Number(m[1]);
if (el === stage()) break;
}
return alpha;
};
const opacityById = () => Object.fromEntries(markers().filter(visible)
.map((node) => [node.dataset.id, effectiveOpacity(node)]));
// One unavailable marker exercises contract item 2: the fade multiplies
// the marker's own opacity (.unavail) exactly as the Background layer does.
const target = markers().find((node) => node.dataset.entity && c.hass.states[node.dataset.entity]);
if (target) {
const entity = target.dataset.entity;
c.hass = { ...c.hass, states: { ...c.hass.states,
[entity]: { ...c.hass.states[entity], state: 'unavailable' } } };
await c.updateComplete;
}
await enter('view');
out.unavailableMarkerInView = !!sr().querySelector('.devlayer [data-hp="device"].unavail');
const viewIds = visibleIds();
out.viewHasMarkers = viewIds.length > 0;
await enter('decor');
const decorOpacity = opacityById();
await enter('plan');
const planIds = visibleIds();
const planOpacity = opacityById();
// AC1 — the same markers as View, each at the Background editor's alpha.
out.planShowsViewMarkers = JSON.stringify(planIds) === JSON.stringify(viewIds);
out.planOpacityMatchesBackground = planIds.length > 0 && planIds.every((id) =>
id in decorOpacity && Math.abs(planOpacity[id] - decorOpacity[id]) < 1e-3);
const plain = markers().find((node) => visible(node) && !node.classList.contains('unavail'));
out.planPlainMarkerAt35 = !!plain && Math.abs(planOpacity[plain.dataset.id] - 0.35) < 1e-3;
const unavail = markers().find((node) => visible(node) && node.classList.contains('unavail'));
out.planUnavailableMarkerAt35x35 = !!unavail
&& Math.abs(planOpacity[unavail.dataset.id] - 0.35 * 0.35) < 1e-3;
// Room labels share .devlayer: no layer fade reaches them (their own
// opacity is the label design, not this contract).
const label = sr().querySelector('.roomlabel');
out.roomLabelNotFaded = !!label
&& Math.abs(effectiveOpacity(label) - Number(getComputedStyle(label).opacity)) < 1e-3
&& !/opacity\(/.test(getComputedStyle(label).filter);
out.planMarkersOutOfTabOrder = markers().every((node) => !node.hasAttribute('tabindex')
&& !node.hasAttribute('role'));
// AC2 — pick a marker wholly inside the stage, preferring a value capsule
// that extends outside the marker's nominal box.
const s = stage().getBoundingClientRect();
const inside = markers().filter((node) => {
const r = node.getBoundingClientRect();
return visible(node) && r.left > s.left + 4 && r.right < s.right - 4
&& r.top > s.top + 4 && r.bottom < s.bottom - 4;
});
const at = (p) => p ? sr().elementFromPoint(p.x, p.y) : null;
const centre = (node) => {
const b = node.getBoundingClientRect();
return { x: b.left + b.width / 2, y: b.top + b.height / 2 };
};
// Room chrome lying under a marker keeps the hit (the demo plan puts a
// marker over a room's settings button).
const roomChrome = '.roomlabel, [data-hp="room-settings"]';
const overLabel = inside.find((node) => at(centre(node))?.closest?.(roomChrome));
out.roomChromeUnderMarkerGetsHit = !!overLabel;
const gearPoint = overLabel && at(centre(overLabel))?.closest?.('[data-hp="room-settings"]')
? centre(overLabel) : null;
// The tool probe lies over bare plan, so the Walls tool owns the point;
// prefer a value capsule that extends outside the marker's nominal box.
const overPlan = inside.filter((node) => !at(centre(node))?.closest?.('.devlayer'));
const dev = overPlan.find((node) => node.classList.contains('valonly')
|| node.querySelector('.device-shell.with-values')) || overPlan[0];
const frame = dev?.querySelector('.device-shell-frame');
const r = dev?.getBoundingClientRect();
const fr = frame?.getBoundingClientRect();
const core = dev ? centre(dev) : null;
const capsule = fr && fr.right > r.right + 2
? { x: fr.right - 1, y: fr.top + fr.height / 2 } : core;
out.probeFound = !!dev && !!core;
out.probeHasOutsideCapsule = !!fr && fr.right > r.right + 2;
const cursorAt = (p) => { const t = at(p); return t ? getComputedStyle(t).cursor : null; };
const hit = { core: at(core), capsule: at(capsule) };
const cursor = { core: cursorAt(core), capsule: cursorAt(capsule) };
out.coreFallsThrough = !!hit.core && !hit.core.closest('.dev');
out.capsuleFallsThrough = !!hit.capsule && !hit.capsule.closest('.dev');
out.markerSubtreeIsPointerInert = !!dev && !!frame
&& getComputedStyle(dev).pointerEvents === 'none'
&& getComputedStyle(dev, '::before').pointerEvents === 'none'
&& getComputedStyle(frame).pointerEvents === 'none'
&& [...dev.querySelectorAll('*')].every((el) => getComputedStyle(el).pointerEvents === 'none');
// "The cursor over a marker is the cursor of what lies below": the same
// point with the marker taken out of layout gives the same target/cursor.
if (dev) {
dev.style.display = 'none';
out.targetSameWithoutMarker = at(core) === hit.core && at(capsule) === hit.capsule;
out.cursorSameWithoutMarker = cursorAt(core) === cursor.core && cursorAt(capsule) === cursor.capsule;
dev.style.display = '';
}
await window.__hpTest.setTool('draw');
out.wallsChainEmptyBeforeClick = c._path.length === 0;
return { out, core, id: dev?.dataset.id, gearPoint };
});
// A real mouse click on the marker core reaches the Walls tool. It runs
// before the synthetic probes below, which leave pointer state behind.
if (probe.core) await page.mouse.click(probe.core.x, probe.core.y);
// A real click on a marker lying over a room's settings button opens the
// room dialog: the button below owns the point.
if (probe.gearPoint) await page.mouse.click(probe.gearPoint.x, probe.gearPoint.y);
const gear = await page.evaluate(async () => {
const c = window.__card;
await c.updateComplete;
const opened = c._roomDialog === true && !!c._roomEditId;
if (opened) await window.__hpTest.close(undefined, { via: 'cancel' });
return { roomSettingsOpenThroughMarker: opened, roomDialogClosed: !c._roomDialog };
});
const inert = await page.evaluate(async ({ core, id }) => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
await c.updateComplete;
out.wallsToolReceivesClickThroughMarker = c._path.length === 1;
const dev = [...sr().querySelectorAll('.devlayer [data-hp="device"]')].find((node) => node.dataset.id === id);
const r = dev?.getBoundingClientRect();
// The click above must have landed on the probed marker.
out.probeDidNotMove = !!r && !!core
&& Math.abs(r.left + r.width / 2 - core.x) < 1 && Math.abs(r.top + r.height / 2 - core.y) < 1;
// Handlers fail closed even when events are dispatched on the marker itself.
let serviceCalls = 0, wsCalls = 0;
const oldCallService = c.hass.callService;
const oldCallWS = c.hass.callWS;
c.hass.callService = () => { serviceCalls += 1; };
c.hass.callWS = async () => { wsCalls += 1; return { ok: true }; };
const before = { tip: c._tip, info: c._infoCard, drag: c._deviceDrag, sel: c._selId };
const opts = { bubbles: true, composed: true, cancelable: true, clientX: core?.x || 0, clientY: core?.y || 0 };
dev?.dispatchEvent(new PointerEvent('pointerover', { ...opts, pointerId: 687, pointerType: 'mouse', isPrimary: true }));
dev?.dispatchEvent(new PointerEvent('pointermove', { ...opts, pointerId: 687, pointerType: 'mouse', isPrimary: true }));
dev?.dispatchEvent(new PointerEvent('pointerdown', { ...opts, pointerId: 688, pointerType: 'mouse', button: 0, isPrimary: true }));
dev?.dispatchEvent(new PointerEvent('pointerup', { ...opts, pointerId: 688, pointerType: 'mouse', button: 0, isPrimary: true }));
dev?.dispatchEvent(new MouseEvent('click', { ...opts, button: 0 }));
const ctx = new MouseEvent('contextmenu', { ...opts, button: 2 });
dev?.dispatchEvent(ctx);
await c.updateComplete;
out.handlersFailClosed = serviceCalls === 0 && wsCalls === 0
&& c._tip === before.tip && c._infoCard === before.info
&& c._deviceDrag === before.drag && c._selId === before.sel;
out.noDeviceHoverAttribute = !sr().querySelector('.dev[data-hp-device-hover]');
out.contextMenuNotClaimed = !ctx.defaultPrevented;
c.hass.callService = oldCallService;
c.hass.callWS = oldCallWS;
return out;
}, { core: probe.core, id: probe.id });
const res = { ...probe.out, ...gear, ...inert };
checkAll(res);
await finish(browser, res);