Files
houseplan-card/demo/smoke_glow.mjs
T
Claudeandclaude[bot] fa319e37b3 test(harness): тестовый фасад window.__hpTest и гейт no-new-private-writes (#629)
- scripts/no-new-private-writes.mjs: смоки и demo/helpers/** не добавляют
  записей в приватное состояние карточки (присваивание, ++/--, delete по
  цепочке с сегментом _x; от this — нет) и вызовов _setMode/_openRoomEdit/
  _openMarkerDialog/_openSpaceDialog. Зачёт правки по полю, перенос блока —
  movedLinesByFile из no-new-any; исключение // private-ok: <причина>.
  --count — остаток на HEAD. Подключён в gate:small и в шаг frontend рядом с
  no-new-any, с той же базой.
- demo/helpers/hp-test.mjs: 10 операций через контрактные хуки и события
  фикстуры (setMode, setTool, switchSpace, openRoomEdit, openMarkerDialog,
  openSpaceDialog, setServerConfig, setLayout, input, close); ставится
  launch*() из demo/serve.mjs. В бандле фасада нет.
- demo/srv/demo.html: доставка houseplan_config_updated/_layout_updated,
  __pushServerConfig/__pushServerLayout; после доставки запись со старым
  expected_rev — conflict, как у настоящего сервера.
- HP_SMOKE_CHECKS=1 печатает имена проверок в finish().
- smoke_area_relocation, smoke_glow, smoke_grid_snap переведены на фасад без
  потери утверждений; новый smoke_test_facade доказывает каждую операцию.
- 7 мутантов, docs/TESTING.md (раздел + правило №6), PROCESS.md §2.7, AGENTS.md.

Issue: #629
User-Visible: no
2026-09-25 01:25:20 +00:00

703 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
// #629: план меняется так, как его меняет другой клиент HA, — событием
// сервера через фасад, а не присваиванием приватного состояния карточки.
const hp = window.__hpTest;
const sr = () => c.shadowRoot || c.renderRoot;
const spId = c._space;
const inSpace = (cfg, patch) => ({ ...cfg, spaces: cfg.spaces.map((s) => s.id !== spId ? s : patch(s)) });
// включить glow-режим
await hp.setServerConfig((cfg) => inSpace(cfg, (s) => ({
...s, settings: { ...(s.settings || {}), fill_mode: 'glow' } })));
await new Promise((r) => setTimeout(r, 250));
// 1) legacy glow переносится в отдельный base-overlay, не в data-fill комнат
const modelRooms = c._spaceModel().rooms.length;
out.dataFillSeparated = sr().querySelectorAll('.room.filled').length === 0;
out.glowBaseRooms = sr().querySelectorAll('.glow-base-layer .glow-base').length === modelRooms;
// 2) пятна от включённых ламп
const litLight = c._devices.find((d) => d.space === spId && d.entities.some((e) => e.startsWith('light.') && c.hass.states[e]?.state === 'on'));
out.hasLitLight = !!litLight;
out.spots = sr().querySelectorAll('.glowlayer circle').length;
out.spotsMatchLights = out.spots === c._devices.filter((d) => d.space === spId && d.entities.some((e) => e.startsWith('light.') && c.hass.states[e]?.state === 'on')).length;
// 3) выключение лампы плавно гасит пятно и удаляет его после 500 мс
const eid = litLight.entities.find((e) => e.startsWith('light.') && c.hass.states[e]?.state === 'on');
const st0 = c.hass.states[eid];
c.hass = { ...c.hass, states: { ...c.hass.states, [eid]: { ...st0, state: 'off' } } };
await c.updateComplete;
const leavingSpot = sr().querySelector('.glow-spot.is-leaving');
out.spotFadesOut = !!leavingSpot
&& getComputedStyle(leavingSpot).transitionDuration.split(',').some((v) => v.trim() === '0.5s');
await new Promise((r) => setTimeout(r, 550));
out.spotGone = sr().querySelectorAll('.glowlayer circle').length === out.spots - 1;
c.hass = { ...c.hass, states: { ...c.hass.states, [eid]: st0 } }; await c.updateComplete;
const returningSpot = [...sr().querySelectorAll('.glow-spot')]
.find((node) => node.dataset.glowSource === eid);
out.spotFadesIn = !!returningSpot
&& !returningSpot.classList.contains('is-leaving')
&& getComputedStyle(returningSpot).transitionDuration.split(',').some((v) => v.trim() === '0.5s');
// 4) rgb-лампа красит градиент своим цветом (форсируем rgb у лампы)
c.hass = { ...c.hass, states: { ...c.hass.states, [eid]: { ...st0, state: 'on', attributes: { ...st0.attributes, rgb_color: [255, 0, 0] } } } };
await c.updateComplete;
out.gradientColored = [...sr().querySelectorAll('defs radialGradient stop')].some((s2) => s2.getAttribute('stop-color') === '#ff0000');
c.hass = { ...c.hass, states: { ...c.hass.states, [eid]: st0 } }; await c.updateComplete;
// 5) пятно обрезано комнатой (есть clipPath)
out.clipped = sr().querySelectorAll('defs clipPath[id^="hp-glowclip"]').length > 0;
// 6) дверь в соседнюю комнату → в clip добавлен сектор (2 сабпути M)
// добавим дверь между r1 и r2 на общей стене x=0.55… найдём общую стену программно:
const spm = c._spaceModel();
const r1 = spm.rooms.find((r) => r.id === 'r1');
const r2 = spm.rooms.find((r) => r.id === 'r2');
// возьмём точку на границе r1, ближайшую к центру r2
const c2 = c._roomCenter(r2);
const poly1 = r1.poly || [[r1.x, r1.y], [r1.x + r1.w, r1.y], [r1.x + r1.w, r1.y + r1.h], [r1.x, r1.y + r1.h]];
// общая стена вертикальная — дверь ставим на неё
const H = 1000; // square canvas
const doorPt = (() => {
let best = null, bd = 1e9;
for (const [x, y] of [[550, 150], [550, 200], [550, 250]]) {
const d2 = Math.hypot(x - c2[0], y - c2[1]);
if (d2 < bd) { bd = d2; best = [x, y]; }
}
return best;
})();
await hp.setServerConfig((cfg) => inSpace(cfg, (s) => ({
...s, openings: [{ id: 'gd', type: 'door', x: doorPt[0] / 1000, y: doorPt[1] / H, angle: 90, length: 0.09 }] })));
// источник детерминированно ставим в центр r1 (двигаем реальную включённую лампу)
const c1 = c._roomCenter(r1);
await hp.setLayout((layout) => ({ ...layout, [litLight.id]: { s: spId, x: c1[0] / 1000, y: c1[1] / 1000 } }));
// Give the shadow assertion a real physical body. The original fixture has
// no walls/partitions/columns, so no shadow mask can legitimately exist.
const shadowCenter = [
c1[0] + (doorPt[0] - c1[0]) * 0.35,
c1[1] + (doorPt[1] - c1[1]) * 0.35,
];
// радиус 6 м, чтобы дверь заведомо была в зоне досягаемости
await hp.setServerConfig((cfg) => ({
...inSpace(cfg, (s) => ({
...s,
wall_columns: [
...(s.wall_columns || []).filter((column) => column.id !== 'glow-shadow-column'),
{
id: 'glow-shadow-column', shape: 'circle', cm: 30,
center: [shadowCenter[0] / 1000, shadowCenter[1] / H],
},
],
})),
settings: { ...(cfg.settings || {}), glow_radius_cm: 600 },
}));
// One source, one shape: the floor this lamp can see. A doorway, the room
// behind it and the shadow of a column are the same region, so they can never
// disagree — which is what every earlier bug in this area was made of.
const litClips = [...sr().querySelectorAll('defs clipPath[id^="hp-glowclip"]')];
const pool = sr().querySelector('.glow-pool');
out.poolLimitedToVisibleFloor = !!pool
&& litClips.length > 0
&& litClips.every((cp) => cp.querySelectorAll('path.glow-lit').length === 1)
&& !!pool.getAttribute('clip-path');
// A spot paints one primitive through one region. A second layer (a spill
// path, an ambient wash, a shadow mask of its own) is exactly how "the
// attribute is there but nothing renders" became possible.
out.singleLayerPerSource = [...sr().querySelectorAll('.glow-spot')].every((spot) => (
spot.children.length === 1 && spot.firstElementChild?.classList.contains('glow-pool')
));
// Exactly one blur in the light layer, and only as the penumbra of that one
// region: a hair on screen, never a soft sector pasted over the plan.
const glowFilters = [...sr().querySelectorAll('defs filter[id^="hp-glow"]')];
const pools = sr().querySelector('.glow-pools');
const frameFilter = sr().querySelector('.glow-pools-frame')?.getAttribute('filter');
const featherTemporarilySuspended = frameFilter === null
&& Date.now() < Number(c._glowFeatherSuspendUntil || 0);
out.edgeFeatherIsAHair = glowFilters.length === 1
&& glowFilters[0].querySelectorAll('feGaussianBlur').length === 1
&& (frameFilter === 'url(#hp-glowfeather)' || featherTemporarilySuspended)
&& Number(pools?.dataset.featherPx) > 0 && Number(pools?.dataset.featherPx) <= 3
&& [...sr().querySelectorAll('.glowlayer [mask], .glow-pools [filter]')].length === 0;
// Дверь наружу: свет через неё не выходит — есть проём, но за ним нет пола.
const minX = Math.min(...poly1.map((p) => p[0]));
const yMid = (Math.min(...poly1.map((p) => p[1])) + Math.max(...poly1.map((p) => p[1]))) / 2;
await hp.setServerConfig((cfg) => inSpace(cfg, (s) => ({
...s, openings: [{ id: 'gd2', type: 'door', x: minX / 1000, y: yMid / 1000, angle: 90, length: 0.09 }] })));
const roomsBox = (() => {
const xs = [], ys = [];
for (const room of c._spaceModel().rooms) {
for (const point of (c._roomPolyOf?.(room) || room.poly
|| [[room.x, room.y], [room.x + room.w, room.y + room.h]])) {
xs.push(point[0]); ys.push(point[1]);
}
}
return { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys) };
})();
const clipNumbers = [...sr().querySelectorAll('defs clipPath[id^="hp-glowclip"] path.glow-lit')]
.flatMap((p) => (p.getAttribute('d').match(/-?\d+(?:\.\d+)?/g) || []).map(Number));
const slack = c._cmToUnits(20);
out.entranceKeepsLightIndoors = clipNumbers.length > 0
&& clipNumbers.every((value, index) => (index % 2 === 0
? value >= roomsBox.minX - slack && value <= roomsBox.maxX + slack
: value >= roomsBox.minY - slack && value <= roomsBox.maxY + slack));
// Nothing behind it to light, so for light a front door is masonry like a
// window: it must not change the lit region at all. Half a lit tunnel — up
// to the centreline, where the room polygon ends — is the failure mode.
const litPath = () => [...sr().querySelectorAll('defs clipPath[id^="hp-glowclip"] path.glow-lit')]
.map((p) => p.getAttribute('d')).join('|');
const withEntrance = litPath();
await hp.setServerConfig((cfg) => inSpace(cfg, (s) => ({ ...s, openings: [] })));
out.entranceChangesNothing = withEntrance.length > 0 && litPath() === withEntrance;
// 6б) профиль градиента: монотонное затухание по всему радиусу. Плато до
// 70% превращало любую обрезанную форму в плашку сплошного цвета с каймой —
// именно так сектор в соседней комнате и читался как «залито непонятно чем».
const gradient = [...sr().querySelectorAll('defs radialGradient')][0];
const offs = gradient
? [...gradient.querySelectorAll('stop')].map((st2) => [
Number(String(st2.getAttribute('offset')).replace('%', '')),
Number(st2.getAttribute('stop-opacity')),
])
: [];
const at = (percent) => {
for (let i = 1; i < offs.length; i++) {
if (offs[i][0] >= percent) {
const span = offs[i][0] - offs[i - 1][0] || 1;
const k = (percent - offs[i - 1][0]) / span;
return offs[i - 1][1] + (offs[i][1] - offs[i - 1][1]) * k;
}
}
return offs.length ? offs[offs.length - 1][1] : 0;
};
out.falloffMonotonic = offs.length >= 4
&& offs[0][0] === 0 && offs[0][1] > 0
&& offs[offs.length - 1][0] === 100 && offs[offs.length - 1][1] === 0
&& offs.every((stop, i) => i === 0 || (stop[0] > offs[i - 1][0] && stop[1] < offs[i - 1][1]))
&& at(70) <= offs[0][1] * 0.75;
// Tri-state role is live: Auto -> Never -> Auto removes and restores only
// this marker's own spatial pool.
const litMarkerId = litLight.id;
const litBinding = litLight.bindingKind === 'virtual'
? 'virtual'
: litLight.bindingKind + ':' + litLight.bindingRef;
const autoSpotCount = sr().querySelectorAll('.glowlayer circle').length;
const withoutMarker = (cfg, id) => ({ ...cfg, markers: (cfg.markers || []).filter((m) => m.id !== id) });
const withMarker = (cfg, marker) => ({ ...cfg, markers: [...withoutMarker(cfg, marker.id).markers, marker] });
await hp.setServerConfig((cfg) => withMarker(cfg, { id: litMarkerId, binding: litBinding, is_light: false }));
out.roleNeverHidesOwnPool = sr().querySelectorAll('.glowlayer circle').length === autoSpotCount - 1;
await hp.setServerConfig((cfg) => withoutMarker(cfg, litMarkerId));
out.roleAutoRestoresOwnPool = sr().querySelectorAll('.glowlayer circle').length === autoSpotCount;
// 7а) персональный радиус источника перекрывает глобальный
await hp.setServerConfig((cfg) => withMarker(cfg, {
id: litMarkerId,
binding: litBinding,
glow_radius_cm: 150,
glow_color: { c: '#123456', bri: 0.25 },
}));
const rOwn = Number(sr().querySelector('.glowlayer circle')?.getAttribute('r'));
out.perSourceRadius = Math.abs(rOwn - c._cmToUnits(150)) < 0.5;
const ownStop = sr().querySelector('defs radialGradient stop');
out.perSourceAppearance = ownStop?.getAttribute('stop-color') === '#123456'
&& Math.abs(Number(ownStop.getAttribute('stop-opacity')) - 0.428) < 0.002
&& !sr().querySelector('.glow-pools-frame')?.hasAttribute('opacity');
await hp.setServerConfig((cfg) => withoutMarker(cfg, litMarkerId));
// 6в) флаг «источник света»: умный выключатель с обычными светильниками
const swDev = c._devices.find((d) => d.space === spId && d.entities.some((e) => e.startsWith('switch.')));
if (swDev) {
const swEid = swDev.entities.find((e) => e.startsWith('switch.'));
c.hass = { ...c.hass, states: { ...c.hass.states, [swEid]: { ...c.hass.states[swEid], state: 'on' } } };
const spotsBefore = sr().querySelectorAll('.glowlayer circle').length;
await hp.setServerConfig((cfg) => withMarker(cfg, {
id: swDev.id, binding: swDev.bindingKind + ':' + swDev.bindingRef, is_light: true,
}));
out.switchGlows = sr().querySelectorAll('.glowlayer circle').length === spotsBefore + 1;
await hp.setServerConfig((cfg) => withoutMarker(cfg, swDev.id));
out.switchGlowsOffByDefault = sr().querySelectorAll('.glowlayer circle').length === spotsBefore;
} else { out.switchGlows = 'no-switch'; out.switchGlowsOffByDefault = 'no-switch'; }
// 7) радиус из настроек: 600 см против 300 см — вдвое больше
const r600 = Number(sr().querySelector('.glowlayer circle')?.getAttribute('r'));
await hp.setServerConfig((cfg) => ({ ...cfg, settings: { ...(cfg.settings || {}), glow_radius_cm: 300 } }));
const r300 = Number(sr().querySelector('.glowlayer circle')?.getAttribute('r'));
out.radiusReacts = Math.abs(r600 / r300 - 2) < 0.01;
// Hover must not promote a filtered SVG sibling and flash the screen-blended
// Glow layer. The pool/gradient nodes stay alive and hover uses plain SVG.
const poolBeforeHover = sr().querySelector('.glow-pool');
const gradientBeforeHover = sr().querySelector('radialGradient[id^="hp-glow-"]');
const roomEl = sr().querySelector('.room');
roomEl?.dispatchEvent(new PointerEvent('pointerenter', { pointerType: 'mouse' }));
await c.updateComplete;
const hoverFill = sr().querySelector('.room-hover-fill');
const hoverFillLayer = sr().querySelector('.room-hover-fill-layer');
const hoverHalo = sr().querySelector('.room-hover-halo');
const hoverOutline = sr().querySelector('.room-hover-outline');
const hoverOutlineLayer = sr().querySelector('.room-hover-outline-layer');
const decorLayer = sr().querySelector('.decorlayer');
const glowLayer = sr().querySelector('.glow-pools-frame');
const wallLayer = sr().querySelector('.wallbodies');
out.hoverKeepsGlowDom = poolBeforeHover === sr().querySelector('.glow-pool')
&& gradientBeforeHover === sr().querySelector('radialGradient[id^="hp-glow-"]');
out.hoverUsesPlainSvg = !!roomEl && !!hoverFill && !!hoverHalo && !!hoverOutline
&& getComputedStyle(roomEl).filter === 'none'
&& getComputedStyle(hoverHalo).filter === 'none'
&& getComputedStyle(hoverOutline).filter === 'none';
const fillStyle = hoverFill ? getComputedStyle(hoverFill) : null;
out.hoverUsesNeutralDarkening = fillStyle?.fill === 'rgb(0, 0, 0)'
&& Math.abs(Number(fillStyle.fillOpacity) - 0.22) < 0.001;
out.hoverLayerOrder = !!hoverFillLayer && !!decorLayer && !!glowLayer && !!hoverOutlineLayer
&& !!(hoverFillLayer.compareDocumentPosition(decorLayer) & Node.DOCUMENT_POSITION_FOLLOWING)
&& !!(decorLayer.compareDocumentPosition(glowLayer) & Node.DOCUMENT_POSITION_FOLLOWING)
// The default smoke fixture has no thick wall body. When one exists, the
// late outline must follow it; absence is not a layer-order failure.
&& (!wallLayer
|| !!(wallLayer.compareDocumentPosition(hoverOutlineLayer) & Node.DOCUMENT_POSITION_FOLLOWING));
roomEl?.dispatchEvent(new PointerEvent('pointerleave', { pointerType: 'mouse' }));
await c.updateComplete;
return out;
});
// A deterministic opaque-floor scene guards the two bugs which survived the
// old DOM-only assertions: separate physical shadows and visible spill at
// every valid doorway. It intentionally uses thick walls and two receiving
// rooms, then compares actual stage pixels with the mask/light toggled.
const rasterFixture = await page.evaluate(async () => {
const c = window.__card;
const root = c.shadowRoot || c.renderRoot;
const spaceId = c._space;
const source = c._devices.find((device) => device.id === 'd_light1');
const sourceEid = source?.entities.find((eid) => eid.startsWith('light.'));
const current = c._serverCfg.spaces.find((space) => space.id === spaceId);
if (!source || !sourceEid || !current) return { ready: false };
const edgeKey = (a, b) => `${a.join(',')}/${b.join(',')}`;
const wallKey = (a, b) => {
const pitch = 1 / 240;
const q = (value) => Math.round(value / pitch) * pitch;
let dx = b[0] - a[0], 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;
const angleBucket = Math.round(angle * 1800) / 1800;
return `${q((a[0] + b[0]) / 2).toFixed(6)},${q((a[1] + b[1]) / 2).toFixed(6)}@${angleBucket.toFixed(4)}`;
};
const edges = new Map();
for (const room of current.rooms || []) {
for (let index = 0; index < room.poly.length; index++) {
const a = room.poly[index];
const b = room.poly[(index + 1) % room.poly.length];
const reverse = edgeKey(b, a);
if (!edges.has(reverse) && !edges.has(edgeKey(a, b))) edges.set(edgeKey(a, b), { a, b });
}
}
const walls = [...edges.values()].map(({ a, b }) => ({
key: wallKey(a, b), a, b, cm: 15,
}));
const dynamicContact = 'cover.glow_dynamic_door';
const openings = [
{
id: 'glow-raster-east', type: 'door', x: 0.55, y: 0.32,
angle: 90, length: 0.09, contact: dynamicContact,
},
{ id: 'glow-raster-south', type: 'door', x: 0.32, y: 0.58, angle: 0, length: 0.09 },
];
const wallSource = [...edges.values()]
.map(({ a, b }) => ({
point: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2],
clearance: Math.min(...openings.map((opening) => Math.hypot(
(a[0] + b[0]) / 2 - opening.x, (a[1] + b[1]) / 2 - opening.y,
))),
}))
.sort((left, right) => right.clearance - left.clearance)[0]?.point;
await window.__hpTest.setServerConfig((cfg) => ({
...cfg,
settings: { ...(cfg.settings || {}), glow_radius_cm: 600 },
markers: (cfg.markers || []).filter((marker) => marker.id !== source.id),
spaces: cfg.spaces.map((space) => space.id !== spaceId ? space : ({
...space,
settings: {
...(space.settings || {}), fill_mode: 'custom',
custom_fill: { c: '#3f4854', a: 1 }, glow_enabled: true, sun_rays: false,
},
walls,
openings,
open_spans: [],
wall_columns: [{
id: 'glow-raster-column', shape: 'circle', cm: 60, center: [0.18, 0.30],
}],
partitions: [],
})),
}));
await window.__hpTest.setLayout((layout) => ({ ...layout, [source.id]: { s: spaceId, x: 0.295, y: 0.36 } }));
const states = { ...c.hass.states };
for (const [eid, state] of Object.entries(states)) {
if (eid.startsWith('light.')) states[eid] = { ...state, state: 'off' };
}
states[sourceEid] = {
...states[sourceEid], state: 'on',
attributes: { ...(states[sourceEid]?.attributes || {}), brightness: 255, rgb_color: [255, 196, 112] },
};
states[dynamicContact] = {
entity_id: dynamicContact,
state: 'open',
attributes: { current_position: 100, friendly_name: 'Glow dynamic door' },
last_changed: new Date().toISOString(),
last_updated: new Date().toISOString(),
context: { id: 'glow-dynamic-door', parent_id: null, user_id: null },
};
c.hass = { ...c.hass, states };
await c.updateComplete;
await new Promise((resolve) => setTimeout(resolve, 600));
const freeze = document.createElement('style');
freeze.dataset.glowSmokeFreeze = 'true';
freeze.textContent = '*,*::before,*::after{animation:none!important;transition:none!important}';
root.querySelector('[data-glow-smoke-freeze]')?.remove();
root.append(freeze);
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const pools = [...root.querySelectorAll('.glow-pool[clip-path]')];
const litParts = pools.length > 0 && pools.every((node) => {
const clipId = node.getAttribute('clip-path')?.match(/^url\(#(.+)\)$/)?.[1];
const path = clipId ? root.getElementById(clipId)?.querySelector('path.glow-lit') : null;
// Every lit part is a subpath of the one clipped shape.
return !!path && (path.getAttribute('d').match(/M/g) || []).length
>= Number(node.dataset.litParts);
});
const stage = root.querySelector('.stage');
const svg = stage?.querySelector('svg');
const matrix = svg?.getScreenCTM();
if (!pools.length || !stage || !svg || !matrix) {
return { ready: false, litParts, sourceEid };
}
const stageRect = stage.getBoundingClientRect();
const toStage = (point) => {
const mapped = new DOMPoint(point[0], point[1]).matrixTransform(matrix);
return [mapped.x - stageRect.left, mapped.y - stageRect.top];
};
const sourcePos = c._pos(source);
const openingById = new Map(c._openingsR.map((opening) => [opening.id, opening]));
const wallDepth = c._cmToUnits(15);
const doors = openings.map((stored) => {
const opening = openingById.get(stored.id);
if (!opening) return null;
const radians = opening.angle * Math.PI / 180;
const tangent = [Math.cos(radians), Math.sin(radians)];
const normal = [-tangent[1], tangent[0]];
const sourceSide = ((sourcePos.x - opening.rx) * normal[0]
+ (sourcePos.y - opening.ry) * normal[1]) >= 0 ? 1 : -1;
const outward = [-normal[0] * sourceSide, -normal[1] * sourceSide];
const after = wallDepth / 2 + Math.max(6, c._gridPitch * 0.6);
const sampleCenter = [opening.rx + outward[0] * after, opening.ry + outward[1] * after];
const scan = [];
for (let offset = -opening.rlen * 2.5; offset <= opening.rlen * 2.5; offset += 2) {
scan.push({
offset,
point: toStage([
sampleCenter[0] + tangent[0] * offset,
sampleCenter[1] + tangent[1] * offset,
]),
});
}
// Inside the aperture itself. An unlit bar here is what disconnected the
// pool from its own beam and made a doorway read as a red plug (#71).
const tunnel = [-0.3, -0.15, 0, 0.15, 0.3].map((share) => toStage([
opening.rx + tangent[0] * opening.rlen * share,
opening.ry + tangent[1] * opening.rlen * share,
]));
return {
id: opening.id,
openingLength: opening.rlen,
sample: toStage(sampleCenter),
tunnel,
scan,
};
}).filter(Boolean);
// The column: behind it there must be no light, beside it there must be, and
// the border between the two must be a line — not a 45 cm gradient.
const column = (c._curSpaceCfg.wall_columns || [])[0];
let shadow = null;
if (column) {
const centre = [column.center[0] * 1000, column.center[1] * c._spaceH];
const radius = c._cmToUnits(column.cm) / 2;
const away = Math.hypot(centre[0] - sourcePos.x, centre[1] - sourcePos.y);
const base = Math.atan2(centre[1] - sourcePos.y, centre[0] - sourcePos.x);
const half = Math.asin(Math.max(0.02, Math.min(0.9, radius / away)));
const atPlan = (angle, distance) => [
sourcePos.x + Math.cos(angle) * distance,
sourcePos.y + Math.sin(angle) * distance,
];
const far = away + radius + Math.max(10, c._gridPitch);
const insidePlan = [atPlan(base, far), atPlan(base, away + radius * 2.2)];
const besidePlan = [atPlan(base + half * 4, far), atPlan(base - half * 4, far)];
const edgePlan = Array.from({ length: 41 }, (_, index) => {
const offset = (index - 20) * 1.0;
const angle = base + half + Math.atan2(offset, far);
return { offset, point: atPlan(angle, far) };
});
const distanceToSegment = (point, a, b) => {
const dx = b[0] - a[0], dy = b[1] - a[1];
const length2 = dx * dx + dy * dy;
const share = length2 > 0
? Math.max(0, Math.min(1, ((point[0] - a[0]) * dx + (point[1] - a[1]) * dy) / length2))
: 0;
return Math.hypot(point[0] - (a[0] + dx * share), point[1] - (a[1] + dy * share));
};
const rooms = c._spaceModel().rooms;
const renderedWalls = c._spaceWalls || [];
const sampleClearance = Math.max(2, c._gridPitch * 0.5);
const liesOnCleanFloor = (point) => {
const insideRoom = rooms.some((room) => c._pointInRoom(point, room));
const outsideColumn = Math.hypot(point[0] - centre[0], point[1] - centre[1])
>= radius + sampleClearance;
const outsideWalls = renderedWalls.every((wall) =>
distanceToSegment(point, wall.a, wall.b) >= wallDepth / 2 + sampleClearance);
return insideRoom && outsideColumn && outsideWalls;
};
const samplePoints = [...insidePlan, ...besidePlan, ...edgePlan.map((sample) => sample.point)];
shadow = {
inside: insidePlan.map(toStage),
beside: besidePlan.map(toStage),
samplesAreValid: samplePoints.every(liesOnCleanFloor),
// Across the shadow edge, one plan unit at a time.
edge: edgePlan.map(({ offset, point }) => ({ offset, point: toStage(point) })),
};
}
return {
ready: true, litParts, sourceEid, sourceId: source.id, wallSource,
dynamicContact, doors, shadow,
};
});
if (rasterFixture.ready) {
const stage = page.locator('houseplan-card').locator('.stage').first();
const lightOn = await stage.screenshot({ animations: 'disabled' });
await page.evaluate(async (sourceEid) => {
const c = window.__card;
const state = c.hass.states[sourceEid];
c.hass = {
...c.hass,
states: { ...c.hass.states, [sourceEid]: { ...state, state: 'off' } },
};
await c.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
}, rasterFixture.sourceEid);
const lightOff = await stage.screenshot({ animations: 'disabled' });
const metrics = await page.evaluate(async ({ on64, off64, doors, shadow }) => {
const decode = async (base64) => {
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
return createImageBitmap(new Blob([bytes], { type: 'image/png' }));
};
const images = await Promise.all([decode(on64), decode(off64)]);
const width = images[0].width;
const height = images[0].height;
if (images.some((image) => image.width !== width || image.height !== height)) {
return { dimensionsMatch: false, shadow: null, doors: [] };
}
const data = images.map((image) => {
const canvas = document.createElement('canvas');
canvas.width = width; canvas.height = height;
const context = canvas.getContext('2d', { willReadFrequently: true });
context.drawImage(image, 0, 0);
return context.getImageData(0, 0, width, height).data;
});
const [lit, off] = data;
const lumaAt = (pixels, x, y) => {
const px = Math.max(0, Math.min(width - 1, Math.round(x)));
const py = Math.max(0, Math.min(height - 1, Math.round(y)));
const offset = (py * width + px) * 4;
return pixels[offset] * 0.2126 + pixels[offset + 1] * 0.7152 + pixels[offset + 2] * 0.0722;
};
const meanLuma = (pixels, point, radius = 2) => {
let total = 0; let count = 0;
for (let y = point[1] - radius; y <= point[1] + radius; y++) {
for (let x = point[0] - radius; x <= point[0] + radius; x++) {
total += lumaAt(pixels, x, y); count++;
}
}
return total / count;
};
const delta = (point, radius = 2) => meanLuma(lit, point, radius) - meanLuma(off, point, radius);
const doorMetrics = doors.map((door) => {
const beyond = door.scan.filter(({ point }) => delta(point, 1) >= 3);
const visibleWidth = beyond.length
? beyond[beyond.length - 1].offset - beyond[0].offset : 0;
// Median, not mean: the door symbol itself draws a dark line across the
// aperture and would otherwise decide the verdict.
const tunnelDeltas = door.tunnel
.map((point) => delta(point, 1))
.sort((left, right) => left - right);
return {
id: door.id, delta: delta(door.sample), visibleWidth,
openingLength: door.openingLength,
tunnelDelta: tunnelDeltas[Math.floor(tunnelDeltas.length / 2)],
};
});
let shadowMetrics = null;
if (shadow) {
const inside = Math.max(...shadow.inside.map((point) => delta(point, 1)));
const beside = Math.min(...shadow.beside.map((point) => delta(point, 1)));
// Width of the lit → unlit transition, in stage pixels. A geometric edge
// crosses it in one or two antialiased pixels; a Gaussian took tens.
const profile = shadow.edge.map(({ offset, point }) => ({ offset, value: delta(point, 0) }));
const high = Math.max(...profile.map((sample) => sample.value));
const low = Math.min(...profile.map((sample) => sample.value));
const band = profile.filter((sample) =>
sample.value > low + (high - low) * 0.2 && sample.value < low + (high - low) * 0.8);
shadowMetrics = {
inside, beside, high, low,
edgeWidth: band.length
? Math.abs(band[band.length - 1].offset - band[0].offset) : 0,
};
}
return { dimensionsMatch: true, shadow: shadowMetrics, doors: doorMetrics };
}, {
on64: lightOn.toString('base64'),
off64: lightOff.toString('base64'),
doors: rasterFixture.doors,
shadow: rasterFixture.shadow,
});
console.log('Glow raster metrics:', JSON.stringify(metrics));
res.litPartsMatchClip = rasterFixture.litParts;
res.doorwayCarriesLight = metrics.doors.length === 2
&& metrics.doors.every((door) => door.delta >= 8);
res.beamWidthBounded = metrics.doors.length === 2
&& metrics.doors.every((door) => door.visibleWidth <= door.openingLength * 2);
res.apertureItselfLit = metrics.doors.length === 2
&& metrics.doors.every((door) => door.tunnelDelta >= 8);
res.shadowSamplesAreValid = rasterFixture.shadow?.samplesAreValid === true;
// An opaque body leaves no light behind it, keeps the floor beside it lit,
// and the border between the two is a line rather than a soft ramp.
res.occluderCastsShadow = !!metrics.shadow
&& metrics.shadow.inside <= 3 && metrics.shadow.beside >= 8;
res.shadowEdgeIsCrisp = !!metrics.shadow
&& metrics.shadow.high - metrics.shadow.low >= 8
&& metrics.shadow.edgeWidth <= 4;
// The east opening is a positional cover. Exercise live 100 → 0 → 50
// transitions without clearing any Glow cache: the rendered pixels must
// prove both state invalidation and the centre-preserving partial aperture.
const setDoorPosition = async (position) => page.evaluate(async ({ contact, position: next }) => {
const c = window.__card;
const previous = c.hass.states[contact];
c.hass = {
...c.hass,
states: {
...c.hass.states,
[contact]: {
...previous,
state: next > 0 ? 'open' : 'closed',
attributes: { ...(previous?.attributes || {}), current_position: next },
},
},
};
await c.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
}, { contact: rasterFixture.dynamicContact, position });
await page.evaluate(async (sourceEid) => {
const c = window.__card;
const previous = c.hass.states[sourceEid];
c.hass = {
...c.hass,
states: { ...c.hass.states, [sourceEid]: { ...previous, state: 'on' } },
};
await c.updateComplete;
}, rasterFixture.sourceEid);
await setDoorPosition(0);
const doorClosed = await stage.screenshot({ animations: 'disabled' });
await setDoorPosition(50);
const doorHalf = await stage.screenshot({ animations: 'disabled' });
const dynamicDoor = rasterFixture.doors.find((door) => door.id === 'glow-raster-east');
const dynamicMetrics = dynamicDoor ? await page.evaluate(async ({ images64, door }) => {
const decode = async (base64) => {
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
return createImageBitmap(new Blob([bytes], { type: 'image/png' }));
};
const images = await Promise.all(images64.map(decode));
const width = images[0].width;
const height = images[0].height;
const pixels = images.map((image) => {
const canvas = document.createElement('canvas');
canvas.width = width; canvas.height = height;
const context = canvas.getContext('2d', { willReadFrequently: true });
context.drawImage(image, 0, 0);
return context.getImageData(0, 0, width, height).data;
});
const luma = (data, point) => {
const x = Math.max(0, Math.min(width - 1, Math.round(point[0])));
const y = Math.max(0, Math.min(height - 1, Math.round(point[1])));
const offset = (y * width + x) * 4;
return data[offset] * 0.2126 + data[offset + 1] * 0.7152 + data[offset + 2] * 0.0722;
};
const [open, closed, half, off] = pixels;
const delta = (data, point) => luma(data, point) - luma(off, point);
const widthOf = (data) => {
const lit = door.scan.filter(({ point }) => delta(data, point) >= 3);
return lit.length ? lit[lit.length - 1].offset - lit[0].offset : 0;
};
return {
openDelta: delta(open, door.sample),
closedDelta: delta(closed, door.sample),
openWidth: widthOf(open),
halfWidth: widthOf(half),
};
}, {
images64: [
lightOn.toString('base64'), doorClosed.toString('base64'),
doorHalf.toString('base64'), lightOff.toString('base64'),
],
door: dynamicDoor,
}) : null;
console.log('Dynamic door Glow metrics:', JSON.stringify(dynamicMetrics));
res.closedDoorBlocksGlow = !!dynamicMetrics
&& dynamicMetrics.openDelta >= 8 && dynamicMetrics.closedDelta <= 3;
res.partialDoorNarrowsGlow = !!dynamicMetrics
&& dynamicMetrics.halfWidth > 0
&& dynamicMetrics.halfWidth < dynamicMetrics.openWidth;
await setDoorPosition(100);
const sourceInsideWall = await page.evaluate(async ({ sourceEid, sourceId, wallSource }) => {
if (!wallSource) return false;
const c = window.__card;
const state = c.hass.states[sourceEid];
await window.__hpTest.setLayout((layout) => ({
...layout,
[sourceId]: { s: c._space, x: wallSource[0], y: wallSource[1] },
}));
c.hass = {
...c.hass,
states: { ...c.hass.states, [sourceEid]: { ...state, state: 'on' } },
};
await c.updateComplete;
// The continuity contract may keep the last complete device frame briefly
// while the moved source and its HA snapshot are staged atomically. Wait
// for that bounded hand-off instead of mistaking the allowed stale frame
// for a geometry leak.
const root = c.shadowRoot || c.renderRoot;
const selector = `[data-glow-source="${CSS.escape(sourceEid)}"] .glow-pool`;
const deadline = performance.now() + 1800;
while (root.querySelector(selector) && performance.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 40));
await c.updateComplete;
}
return !root.querySelector(selector);
}, {
sourceEid: rasterFixture.sourceEid,
sourceId: rasterFixture.sourceId,
wallSource: rasterFixture.wallSource,
});
res.sourceInsideWallSuppressed = sourceInsideWall;
} else {
res.litPartsMatchClip = false;
res.doorwayCarriesLight = false;
res.beamWidthBounded = false;
res.apertureItselfLit = false;
res.shadowSamplesAreValid = false;
res.occluderCastsShadow = false;
res.shadowEdgeIsCrisp = false;
res.closedDoorBlocksGlow = false;
res.partialDoorNarrowsGlow = false;
res.sourceInsideWallSuppressed = false;
}
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"spots": 1,
});
await finish(browser, res);