From 128af2392a3d75e75716a69271682bbf1775cc09 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 09:50:32 +0300 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd --- demo/smoke_plan_device_landmarks.mjs | 15 ++---- scripts/mutation-registry.mjs | 12 +++-- test/plan-device-landmarks.test.mjs | 81 ++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 14 deletions(-) create mode 100644 test/plan-device-landmarks.test.mjs diff --git a/demo/smoke_plan_device_landmarks.mjs b/demo/smoke_plan_device_landmarks.mjs index 6c53e753..1c5eaaf7 100644 --- a/demo/smoke_plan_device_landmarks.mjs +++ b/demo/smoke_plan_device_landmarks.mjs @@ -21,7 +21,7 @@ const probe = await page.evaluate(async () => { .map((node) => { const r = node.getBoundingClientRect(); return `${r.left.toFixed(1)},${r.top.toFixed(1)}`; }) .join('|'); const enter = async (mode) => { - c._setMode(mode, false); + if (c._mode !== mode) await window.__hpTest.setMode(mode); await settleMode(); const started = performance.now(); let key = layoutKey(), still = 0; @@ -144,8 +144,8 @@ const probe = await page.evaluate(async () => { dev.style.display = ''; } - c._tool = 'draw'; c._path = []; c._cursorPt = null; - await c.updateComplete; + await window.__hpTest.setTool('draw'); + out.wallsChainEmptyBeforeClick = c._path.length === 0; return { out, core, id: dev?.dataset.id, gearPoint }; }); @@ -160,8 +160,8 @@ const gear = await page.evaluate(async () => { const c = window.__card; await c.updateComplete; const opened = c._roomDialog === true && !!c._roomEditId; - if (opened) { c._roomDialogCancel(); await c.updateComplete; } - return { roomSettingsOpenThroughMarker: opened }; + if (opened) await window.__hpTest.close(undefined, { via: 'cancel' }); + return { roomSettingsOpenThroughMarker: opened, roomDialogClosed: !c._roomDialog }; }); const inert = await page.evaluate(async ({ core, id }) => { @@ -170,8 +170,6 @@ const inert = await page.evaluate(async ({ core, id }) => { const sr = () => c.shadowRoot || c.renderRoot; await c.updateComplete; out.wallsToolReceivesClickThroughMarker = c._path.length === 1; - c._path = []; c._cursorPt = null; - await c.updateComplete; 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. @@ -200,9 +198,6 @@ const inert = await page.evaluate(async ({ core, id }) => { out.contextMenuNotClaimed = !ctx.defaultPrevented; c.hass.callService = oldCallService; c.hass.callWS = oldCallWS; - c._pointers?.clear?.(); c._panStart = null; c._panLock = null; - c._path = []; c._cursorPt = null; - await c.updateComplete; return out; }, { core: probe.core, id: probe.id }); diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index d4dde20f..544a8531 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -3957,9 +3957,11 @@ const MUTANT_DEFINITIONS = [ }, { id: 'plan-device-landmarks-hidden', - guard: 'node demo/smoke_plan_device_landmarks.mjs', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test test/plan-device-landmarks.test.mjs', because: '#687 AC1: the Plan editor shows the same markers as View as landmarks; hiding ' - + 'them again removes the reference the owner asked for', + + 'them again removes the reference the owner asked for (browser proof: ' + + 'demo/smoke_plan_device_landmarks.mjs; Node witness keeps #659 cap)', patches: [{ file: 'src/styles/plan.styles.ts', find: ' .stage.markup .devlayer .dev {\n' @@ -3972,7 +3974,8 @@ const MUTANT_DEFINITIONS = [ }, { id: 'plan-device-landmarks-override-own-opacity', - guard: 'node demo/smoke_plan_device_landmarks.mjs', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test test/plan-device-landmarks.test.mjs', because: '#687 contract item 2: the fade multiplies the marker\'s own opacity as the ' + 'Background layer does; an opacity override shows an unavailable marker at 35% ' + 'instead of 35% x 35%', @@ -3986,7 +3989,8 @@ const MUTANT_DEFINITIONS = [ }, { id: 'plan-device-landmarks-hit-target', - guard: 'node demo/smoke_plan_device_landmarks.mjs', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test test/plan-device-landmarks.test.mjs', because: '#687 AC2: a visible marker must never own a Plan-editor point; without the ' + 'pointer boundary its 44 px hit area and capsule swallow the Walls tool click and the ' + 'room settings button below', diff --git a/test/plan-device-landmarks.test.mjs b/test/plan-device-landmarks.test.mjs new file mode 100644 index 00000000..97404ecb --- /dev/null +++ b/test/plan-device-landmarks.test.mjs @@ -0,0 +1,81 @@ +// #687: the Plan editor shows device markers as landmarks exactly as the +// Background editor does (#362). The behaviour is proven in a real browser by +// demo/smoke_plan_device_landmarks.mjs; this suite pins the cascade contract +// without Chromium so the mutants that guard it stay off the capped browser +// inventory (#659). Parity of the 35% with the Background editor is a +// computed-style fact and stays with the smoke (#624: no text reads of the +// monolith). +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { planStyles } from '../test-build/styles.js'; + +/** Top-level and @media rules of one stylesheet as [selectors[], decls{}]. */ +const rulesOf = (cssText) => { + const text = cssText.replace(/\/\*[\s\S]*?\*\//g, ''); + const out = []; + const walk = (chunk) => { + let i = 0; + while (i < chunk.length) { + const open = chunk.indexOf('{', i); + if (open === -1) break; + const header = chunk.slice(i, open).replace(/\s+/g, ' ').trim(); + let depth = 1, j = open + 1; + while (j < chunk.length && depth > 0) { + if (chunk[j] === '{') depth++; + else if (chunk[j] === '}') depth--; + j++; + } + const body = chunk.slice(open + 1, j - 1); + if (header.startsWith('@media') || header.startsWith('@supports')) walk(body); + else if (!header.startsWith('@')) { + const decls = {}; + for (const part of body.split(';')) { + const colon = part.indexOf(':'); + if (colon > 0) decls[part.slice(0, colon).trim()] = part.slice(colon + 1).trim(); + } + out.push([header.split(',').map((s) => s.trim()), decls]); + } + i = j; + } + }; + walk(text); + return out; +}; + +const RULES = rulesOf(planStyles.cssText); +const declsFor = (selector) => RULES.filter(([sels]) => sels.includes(selector)).map(([, d]) => d); +const MARKER = '.stage.markup .devlayer .dev'; + +test('#687 the Plan editor no longer hides device markers', () => { + const hiding = RULES.filter(([sels, d]) => d.display === 'none' + && sels.some((s) => s.includes('.stage.markup') && /\.dev\b/.test(s) && !s.includes('.devlayer .dev '))); + assert.deepEqual(hiding.map(([sels]) => sels.join(', ')), []); +}); + +test('#687 the Plan fade multiplies the marker\'s own opacity instead of replacing it', () => { + const decls = declsFor(MARKER); + assert.ok(decls.some((d) => d.filter === 'opacity(0.35)'), `${MARKER} must fade with filter: opacity(0.35)`); + // .dev.unavail and .dev.ghost carry their own opacity; the Background layer + // multiplies it, so the Plan editor must not override the property. + assert.ok(decls.every((d) => !('opacity' in d)), `${MARKER} must not set opacity`); +}); + +test('#687 the whole marker subtree is pointer-inert in the Plan editor, as in Background', () => { + for (const [plan, background] of [ + [MARKER, '.stage.mode-decor .devlayer'], + [`${MARKER} *`, '.stage.mode-decor .devlayer *'], + [`${MARKER}::before`, '.stage.mode-decor .dev::before'], + ]) { + assert.ok(declsFor(plan).some((d) => d['pointer-events'] === 'none'), `${plan} must be pointer-inert`); + assert.ok(declsFor(background).some((d) => d['pointer-events'] === 'none'), `${background} must stay pointer-inert`); + } +}); + +test('#687 the landmark fade is scoped to the Plan editor stage', () => { + // View, the Device editor and the kiosk keep their markers untouched. + const fades = RULES.filter(([sels, d]) => d.filter === 'opacity(0.35)' && sels.some((s) => /\.dev\b/.test(s))); + assert.ok(fades.length > 0); + for (const [sels] of fades) { + assert.ok(sels.every((s) => s.startsWith('.stage.markup ')), `unscoped fade: ${sels.join(', ')}`); + } +});