mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Audit dev@2c947f4, DEV-2C947-03 (P2). Three rooms in the core plus one dragged
90 canvases out: the frame rejected the stray exactly as §4.1 promises, and
then a perfectly ordinary marker on the main plan came out 90.89x too big and
covered the house. `contentFrame` voted; `iconUnit` did not — it took
`boxOf(every room)`, so the distance to the stray the frame had just thrown
away lived on in the numerator of `iconCqw`.
`iconUnit` now takes `contentFrame(roomItems, { pad: 0 }).core`: the same
main-mass vote, over the same rooms it always used (rooms only is what keeps
the full card and the static card bit-identical), with no padding, because
this is a UNIT and not a viewport. Below MIN_VOTERS nothing is declared an
outlier, so every ordinary plan — and every genuinely wide one, where the
majority veto applies — keeps exactly the unit it had. `defaultPositions`
takes its declump distance from the same call, so the auto-placement spacing
follows without a second rule.
Unit (test/canvas.test.mjs): a far room leaves both the frame and the icon
unit alone, `iconCqw` on the strayed plan equals `iconCqw` on the same plan
without the stray, and a plan that is honestly two canvases wide still scales.
smoke_canvas_frame.mjs measures the rendered badge in px with and without the
far room. Both are red on the parent commit.
213 lines
9.1 KiB
JavaScript
213 lines
9.1 KiB
JavaScript
// The content frame is PRESENTATION (docs/CANVAS.md §4) — audit dev@2c947f4.
|
|
//
|
|
// DEV-2C947-01: a hidden device is not drawn, but it still stretched the
|
|
// frame — hiding a marker that had wandered into the yard left the visible
|
|
// house a dot in the corner (the full card AND the static
|
|
// houseplan-space-card, which drew `devs` but framed `spaceDevs`).
|
|
//
|
|
// DEV-2C947-02: inside an editor the frame only grows (a frame that shrank
|
|
// mid-drag would move the ground under the pointer); that union was memoised
|
|
// without the mode, so it survived the way back into View.
|
|
//
|
|
// DEV-2C947-03: a room-outlier is rejected from the frame, but iconUnit()
|
|
// measured ALL rooms — so the stray it had just rejected still blew every
|
|
// marker of the main plan up ~91x.
|
|
import { launch, checkAll, finish } from './serve.mjs';
|
|
|
|
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
|
|
const out = {};
|
|
const raf = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
|
|
|
// Load a config/layout into the card AND into the demo's WS stub, so the
|
|
// static card (which fetches its own copy) sees exactly the same house.
|
|
const load = (cfg, layout, space) => page.evaluate(async ([c2, l2, sp]) => {
|
|
const c = window.__card;
|
|
const srvCfg = (await c.hass.callWS({ type: 'houseplan/config/get' })).config;
|
|
const srvLay = (await c.hass.callWS({ type: 'houseplan/layout/get' })).layout;
|
|
for (const k of Object.keys(srvCfg)) delete srvCfg[k];
|
|
Object.assign(srvCfg, JSON.parse(JSON.stringify(c2)));
|
|
for (const k of Object.keys(srvLay)) delete srvLay[k];
|
|
Object.assign(srvLay, JSON.parse(JSON.stringify(l2)));
|
|
c._serverCfg = srvCfg;
|
|
c._layout = JSON.parse(JSON.stringify(l2));
|
|
c._modelCache = null;
|
|
c._frame = null;
|
|
c._showFar = false;
|
|
c._space = sp;
|
|
c._cfgEpoch++;
|
|
c._regSignature = '';
|
|
c._maybeRebuildDevices?.();
|
|
c._defPos = c._defaultPositions();
|
|
c._view = null;
|
|
c._zoom = 1;
|
|
c.requestUpdate();
|
|
await c.updateComplete;
|
|
c._resetZoom();
|
|
await c.updateComplete;
|
|
}, [cfg, layout, space]);
|
|
|
|
// ---------------------------------------------------------------- 01: hidden
|
|
// The auditor's probe, verbatim: ONE visible room and ONE marker parked 90
|
|
// canvases away. Two content items is below MIN_VOTERS, so the outlier vote
|
|
// never runs and the far position is guaranteed to stay in the core — the
|
|
// frame either sees the marker or it does not.
|
|
const oneRoom = (hidden) => ({
|
|
spaces: [{
|
|
id: 'h1', title: 'Hidden', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
|
|
rooms: [{ id: 'r1', name: 'Room', area: 'zx_room',
|
|
poly: [[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]] }],
|
|
}],
|
|
// a made-up area: no auto device of the demo house lands in this space, so
|
|
// the only content is the room and the one marker below
|
|
markers: [{ id: 'm_far', binding: 'device:d_motion', area: 'zx_room', hidden }],
|
|
settings: { filter_seeded: true },
|
|
});
|
|
const FAR_POS = { m_far: { s: 'h1', x: 90, y: 0.5 } };
|
|
|
|
await load(oneRoom(false), FAR_POS, 'h1');
|
|
await raf();
|
|
Object.assign(out, await page.evaluate(() => {
|
|
const c = window.__card;
|
|
const sr = c.shadowRoot || c.renderRoot;
|
|
const b = c._baseVb();
|
|
return {
|
|
// premise: while it IS drawn, the marker is content and the frame holds it
|
|
visibleMarkerIsDrawn: sr.querySelectorAll('.devlayer .dev').length === 1,
|
|
visibleMarkerWidensTheFrame: b[2] > 50000,
|
|
};
|
|
}));
|
|
|
|
await load(oneRoom(true), FAR_POS, 'h1');
|
|
await raf();
|
|
Object.assign(out, await page.evaluate(() => {
|
|
const c = window.__card;
|
|
const sr = c.shadowRoot || c.renderRoot;
|
|
const o = {};
|
|
const b = c._baseVb();
|
|
o.hiddenMarkerNotDrawn = sr.querySelectorAll('.devlayer .dev').length === 0;
|
|
o.hiddenMarkerStillBuilt = !!c._devices.find((d) => d.id === 'm_far' && d.hidden === true);
|
|
// the room is 100..900 render units, padded by 5 % of the longer side
|
|
o.frameIsTheVisibleRoom = Math.round(b[0]) === 60 && Math.round(b[2]) === 880
|
|
&& Math.round(b[1]) === 60 && Math.round(b[3]) === 880;
|
|
// and the room fills the screen instead of being a dot 112x too small
|
|
const st = sr.querySelector('.stage').getBoundingClientRect();
|
|
const poly = sr.querySelector('polygon.room').getBoundingClientRect();
|
|
o.roomFillsTheStage = poly.width > st.width * 0.7;
|
|
return o;
|
|
}));
|
|
|
|
// the same house on the STATIC card (space-render.ts drew `devs` but framed
|
|
// `spaceDevs` — the identical split)
|
|
Object.assign(out, await page.evaluate(async () => {
|
|
await customElements.whenDefined('houseplan-space-card');
|
|
const o = {};
|
|
const host = document.createElement('div');
|
|
host.style.cssText = 'width:400px;height:400px';
|
|
document.body.appendChild(host);
|
|
const el = document.createElement('houseplan-space-card');
|
|
el.setConfig({ type: 'custom:houseplan-space-card', space: 'h1' });
|
|
el.hass = window.__card.hass;
|
|
host.appendChild(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;
|
|
const svg = el.renderRoot.querySelector('.hp-static-stage svg');
|
|
const vb = (svg?.getAttribute('viewBox') || '').split(/\s+/).map(Number);
|
|
o.staticCardHidesTheMarker = el.renderRoot.querySelectorAll('.devlayer .dev').length === 0;
|
|
o.staticFrameIsTheVisibleRoom = vb.length === 4
|
|
&& Math.round(vb[0]) === 60 && Math.round(vb[2]) === 880;
|
|
host.remove();
|
|
return o;
|
|
}));
|
|
|
|
// ------------------------------------------------------- 02: editor -> view
|
|
const oneRoomAt = (x0) => ({
|
|
spaces: [{
|
|
id: 'e1', title: 'Editor', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
|
|
rooms: [{ id: 'r1', name: 'Room', area: 'zx_room',
|
|
poly: [[x0, 0.1], [x0 + 0.8, 0.1], [x0 + 0.8, 0.9], [x0, 0.9]] }],
|
|
}],
|
|
markers: [], settings: { filter_seeded: true },
|
|
});
|
|
|
|
await load(oneRoomAt(0.1), {}, 'e1');
|
|
await raf();
|
|
Object.assign(out, await page.evaluate(async () => {
|
|
const c = window.__card;
|
|
const o = {};
|
|
const round = () => c._baseVb().map((n) => Math.round(n));
|
|
o.viewFrameBefore = JSON.stringify(round()) === JSON.stringify([60, 60, 880, 880]);
|
|
// move the only room 5 canvases to the right, inside the Plan editor
|
|
c._setMode('plan');
|
|
await c.updateComplete;
|
|
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
|
cfg.spaces[0].rooms[0].poly = [[5.1, 0.1], [5.9, 0.1], [5.9, 0.9], [5.1, 0.9]];
|
|
c._serverCfg = cfg;
|
|
c._modelCache = null;
|
|
c._cfgEpoch++;
|
|
c.requestUpdate();
|
|
await c.updateComplete;
|
|
const inEditor = round();
|
|
// inside the editor the frame is the UNION — that is deliberate: it bounds
|
|
// pan and defines zoom 1, and it must not shrink under a live gesture
|
|
o.editorFrameOnlyGrows = inEditor[2] > 5000;
|
|
c._setMode('view');
|
|
await c.updateComplete;
|
|
const back = round();
|
|
o.viewFrameFollowsTheRoomAgain = JSON.stringify(back) === JSON.stringify([5060, 60, 880, 880]);
|
|
if (!o.viewFrameFollowsTheRoomAgain) console.log('frame after exit', back, 'editor', inEditor);
|
|
// and back into an editor it starts from the CURRENT geometry, not the old union
|
|
c._setMode('plan');
|
|
await c.updateComplete;
|
|
o.reenteringTheEditorDoesNotResurrectTheUnion =
|
|
JSON.stringify(round()) === JSON.stringify([5060, 60, 880, 880]);
|
|
c._setMode('view');
|
|
await c.updateComplete;
|
|
return o;
|
|
}));
|
|
|
|
// ---------------------------------------------------- 03: icons and outliers
|
|
// Three rooms in the core plus one dragged 90 canvases out. The frame rejects
|
|
// the stray (that part always worked); the ICON UNIT used to measure all four
|
|
// rooms, so every marker of the plan the user is looking at came out ~91x too
|
|
// big and covered the house.
|
|
const withFarRoom = (far) => ({
|
|
spaces: [{
|
|
id: 'i1', title: 'Icons', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
|
|
rooms: [
|
|
{ id: 'r1', name: 'A', area: 'zx_a', poly: [[0.10, 0.10], [0.30, 0.10], [0.30, 0.30], [0.10, 0.30]] },
|
|
{ id: 'r2', name: 'B', area: 'zx_b', poly: [[0.30, 0.10], [0.55, 0.10], [0.55, 0.35], [0.30, 0.35]] },
|
|
{ id: 'r3', name: 'C', area: 'zx_c', poly: [[0.10, 0.30], [0.35, 0.30], [0.35, 0.55], [0.10, 0.55]] },
|
|
...(far ? [{ id: 'r4', name: 'Far', area: 'zx_d', poly: [[90, 90], [91, 90], [91, 91], [90, 91]] }] : []),
|
|
],
|
|
}],
|
|
markers: [{ id: 'm_one', binding: 'device:d_light1', area: 'zx_a' }],
|
|
settings: { filter_seeded: true },
|
|
});
|
|
const ICON_POS = { m_one: { s: 'i1', x: 0.2, y: 0.2 } };
|
|
|
|
const iconPx = () => page.evaluate(() => {
|
|
const c = window.__card;
|
|
const sr = c.shadowRoot || c.renderRoot;
|
|
const el = sr.querySelector('.devlayer .dev');
|
|
// the badge is --dev-size plus a 1 px border on each side (smoke_icon_scale)
|
|
return { px: el ? Math.round((el.getBoundingClientRect().width - 2) * 100) / 100 : null,
|
|
outliers: c._outliers, frameW: Math.round(c._baseVb()[2]) };
|
|
});
|
|
|
|
await load(withFarRoom(false), ICON_POS, 'i1');
|
|
await raf();
|
|
const plain = await iconPx();
|
|
await load(withFarRoom(true), ICON_POS, 'i1');
|
|
await raf();
|
|
const strayed = await iconPx();
|
|
out.strayRoomIsRejectedFromTheFrame = strayed.outliers === 1 && strayed.frameW < 1000;
|
|
out.strayRoomDoesNotInflateTheIcons =
|
|
plain.px > 4 && Math.abs(strayed.px - plain.px) < 0.5;
|
|
if (!out.strayRoomDoesNotInflateTheIcons) console.log('icon px', plain, strayed);
|
|
|
|
checkAll(out);
|
|
await finish(browser, out);
|