mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-04 05:41:34 +00:00
The space card drew its rooms with a bare map(), so Lit reused room nodes by
position, and `.room { transition: 0.12s }` (planStyles is part of this card's
styles too) drew a node's fill and stroke in from whichever room held it
before. Two paths change the room set in the same DOM: a new `space` in
setConfig of the same element (the card editor's preview), and a config event
from any device that inserts, removes, reorders or re-zones a room of the
shown space. Filled rooms faded out and back in for ~0.12 s, unfilled ones
briefly darkened in a filled room's place.
The list is now keyed(space.id, repeat(rooms, (r, i) => r.id || i, ...)),
the same shape as the full card after #742: the outer key handles the space
change, the inner one keeps a node bound to its room inside a space. An
id-less room keys by its numeric index, which never equals a string id. The
transition itself stays: it smooths a real fill change on the same room. The
#742 note in plan.styles.ts now names the space card as well.
Witness: a new section of smoke_space_card. The config is delivered by a
server push (__hpTest.setServerConfig), the event the card subscribes to.
Red on dev: node r1 reused for g1 with fill/fill-opacity transitions and a
first-frame fill of rgba(0, 0, 0, 0) / 0; a room inserted first shifts all
four nodes and replays fill transitions. A real custom_fill change still runs
a fill transition on the same node (catches `transition: none`). One mutant:
inner key replaced by map(), guarded by AC2 (checked by hand: red).
Issue: #745
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
453 lines
23 KiB
JavaScript
453 lines
23 KiB
JavaScript
// Smoke: houseplan-space-card renders a live, non-interactive schematic + deep-link button.
|
||
import { launch, reportPageErrors } from './serve.mjs';
|
||
/** DEFAULT_CUSTOM_FILL (`src/logic.ts`) в вычисленном стиле: цвет / `fill-opacity`. */
|
||
const FINAL_FILL = 'rgb(96, 125, 139) / 0.18';
|
||
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
|
||
const res = await page.evaluate(async () => {
|
||
await customElements.whenDefined('houseplan-space-card');
|
||
const hass = window.__card.hass;
|
||
const alarmDevice = window.__card._devices.find((device) => device.id === 'd_leak');
|
||
if (alarmDevice?.primary && hass.states[alarmDevice.primary]) {
|
||
hass.states = {
|
||
...hass.states,
|
||
[alarmDevice.primary]: { ...hass.states[alarmDevice.primary], state: 'on' },
|
||
};
|
||
}
|
||
const spaceId = window.__card._model[0].id;
|
||
// Give the real static renderer structural voters that room-only fixtures
|
||
// cannot prove: an independent wall, saved draft, column and hosted door.
|
||
const response = await hass.callWS({ type: 'houseplan/config/get' });
|
||
const configs = [...new Set([response?.config, window.__card._serverCfg])].filter(Boolean);
|
||
for (const config of configs) {
|
||
const fixture = config.spaces.find((space) => space.id === spaceId);
|
||
fixture.cell_cm = 5;
|
||
fixture.settings = {
|
||
...(fixture.settings || {}), show_borders: true, show_names: true, hide_openings: false,
|
||
};
|
||
fixture.partitions = [{ id: 'fit-wall', a: [0.20, 0.25], b: [0.80, 0.25], cm: 15 }];
|
||
fixture.room_drafts = [{
|
||
id: 'fit-draft', points: [[0.18, 0.90], [0.42, 0.90]], segments: [{ cm: 10 }],
|
||
}];
|
||
fixture.wall_columns = [{ id: 'fit-column', shape: 'circle', center: [0.84, 0.90], cm: 30 }];
|
||
fixture.openings = [{
|
||
id: 'fit-door', type: 'door', length: 0.14,
|
||
host: { kind: 'partition', id: 'fit-wall', t: 0.5 },
|
||
}];
|
||
}
|
||
const host = document.createElement('div');
|
||
document.body.appendChild(host);
|
||
|
||
const mk = (cfg) => { const el = document.createElement('houseplan-space-card'); el.setConfig(cfg); el.hass = hass; host.appendChild(el); return el; };
|
||
const card = mk({ type: 'custom:houseplan-space-card', space: spaceId, button_target: '/plan-doma' });
|
||
const explicitContent = mk({ type: 'custom:houseplan-space-card', space: spaceId, fit: 'content' });
|
||
const unknownFit = mk({ type: 'custom:houseplan-space-card', space: spaceId, fit: 'cover' });
|
||
const tight = mk({ type: 'custom:houseplan-space-card', space: spaceId, fit: 'house' });
|
||
const tightNoTitle = mk({ type: 'custom:houseplan-space-card', space: spaceId, fit: 'house', title: '' });
|
||
const named = mk({ type: 'custom:houseplan-space-card', space: spaceId, title: 'Named floor' });
|
||
const compact = mk({ type: 'custom:houseplan-space-card', space: spaceId, title: '' });
|
||
const compactNoButton = mk({ type: 'custom:houseplan-space-card', space: spaceId, title: '', show_button: false });
|
||
// #376(а): YAML `title:` with no value is null — same compact contract as ''
|
||
const nullTitle = mk({ type: 'custom:houseplan-space-card', space: spaceId, title: null });
|
||
const narrowHost = document.createElement('div');
|
||
narrowHost.style.width = '320px';
|
||
compact.before(narrowHost);
|
||
narrowHost.appendChild(compact);
|
||
compact.hass = { ...hass, themes: { ...(hass.themes || {}), darkMode: false } };
|
||
const wideHost = document.createElement('div');
|
||
wideHost.style.width = '900px';
|
||
compactNoButton.before(wideHost);
|
||
wideHost.appendChild(compactNoButton);
|
||
compactNoButton.hass = { ...hass, themes: { ...(hass.themes || {}), darkMode: true } };
|
||
const waitForStage = async (el) => {
|
||
const t0 = Date.now();
|
||
while (!el.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) {
|
||
await new Promise(r => setTimeout(r, 80));
|
||
}
|
||
await el.updateComplete;
|
||
};
|
||
await Promise.all([
|
||
card, explicitContent, unknownFit, tight, tightNoTitle,
|
||
named, compact, compactNoButton,
|
||
].map(waitForStage));
|
||
|
||
const frameOf = (el) => {
|
||
const vb = el.renderRoot.querySelector('.hp-static-stage svg')?.viewBox?.baseVal;
|
||
return vb ? { x: vb.x, y: vb.y, w: vb.width, h: vb.height } : null;
|
||
};
|
||
const frame = frameOf(card);
|
||
const explicitContentFrame = frameOf(explicitContent);
|
||
const unknownFitFrame = frameOf(unknownFit);
|
||
const tightFrame = frameOf(tight);
|
||
const tightNoTitleFrame = frameOf(tightNoTitle);
|
||
const namedFrame = frameOf(named);
|
||
const compactFrame = frameOf(compact);
|
||
const compactNoButtonFrame = frameOf(compactNoButton);
|
||
const nullTitleFrame = frameOf(nullTitle);
|
||
// #384: hidden architecture must not vote in fit: house. Twin cards over a
|
||
// config clone with a far-away wall column; only show_borders differs.
|
||
const hiddenTwinFrames = await (async () => {
|
||
// Twin cards share the page config store; patch each twin's reactive
|
||
// snapshot directly — a deep clone with a far-away wall column, differing
|
||
// only in show_borders.
|
||
const twin = (borders) => {
|
||
const el = document.createElement('houseplan-space-card');
|
||
el.setConfig({ type: 'custom:houseplan-space-card', space: spaceId, fit: 'house' });
|
||
el.hass = card.hass;
|
||
host.appendChild(el);
|
||
return el;
|
||
};
|
||
const hidden = twin(false);
|
||
const shown = twin(true);
|
||
await waitForStage(hidden); await waitForStage(shown);
|
||
const patchSnap = async (el, borders) => {
|
||
// clone ONLY the config: virtualLights carries a Set that JSON kills
|
||
const snap = { ...el._snap, config: JSON.parse(JSON.stringify(el._snap.config)) };
|
||
const sp = snap.config.spaces.find((x) => x.id === spaceId);
|
||
const pts = (sp.rooms || []).flatMap((room) => (room.poly || [])
|
||
.map((pt) => Array.isArray(pt) ? pt : [pt.x, pt.y]));
|
||
const maxX = Math.max(...pts.map((pt) => pt[0]));
|
||
const midY = pts.reduce((acc, pt) => acc + pt[1], 0) / pts.length;
|
||
sp.wall_columns = [...(sp.wall_columns || []), {
|
||
id: 'far-column-384', shape: 'rect', center: [maxX + 300, midY], cm: 40, angle: 0,
|
||
}];
|
||
sp.settings = { ...(sp.settings || {}), show_borders: borders };
|
||
el._snap = snap;
|
||
await el.updateComplete;
|
||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||
await el.updateComplete;
|
||
};
|
||
await patchSnap(hidden, false);
|
||
await patchSnap(shown, true);
|
||
const frames = { hidden: frameOf(hidden), shown: frameOf(shown) };
|
||
hidden.remove(); shown.remove();
|
||
return frames;
|
||
})();
|
||
const nullTitleHasTitle = !!nullTitle.renderRoot.querySelector('.hp-static-title');
|
||
const compactCardBox = compact.renderRoot.querySelector('ha-card')?.getBoundingClientRect();
|
||
const compactStageBox = compact.renderRoot.querySelector('.hp-static-stage')?.getBoundingClientRect();
|
||
const wideStageBox = compactNoButton.renderRoot.querySelector('.hp-static-stage')?.getBoundingClientRect();
|
||
const compactTopGap = compactCardBox && compactStageBox
|
||
? Math.abs(compactStageBox.top - compactCardBox.top) : null;
|
||
|
||
const stage = card.renderRoot.querySelector('.hp-static-stage');
|
||
const pe = stage ? getComputedStyle(stage).pointerEvents : null;
|
||
const markers = card.renderRoot.querySelectorAll('.hp-static-stage .devlayer .dev').length;
|
||
const litMarker = card.renderRoot.querySelector('.hp-static-stage .dev[data-id="d_light1"]');
|
||
const alarmMarker = alarmDevice
|
||
? card.renderRoot.querySelector(`.hp-static-stage .dev[data-id="${alarmDevice.id}"]`)
|
||
: null;
|
||
const alarmSegments = (alarmMarker?.getAttribute('aria-label') || '').split(', ')
|
||
.map((value) => value.trim().toLowerCase()).filter(Boolean);
|
||
const btn = card.renderRoot.querySelector('.hp-static-btn');
|
||
|
||
// #664: the stage is pointer-events:none, but that is not an inherited ban —
|
||
// a descendant that opts back in (the shared 44 px floor and painted capsule
|
||
// of #564) becomes a hit target again, shows a pointer cursor and swallows
|
||
// clicks that do nothing. Ask the browser's hit test itself, not the stage's
|
||
// computed style: nothing inside the schematic may be under the pointer.
|
||
litMarker?.scrollIntoView({ block: 'center', inline: 'center' });
|
||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||
const hitAt = (x, y) => card.renderRoot.elementFromPoint(x, y);
|
||
const insideStage = (el) => !!el && !!stage && (el === stage || stage.contains(el));
|
||
const markerBox = litMarker?.getBoundingClientRect();
|
||
const markerHit = markerBox
|
||
? hitAt(markerBox.left + markerBox.width / 2, markerBox.top + markerBox.height / 2) : null;
|
||
const stageBox = stage?.getBoundingClientRect();
|
||
const gridHits = [];
|
||
if (stageBox) {
|
||
for (let i = 1; i < 10; i += 1) {
|
||
for (let j = 1; j < 10; j += 1) {
|
||
const x = stageBox.left + (stageBox.width * i) / 10;
|
||
const y = stageBox.top + (stageBox.height * j) / 10;
|
||
if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) continue;
|
||
gridHits.push(hitAt(x, y));
|
||
}
|
||
}
|
||
}
|
||
|
||
// deep-link: clicking the button pushes #space=<id>
|
||
let pushed = null;
|
||
const orig = history.pushState;
|
||
history.pushState = function (a, b, url) { pushed = url; return orig.apply(this, arguments); };
|
||
btn?.click();
|
||
history.pushState = orig;
|
||
|
||
// error card for an unknown space
|
||
const bad = mk({ type: 'custom:houseplan-space-card', space: '__nope__' });
|
||
await bad.updateComplete;
|
||
const errCard = bad.renderRoot.querySelector('.hp-static-error');
|
||
|
||
return {
|
||
stagePointerEvents: pe,
|
||
markerProbe: markerHit ? {
|
||
insideStage: insideStage(markerHit),
|
||
element: `${markerHit.tagName.toLowerCase()}.${[...markerHit.classList].join('.')}`,
|
||
cursor: getComputedStyle(markerHit).cursor,
|
||
} : null,
|
||
stageGridProbes: gridHits.length,
|
||
stageGridHitsInside: gridHits.filter(insideStage).length,
|
||
stageGridPointerCursors: gridHits.filter((el) => el && getComputedStyle(el).cursor === 'pointer').length,
|
||
markers,
|
||
litMarkerOn: !!litMarker?.classList.contains('on'),
|
||
sharedFacePresent: !!litMarker?.querySelector('ha-icon'),
|
||
alarmLabelHasNoRepeats: alarmSegments.length >= 2
|
||
&& new Set(alarmSegments).size === alarmSegments.length
|
||
&& alarmSegments.filter((part) => part === 'alarm').length === 1,
|
||
omittedTitle: card.renderRoot.querySelector('.hp-static-title')?.textContent?.trim() || null,
|
||
namedTitle: named.renderRoot.querySelector('.hp-static-title')?.textContent?.trim() || null,
|
||
compactHasTitle: !!compact.renderRoot.querySelector('.hp-static-title'),
|
||
compactTopGap,
|
||
compactWidths: [compactStageBox?.width || 0, wideStageBox?.width || 0],
|
||
compactOverflow: [compact, compactNoButton].some((el) => {
|
||
const body = el.renderRoot.querySelector('.hp-static-body');
|
||
return body ? body.scrollWidth > body.clientWidth + 1 : true;
|
||
}),
|
||
compactThemes: [compact.hass.themes.darkMode, compactNoButton.hass.themes.darkMode],
|
||
frame,
|
||
explicitContentFrame,
|
||
unknownFitFrame,
|
||
tightFrame,
|
||
tightNoTitleFrame,
|
||
tightPointerEvents: getComputedStyle(
|
||
tight.renderRoot.querySelector('.hp-static-stage'),
|
||
).pointerEvents,
|
||
tightStructuralEdges: (() => {
|
||
const vb = tightFrame;
|
||
if (!vb) return null;
|
||
const rooms = [...tight.renderRoot.querySelectorAll('[data-hp="room"]')];
|
||
const points = rooms.flatMap((room) => {
|
||
if (room.tagName.toLowerCase() === 'polygon') {
|
||
return Array.from(room.points).map((point) => [point.x, point.y]);
|
||
}
|
||
const x = room.x.baseVal.value, y = room.y.baseVal.value;
|
||
const w = room.width.baseVal.value, h = room.height.baseVal.value;
|
||
return [[x, y], [x + w, y + h]];
|
||
});
|
||
return points.every(([x, y]) =>
|
||
x >= vb.x && x <= vb.x + vb.w && y >= vb.y && y <= vb.y + vb.h);
|
||
})(),
|
||
tightPaintedEnvelope: (() => {
|
||
const svg = tight.renderRoot.querySelector('.hp-static-stage svg');
|
||
if (!svg) return null;
|
||
const viewport = svg.getBoundingClientRect();
|
||
const nodes = [...svg.querySelectorAll('.wallbody, .zero-wall, .static-opening')];
|
||
return {
|
||
wall: !!svg.querySelector('.wallbody'),
|
||
opening: !!svg.querySelector('[data-id="fit-door"]'),
|
||
contained: nodes.length > 0 && nodes.every((node) => {
|
||
const box = node.getBoundingClientRect();
|
||
return box.left >= viewport.left - 0.51 && box.right <= viewport.right + 0.51
|
||
&& box.top >= viewport.top - 0.51 && box.bottom <= viewport.bottom + 0.51;
|
||
}),
|
||
};
|
||
})(),
|
||
nullTitleFrame,
|
||
nullTitleHasTitle,
|
||
hiddenTwinFrames,
|
||
namedFrame,
|
||
compactFrame,
|
||
compactNoButtonFrame,
|
||
hasButton: !!btn,
|
||
deepLink: pushed,
|
||
errorShown: !!errCard,
|
||
errorText: errCard?.textContent?.trim() || null,
|
||
};
|
||
});
|
||
// ---- #745: фигуры комнат с ключами (пространство, id комнаты) -------------
|
||
// `.room` переводит все свойства за 0,12 с (planStyles входит в стили этой
|
||
// карточки). Пока список фигур был голым `map()`, Lit отдавал узел комнаты
|
||
// соседке по позиции, и заливка ехала от чужого значения. Путь A — новый
|
||
// `space` в `setConfig` того же элемента (превью редактора карточки). Путь B —
|
||
// событие конфигурации: комнату вставили, удалили, переставили с другого
|
||
// устройства; конфиг доставляется пушем с сервера (`__hpTest.setServerConfig`),
|
||
// тем же событием, на которое подписана карточка.
|
||
const keyedRooms = await page.evaluate(async () => {
|
||
const out = {};
|
||
const T = window.__hpTest;
|
||
const frame = () => new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
||
const settle = async (frames = 10, ms = 400) => {
|
||
for (let i = 0; i < frames; i++) await frame();
|
||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||
};
|
||
const spaceOf = (cfg, id) => cfg.spaces.find((space) => space.id === id);
|
||
const host = document.createElement('div');
|
||
host.style.width = '600px';
|
||
document.body.appendChild(host);
|
||
const el = document.createElement('houseplan-space-card');
|
||
const config = { type: 'custom:houseplan-space-card', show_button: false };
|
||
const roomNodes = () => [...(el.renderRoot?.querySelectorAll('[data-hp="room"]') || [])];
|
||
const roomNode = (id) => roomNodes().find((node) => node.dataset.id === id) || null;
|
||
const roomTransitions = () => roomNodes().flatMap((node) => node.getAnimations()
|
||
.filter((animation) => animation instanceof CSSTransition)
|
||
.map((animation) => `${node.dataset.id}:${animation.transitionProperty}`));
|
||
const fillOf = (node) => {
|
||
const style = node ? getComputedStyle(node) : null;
|
||
return style ? `${style.fill} / ${style.fillOpacity}` : null;
|
||
};
|
||
const waitUntil = async (predicate, ms = 6000) => {
|
||
const deadline = performance.now() + ms;
|
||
while (!predicate() && performance.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 16));
|
||
return !!predicate();
|
||
};
|
||
/**
|
||
* Узлы комнат по `data-id` и переходы, стартовавшие на них до `stop()`.
|
||
* Событие не зависит от того, успел ли кадр дойти до проверки.
|
||
*/
|
||
const watchRooms = () => {
|
||
const nodes = new Map(roomNodes().map((node) => [node.dataset.id, node]));
|
||
const ran = [];
|
||
const listening = new AbortController();
|
||
for (const [id, node] of nodes) {
|
||
node.addEventListener('transitionrun', (event) => ran.push(`${id}:${event.propertyName}`),
|
||
{ signal: listening.signal });
|
||
}
|
||
return { nodes, stop: () => { listening.abort(); return [...ran]; } };
|
||
};
|
||
// Фикстура: f1 — подложка, комнаты без заливки, но с границами (`styled`);
|
||
// у garden своя заливка.
|
||
await T.setServerConfig((cfg) => {
|
||
const f1 = spaceOf(cfg, 'f1');
|
||
f1.settings = { ...(f1.settings || {}), show_borders: true, fill_mode: 'none' };
|
||
const garden = spaceOf(cfg, 'garden');
|
||
garden.settings = { ...(garden.settings || {}), show_borders: true, fill_mode: 'custom' };
|
||
});
|
||
el.setConfig({ ...config, space: 'f1' });
|
||
el.hass = window.__card.hass;
|
||
host.appendChild(el);
|
||
await waitUntil(() => roomNodes().length >= 2
|
||
&& roomNodes().every((node) => /\bstyled\b/.test(node.getAttribute('class'))));
|
||
await el.updateComplete;
|
||
await settle();
|
||
|
||
// AC1, путь A: тот же элемент получает другое пространство
|
||
const before = roomNodes().map((node) => ({
|
||
node, id: node.dataset.id, tag: node.tagName.toLowerCase(), cls: node.getAttribute('class'),
|
||
}));
|
||
const watchA = watchRooms();
|
||
el.setConfig({ ...config, space: 'garden' });
|
||
await el.updateComplete;
|
||
await frame();
|
||
const g1 = roomNode('g1');
|
||
out.pathAFixtureHolds = before[0]?.id === 'r1' && before[0].tag === 'polygon'
|
||
&& !/\bfilled\b/.test(before[0].cls)
|
||
&& g1?.tagName.toLowerCase() === 'polygon' && /\bfilled\b/.test(g1.getAttribute('class'));
|
||
out.pathANoRoomTransition = [...new Set([...roomTransitions(), ...watchA.stop()])];
|
||
out.pathANoRoomNodeOutlivesTheSwitch = before.filter((entry) => entry.node.isConnected)
|
||
.map((entry) => `${entry.id} → ${entry.node.dataset.id}`);
|
||
out.pathANewRoomBornInItsFill = fillOf(g1);
|
||
|
||
// AC2, путь B: событие конфигурации меняет состав комнат того же пространства
|
||
el.setConfig({ ...config, space: 'f1' });
|
||
await T.setServerConfig((cfg) => {
|
||
const f1 = spaceOf(cfg, 'f1');
|
||
f1.settings = { ...f1.settings, fill_mode: 'custom' };
|
||
});
|
||
await waitUntil(() => roomNodes().length >= 2
|
||
&& roomNodes().every((node) => /\bfilled\b/.test(node.getAttribute('class'))));
|
||
await el.updateComplete;
|
||
await settle();
|
||
const probeId = 'hp-745-probe';
|
||
const kept = watchRooms();
|
||
await T.setServerConfig((cfg) => {
|
||
const f1 = spaceOf(cfg, 'f1');
|
||
f1.rooms = [{
|
||
id: probeId, name: 'Porch', poly: [[0.04, 0.88], [0.2, 0.88], [0.2, 0.97], [0.04, 0.97]],
|
||
settings: { fill_mode: 'none' },
|
||
}, ...f1.rooms];
|
||
});
|
||
out.pathBRoomListGrew = await waitUntil(() => roomNodes()[0]?.dataset.id === probeId)
|
||
&& roomNodes().length === kept.nodes.size + 1
|
||
&& !/\bfilled\b/.test(roomNodes()[0].getAttribute('class'));
|
||
await el.updateComplete;
|
||
await frame();
|
||
const afterInsert = new Map(roomNodes().map((node) => [node.dataset.id, node]));
|
||
out.pathBNoRoomNodeSwapped = [...kept.nodes]
|
||
.filter(([id, node]) => afterInsert.get(id) !== node).map(([id]) => id);
|
||
out.pathBQuietRooms = [...new Set([...roomTransitions(), ...kept.stop()])];
|
||
|
||
// AC2: настоящая смена цвета той же комнаты по-прежнему анимируется —
|
||
// ловит ложный фикс `transition: none`
|
||
await settle();
|
||
const filled = watchRooms();
|
||
const witnessId = 'r1';
|
||
await T.setServerConfig((cfg) => {
|
||
const f1 = spaceOf(cfg, 'f1');
|
||
f1.settings = { ...f1.settings, custom_fill: { c: '#c62828', a: 0.5 } };
|
||
});
|
||
await waitUntil(() => /#c62828/i.test(roomNode(witnessId)?.getAttribute('style') || ''));
|
||
await el.updateComplete;
|
||
await frame();
|
||
out.pathBRealFillChangeKeepsTheRoomNode = !!filled.nodes.get(witnessId)
|
||
&& roomNode(witnessId) === filled.nodes.get(witnessId);
|
||
out.pathBRealFillChangeAnimates = [...roomTransitions(), ...filled.stop()]
|
||
.includes(`${witnessId}:fill`);
|
||
host.remove();
|
||
return out;
|
||
});
|
||
const keyedRoomsExpected = {
|
||
pathAFixtureHolds: true,
|
||
pathANoRoomTransition: [],
|
||
pathANoRoomNodeOutlivesTheSwitch: [],
|
||
pathANewRoomBornInItsFill: FINAL_FILL,
|
||
pathBRoomListGrew: true,
|
||
pathBNoRoomNodeSwapped: [],
|
||
pathBQuietRooms: [],
|
||
pathBRealFillChangeKeepsTheRoomNode: true,
|
||
pathBRealFillChangeAnimates: true,
|
||
};
|
||
const keyedRoomsFailures = Object.entries(keyedRoomsExpected)
|
||
.filter(([key, value]) => JSON.stringify(keyedRooms[key]) !== JSON.stringify(value))
|
||
.map(([key, value]) => `${key}: expected ${JSON.stringify(value)}, got ${JSON.stringify(keyedRooms[key])}`);
|
||
await browser.close();
|
||
const ok =
|
||
res.stagePointerEvents === 'none' &&
|
||
// #664 AC1/AC2: the browser's hit test finds nothing inside the schematic —
|
||
// not the marker, not the paper — and no probe shows a pointer cursor.
|
||
res.markerProbe && !res.markerProbe.insideStage && res.markerProbe.cursor !== 'pointer' &&
|
||
res.stageGridProbes >= 40 && res.stageGridHitsInside === 0 && res.stageGridPointerCursors === 0 &&
|
||
res.markers > 0 &&
|
||
res.litMarkerOn &&
|
||
res.sharedFacePresent &&
|
||
res.alarmLabelHasNoRepeats &&
|
||
!!res.omittedTitle &&
|
||
res.namedTitle === 'Named floor' &&
|
||
!res.compactHasTitle &&
|
||
res.compactTopGap !== null && res.compactTopGap < 0.51 &&
|
||
res.compactWidths[0] > 300 && res.compactWidths[0] <= 320 &&
|
||
res.compactWidths[1] > 880 && res.compactWidths[1] <= 900 &&
|
||
!res.compactOverflow &&
|
||
JSON.stringify(res.compactThemes) === JSON.stringify([false, true]) &&
|
||
res.frame && res.namedFrame && res.compactFrame && res.compactNoButtonFrame &&
|
||
res.explicitContentFrame && res.unknownFitFrame && res.tightFrame && res.tightNoTitleFrame &&
|
||
JSON.stringify(res.frame) === JSON.stringify(res.explicitContentFrame) &&
|
||
JSON.stringify(res.frame) === JSON.stringify(res.unknownFitFrame) &&
|
||
JSON.stringify(res.tightFrame) === JSON.stringify(res.tightNoTitleFrame) &&
|
||
res.tightFrame.w < res.frame.w && res.tightFrame.h < res.frame.h &&
|
||
res.tightFrame.x > res.frame.x && res.tightFrame.y > res.frame.y &&
|
||
res.tightPointerEvents === 'none' && res.tightStructuralEdges &&
|
||
res.tightPaintedEnvelope?.wall && res.tightPaintedEnvelope?.opening &&
|
||
res.tightPaintedEnvelope?.contained &&
|
||
JSON.stringify(res.frame) === JSON.stringify(res.namedFrame) &&
|
||
res.compactFrame.x === res.frame.x &&
|
||
res.compactFrame.w === res.frame.w &&
|
||
res.compactFrame.y > res.frame.y &&
|
||
Math.abs((res.compactFrame.y + res.compactFrame.h) - (res.frame.y + res.frame.h)) < 1e-6 &&
|
||
JSON.stringify(res.compactFrame) === JSON.stringify(res.compactNoButtonFrame) &&
|
||
!res.nullTitleHasTitle &&
|
||
res.hiddenTwinFrames && res.hiddenTwinFrames.hidden && res.hiddenTwinFrames.shown &&
|
||
// #384 AC1: скрытая архитектура не голосует — кадр без колонны строго уже
|
||
(res.hiddenTwinFrames.hidden.x + res.hiddenTwinFrames.hidden.w)
|
||
< (res.hiddenTwinFrames.shown.x + res.hiddenTwinFrames.shown.w) - 100 &&
|
||
JSON.stringify(res.nullTitleFrame) === JSON.stringify(res.compactFrame) &&
|
||
res.hasButton &&
|
||
typeof res.deepLink === 'string' && res.deepLink.includes('#space=') &&
|
||
res.errorShown;
|
||
console.log(JSON.stringify(res));
|
||
console.log(JSON.stringify({ keyedRooms }));
|
||
for (const failure of keyedRoomsFailures) console.error(`FAIL #745 ${failure}`);
|
||
// #407: своя развязка про исключения в карточке не спрашивает. Вердикт обязан
|
||
// именно остановить: иначе строка успеха печатается после «FAILED».
|
||
if (await reportPageErrors()) process.exit(1);
|
||
if (!ok || keyedRoomsFailures.length) { console.error('FAIL space-card smoke'); process.exit(1); }
|
||
console.log('OK space-card: live shared marker face, pointer-events:none, nothing hit-testable in the schematic (#664), deep-link button, error card, room shapes keyed by space and id (#745)');
|