Files
houseplan-card/demo/smoke_open_passage.mjs
2026-08-26 02:53:19 +03:00

178 lines
7.7 KiB
JavaScript

// #157: open passage is negative architecture across editor, Full/Static and
// light transport. The pure symbol/static/iso guards have dedicated mutants;
// this browser scenario proves the assembled user flow and rendered geometry.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const c = window.__card;
const root = () => c.shadowRoot || c.renderRoot;
const settle = async () => {
c._cfgEpoch++;
c._lightBarrierCache = null;
c.requestUpdate();
await c.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
};
const settleMode = async () => {
const started = performance.now();
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
while (c._modeTransitionBusy && performance.now() - started < 1500);
await settle();
};
const pitch = 1 / 240;
const wallKey = (a, b) => {
const q = (value) => Math.round(value / pitch) * pitch;
let dx = b[0] - a[0]; let dy = b[1] - a[1];
const length = Math.hypot(dx, dy) || 1;
dx /= length; dy /= length;
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) { dx = -dx; dy = -dy; }
let angle = Math.atan2(dy, dx);
if (angle < 0) angle += Math.PI;
angle = Math.round(angle * 1800) / 1800;
return `${q((a[0] + b[0]) / 2).toFixed(6)},${q((a[1] + b[1]) / 2).toFixed(6)}@${angle.toFixed(4)}`;
};
const sp = c._serverCfg.spaces.find((space) => space.id === c._space);
sp.rooms = [
{ id: 'passage-left', name: 'Left', area: 'living',
poly: [[0.1, 0.2], [0.5, 0.2], [0.5, 0.7], [0.1, 0.7]],
settings: { fill_mode: 'none' } },
{ id: 'passage-right', name: 'Right', area: 'kitchen',
poly: [[0.5, 0.2], [0.9, 0.2], [0.9, 0.7], [0.5, 0.7]],
settings: { fill_mode: 'none' } },
];
sp.walls = [{
key: wallKey([0.5, 0.2], [0.5, 0.7]),
a: [0.5, 0.2], b: [0.5, 0.7], cm: 20,
}];
sp.openings = [{
id: 'passage-main', type: 'passage', x: 0.5, y: 0.45,
angle: 90, length: 0.1, future_material: 'stone',
}];
sp.settings = { ...(sp.settings || {}), show_borders: true, hide_openings: false,
fill_mode: 'glow', glow_enabled: true };
c._serverCfg.settings = {
...(c._serverCfg.settings || {}), glow_radius_cm: 800,
};
const light = c._devices.find((device) => device.space === c._space
&& device.entities.some((entity) => entity.startsWith('light.') && c.hass.states[entity]?.state === 'on'));
if (light) c._layout = { ...c._layout, [light.id]: { s: c._space, x: 0.3, y: 0.45 } };
const litRings = () => [...root().querySelectorAll('defs clipPath[id^="hp-glowclip"] path.glow-lit')]
.flatMap((path) => (path.getAttribute('d') || '').split('M').filter(Boolean).map((subpath) => {
const values = (subpath.match(/-?\d+(?:\.\d+)?/g) || []).map(Number);
const ring = [];
for (let index = 0; index + 1 < values.length; index += 2) ring.push([values[index], values[index + 1]]);
return ring;
}));
const isLit = (point) => litRings().some((ring) => {
let inside = false;
for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index++) {
const [x1, y1] = ring[index]; const [x2, y2] = ring[previous];
if ((y1 > point[1]) !== (y2 > point[1])
&& point[0] < ((x2 - x1) * (point[1] - y1)) / ((y2 - y1) || 1e-12) + x1) inside = !inside;
}
return inside;
});
c._setMode('view');
await settleMode();
const result = {};
result.interiorPassageTransmitsLight = !!light && isLit([650, 450]);
result.wallOutsidePassageStaysDark = !!light && !isLit([650, 260]);
sp.openings[0].type = 'future-opening';
await settle();
await new Promise((resolve) => setTimeout(resolve, 650));
await settle();
result.unknownOpeningFailsDark = !isLit([650, 450]);
sp.openings[0].type = 'passage';
await settle();
c._setMode('plan');
await settleMode();
c._activateOpeningPlacement('passage');
await settle();
result.passagePresetIs90cm = c._openingPreset?.type === 'passage'
&& c._openingPreset?.lengthCm === 90;
const committed = root().querySelector('.opening[data-kind="passage"]');
result.committedHasHitboxButNoSymbol = !!committed?.querySelector('.op-hit')
&& committed.querySelectorAll('.op-leaf,.op-arc,.op-glass,line').length === 0;
// Cancel is a pure draft operation, including stale values hidden by the
// passage radio. Save canonicalises those known fields but keeps extensions.
Object.assign(sp.openings[0], {
type: 'door', contact: 'binary_sensor.window', lock: 'lock.front_door',
invert: true, flip_h: true, flip_v: true,
});
await settle();
c._editOpening(c._openingsR.find((opening) => opening.id === 'passage-main'));
c._openingDialog = { ...c._openingDialog, type: 'passage' };
await settle();
const beforeCancel = JSON.stringify(sp.openings[0]);
result.warningIsVisibleAndAccessible = !!root().querySelector(
'.habindingbanner[role="status"][aria-live="polite"]',
);
result.passageHidesBindingAndFlipControls = root().querySelectorAll(
'hp-dialog .body select, hp-dialog .body input[type="checkbox"]',
).length === 0;
c._openingDialog = null;
await settle();
result.cancelKeepsOriginalOpening = JSON.stringify(sp.openings[0]) === beforeCancel;
c._editOpening(c._openingsR.find((opening) => opening.id === 'passage-main'));
c._openingDialog = { ...c._openingDialog, type: 'passage' };
c._saveOpening();
await settle();
const saved = sp.openings.find((opening) => opening.id === 'passage-main');
result.saveIsCanonicalAndLossless = saved?.type === 'passage'
&& saved.future_material === 'stone'
&& ['contact', 'lock', 'invert', 'flip_h', 'flip_v']
.every((field) => !Object.prototype.hasOwnProperty.call(saved, field));
result.noPassageLockBadge = root().querySelectorAll('.oplock').length === 0;
c._openingInfo = c._openingsR.find((opening) => opening.id === 'passage-main');
await settle();
result.stalePassageInfoIsInert = root().querySelectorAll('.oprow,.lockact').length === 0;
c._openingInfo = null;
// Exercise the actual static custom element with the same immutable snapshot.
const staticCard = document.createElement('houseplan-space-card');
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: c._space, show_button: false });
staticCard.hass = c.hass;
document.body.append(staticCard);
await staticCard.updateComplete;
const baseSnap = staticCard._snap || {};
staticCard._snap = {
...baseSnap,
config: c._serverCfg,
rev: 157,
configFingerprint: 'open-passage-smoke',
layout: c._layout,
layoutRev: 157,
layoutFingerprint: 'open-passage-layout',
virtualLights: baseSnap.virtualLights || c._virtualLights,
};
staticCard._refreshDevices();
staticCard.requestUpdate();
await staticCard.updateComplete;
await new Promise((resolve) => setTimeout(resolve, 350));
staticCard.requestUpdate();
await staticCard.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const staticRoot = staticCard.shadowRoot || staticCard.renderRoot;
const staticWallBodyPaths = [...staticRoot.querySelectorAll('.wallbodies path')];
const passagePoint = new DOMPoint(500, 450);
const solidWallPoint = new DOMPoint(500, 260);
result.staticCutsAndFillsPassage = staticWallBodyPaths.length > 0
&& staticWallBodyPaths.every((path) => !path.isPointInFill(passagePoint))
&& staticWallBodyPaths.some((path) => path.isPointInFill(solidWallPoint))
&& !!staticRoot.querySelector('.static-opening-tunnels [data-kind="passage"]');
result.staticDoesNotInventPassageSymbol = staticRoot.querySelectorAll('[data-hp="opening"]').length === 0;
staticCard.remove();
return result;
});
checkAll(out);
await finish(browser, out);