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
This commit is contained in:
Claude
2026-09-28 09:59:52 +03:00
parent 560849a1a7
commit 128af2392a
3 changed files with 94 additions and 14 deletions
+5 -10
View File
@@ -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 });
+8 -4
View File
@@ -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',
+81
View File
@@ -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(', ')}`);
}
});