Files
houseplan-card/demo/smoke_floor_geometry_cache.mjs
T
Claude 6ed9aa1652 test(floor): judge both frames of a held resize drag (#744)
smoke_floor_geometry_cache failed in Validate run 36875756451 on one check,
ac2cPreviewWallStandsWhereAFreshCardDrawsIt, with every area check green.
The check read the moving wall only from the live layer (data-kind
"preview"). A host render during a held drag ends that layer: updated()
commits it, and nothing repaints it until the next accepted move. The
settled scene then draws the preview record itself, and its walls equal
those of a fresh card on that record. In the smoke, the "Room updated"
toast from the AC1 rename expires 3.5 s after the save, about when the drag
starts: locally the rename-to-drag gap is 3.47-3.6 s. On a faster runner
the expiry landed after the last move, so the check found no live path. A
light toggled in Home Assistant mid-drag gives the same red.

The drag now starts once the toast is gone, so the live frame normally
judges the live layer. If a host render still lands, the check judges the
settled union the same way; a stale union has none of the moved faces. A
new check takes the settled frame on purpose: a state change during the
held drag, then the union path and the areas must equal the fresh card's.
The live strips never pass through the floor-geometry caches, so the old
check stayed green with a floor key that ignores the preview. The new
check fails on that key, and a failure prints a diagnostic line naming the
judged layer and whether the settled union is the stored one.

Issue: #744
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-02 00:56:04 +03:00

384 lines
20 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.
// #744: the four floor-geometry caches (physical bodies, the wall union,
// inner room contours, the clean floor) are keyed by the content of ONE
// floor's record, not by the global config epoch.
//
// AC1 an edit of `f1` (a rename through the room dialog) leaves `garden`
// warm: its first visit after the edit builds nothing. On the epoch key
// the visit rebuilt the union (+1), every contour and clean floor (+N).
// AC2a a server push that changes walls of BOTH floors (shared walls: one
// thickness moves the faces of two rooms) while `f1` is on screen: each
// floor draws the walls and areas an independent card computes from
// scratch on the same config, and they differ from the previous ones.
// AC2b a stair placed on `f1` through the stairs tool shrinks the room by its
// footprint, as the independent card says, and `garden` keeps its clean
// floors (the stairs editor used to clear the cache of every floor).
// AC2c a live Resize preview on `f1`: the frame follows the preview record,
// as the independent card on that record says; cancelling restores the
// walls and areas of the stored record. Both frames of a held drag are
// judged: the live layer that paints the moving wall, and the settled
// scene that replaces it whenever the host renders mid-drag (an entity
// state change from Home Assistant, a toast expiring) and draws the
// preview record through the floor-geometry caches.
//
// The independent card is the oracle: a new `houseplan-card` given the same
// config through its own `config/get`, with nothing in its caches to reuse.
// Card internals are read-only here; writes go through the harness facade,
// the room dialog, the stairs tool, real pointer input and the demo's Home
// Assistant stub (a service call delivers the new entity state).
import { launch, checkAll, finish } from './serve.mjs';
import { fixtureWallKey } from './fixtures/wall-key.mjs';
const { page, browser } = await launch({ width: 1100, height: 850 });
/** Every edge of every room once, as a real wall record of `cm`. */
const wallsOf = (rooms, cm, overrides = {}) => {
const byKey = new Map();
for (const room of rooms) {
room.poly.forEach((a, index) => {
const b = room.poly[(index + 1) % room.poly.length];
const key = fixtureWallKey(a, b);
if (!byKey.has(key)) byKey.set(key, { key, cm: overrides[key] ?? cm, a, b });
});
}
return [...byKey.values()];
};
const rect = (x0, y0, x1, y1) => [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
// On the 1/240 editor grid, every interior edge shared by exactly two rooms.
const f1Rooms = [
{ id: 'r1', name: 'Living room', area: 'living_room', poly: rect(0.1, 0.15, 0.5, 0.5) },
{ id: 'r2', name: 'Kitchen', area: 'kitchen', poly: rect(0.5, 0.15, 0.9, 0.5) },
{ id: 'r3', name: 'Hallway', area: 'hallway', poly: rect(0.1, 0.5, 0.5, 0.85) },
{ id: 'r4', name: 'Bedroom', area: 'bedroom', poly: rect(0.5, 0.5, 0.9, 0.85) },
];
const gardenRooms = [
{ id: 'g1', name: 'Garden west', area: 'garden', poly: rect(0.1, 0.2, 0.5, 0.8) },
{ id: 'g2', name: 'Garden east', poly: rect(0.5, 0.2, 0.9, 0.8) },
];
const f1Shared = fixtureWallKey([0.5, 0.15], [0.5, 0.5]);
const gardenShared = fixtureWallKey([0.5, 0.2], [0.5, 0.8]);
const fixture = {
f1: {
rooms: f1Rooms,
walls: wallsOf(f1Rooms, 15),
partitions: [{ id: 'f1-wall', a: [0.6, 0.62], b: [0.8, 0.62], cm: 10 }],
wall_columns: [{ id: 'f1-column', shape: 'circle', center: [0.7, 0.3], cm: 20 }],
},
garden: {
rooms: gardenRooms,
walls: wallsOf(gardenRooms, 15),
partitions: [{ id: 'garden-wall', a: [0.2, 0.5], b: [0.4, 0.5], cm: 10 }],
wall_columns: [{ id: 'garden-column', shape: 'square', center: [0.7, 0.5], cm: 20, angle: 0 }],
},
// AC2a: one push thickens the shared wall of each floor.
thick: {
f1: wallsOf(f1Rooms, 15, { [f1Shared]: 30 }),
garden: wallsOf(gardenRooms, 15, { [gardenShared]: 30 }),
},
};
const res = await page.evaluate(async (fixture) => {
const out = {};
const diag = {};
const card = window.__card;
const hp = window.__hpTest;
const root = () => card.renderRoot;
const settled = () => hp.settled();
const frames = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const writesIdle = async () => {
const busy = () => card._saveConfigDebounced.pending() || card._writesPending > 0;
const deadline = performance.now() + 5000;
while (busy() && performance.now() < deadline) await new Promise((done) => setTimeout(done, 16));
await settled();
return !busy();
};
// ---- what a floor shows: wall outlines and room areas ---------------------
const wallPaths = (host = card) => [...host.renderRoot.querySelectorAll('[data-hp="wall"]')]
.map((node) => `${node.getAttribute('data-kind')}:${node.getAttribute('d')}`).join(' | ');
const areas = (host = card) => Object.fromEntries((host._spaceModel()?.rooms || [])
.map((room) => [room.id, host._roomArea(room)]));
const shown = (host = card) => ({
space: host._spaceModel()?.id, walls: wallPaths(host), areas: areas(host),
});
const sameShown = (a, b) => a.space === b.space && a.walls === b.walls
&& JSON.stringify(a.areas) === JSON.stringify(b.areas);
const sizes = () => ({
wallUnionPool: card._wallUnionPool.size,
innerContour: card._innerContourCache.size,
cleanFloor: card._cleanFloorCache.size,
});
const gardenCleanKeys = () => [...card._cleanFloorCache.keys()].filter((key) => key.startsWith('garden|'));
// ---- the oracle: a fresh card on the given config -------------------------
let oracleRev = 900000;
const oracle = async (config, spaceId) => {
const rev = ++oracleRev;
const base = card.hass;
const hass = {
...base,
callWS: async (message) => {
if (message.type === 'houseplan/config/get')
return { config: structuredClone(config), rev, can_write: false };
if (message.type === 'houseplan/config/set') return { ok: true, rev };
return base.callWS(message);
},
};
const host = document.createElement('div');
host.style.cssText = 'position:fixed;left:0;top:0;width:900px;height:700px;opacity:0;pointer-events:none';
document.body.appendChild(host);
const fresh = document.createElement('houseplan-card');
fresh.setConfig({ type: 'custom:houseplan-card', floor: spaceId });
host.appendChild(fresh);
fresh.hass = hass;
const deadline = performance.now() + 9000;
while (performance.now() < deadline && !(fresh._loadOk && fresh._booting === false
&& fresh._cfgRev === rev && fresh._spaceModel()?.id === spaceId)) {
await new Promise((done) => setTimeout(done, 30));
}
fresh.hass = { ...hass };
await fresh.updateComplete;
await frames();
await fresh.updateComplete;
const value = { ...shown(fresh), ready: fresh._cfgRev === rev };
host.remove();
return value;
};
window.__hp744 = { diag, shown, sameShown, oracle, settled, writesIdle };
// ---- fixture: walls on every room edge, a partition and a column per floor -
// The demo record still carries the legacy `segments: []`, and the first
// write of the card canonicalises EVERY floor (`_dropLegacySegments` drops
// it and gives a square column its `angle`): a real, one-time change of
// garden's record. The fixture starts canonical, so the rename below is
// the only change.
await hp.setServerConfig((cfg) => {
for (const space of cfg.spaces) {
const own = fixture[space.id];
if (!own) continue;
Object.assign(space, structuredClone(own), { cell_cm: 5 });
space.settings = { ...(space.settings || {}), show_borders: true };
delete space.stairs;
delete space.segments;
}
return cfg;
});
await hp.switchSpace('f1');
await writesIdle();
const f1Initial = shown();
out.fixtureF1HasWallBodies = f1Initial.walls.includes('union:') && Object.keys(f1Initial.areas).length === 4;
// ======================= AC1: an edit of f1 leaves garden warm ============
await hp.switchSpace('garden');
const gardenInitial = shown();
out.fixtureGardenHasWallBodies = gardenInitial.space === 'garden' && gardenInitial.walls.includes('union:')
&& Object.keys(gardenInitial.areas).length === 2;
const gardenBodiesKey = card._physicalBodiesCache?.key ?? null;
out.fixtureGardenHasPhysicalBodies = !!gardenBodiesKey && (card._physicalBodiesR()?.length ?? 0) >= 2;
const gardenRecord = () => JSON.stringify(card._serverCfg.spaces.find((space) => space.id === 'garden'));
const gardenRecordBefore = gardenRecord();
await hp.switchSpace('f1');
await hp.setMode('plan');
const epochBeforeRename = card._cfgEpoch;
const dialog = await hp.openRoomEdit('r1');
await hp.input(dialog.querySelector('#room-name'), 'Living room 744');
dialog.querySelector('[data-hp="dialog-confirm"]')?.click();
await settled();
await hp.setMode('view');
out.ac1WritesSettled = await writesIdle();
const renamed = card._serverCfg.spaces.find((space) => space.id === 'f1')
?.rooms.find((room) => room.id === 'r1')?.name;
out.ac1TheEditIsARealSave = renamed === 'Living room 744' && card._cfgEpoch > epochBeforeRename;
out.ac1TheEditLeavesGardenRecordAlone = gardenRecord() === gardenRecordBefore;
const beforeVisit = sizes();
await hp.switchSpace('garden');
const afterVisit = sizes();
diag.ac1 = { beforeVisit, afterVisit, gardenBodiesKey, after: card._physicalBodiesCache?.key };
out.ac1GardenVisitBuildsNoWallUnion = afterVisit.wallUnionPool - beforeVisit.wallUnionPool;
out.ac1GardenVisitBuildsNoContour = afterVisit.innerContour - beforeVisit.innerContour;
out.ac1GardenVisitBuildsNoCleanFloor = afterVisit.cleanFloor - beforeVisit.cleanFloor;
out.ac1GardenKeepsItsPhysicalBodiesKey = card._physicalBodiesCache?.key === gardenBodiesKey;
out.ac1GardenLooksTheSame = sameShown(shown(), gardenInitial);
// ============ AC2a: a server push changes both floors while f1 is shown ====
const gardenBefore = shown();
await hp.switchSpace('f1');
const f1Before = shown();
await hp.setServerConfig((cfg) => {
for (const space of cfg.spaces) {
if (fixture.thick[space.id]) space.walls = structuredClone(fixture.thick[space.id]);
}
return cfg;
});
const pushed = structuredClone(card._serverCfg);
const f1After = shown();
await hp.switchSpace('garden');
const gardenAfter = shown();
const f1Oracle = await oracle(pushed, 'f1');
const gardenOracle = await oracle(pushed, 'garden');
diag.ac2a = { f1Before, f1After, f1Oracle, gardenBefore, gardenAfter, gardenOracle };
out.ac2aOraclesReady = f1Oracle.ready && gardenOracle.ready;
out.ac2aPushChangedBothFloors = f1After.walls !== f1Before.walls && gardenAfter.walls !== gardenBefore.walls
&& f1After.areas.r1 !== f1Before.areas.r1 && f1After.areas.r2 !== f1Before.areas.r2
&& gardenAfter.areas.g1 !== gardenBefore.areas.g1 && gardenAfter.areas.g2 !== gardenBefore.areas.g2;
out.ac2aCurrentFloorEqualsAFreshCard = sameShown(f1After, f1Oracle);
out.ac2aOtherFloorEqualsAFreshCard = sameShown(gardenAfter, gardenOracle);
// ======== AC2b: a stair on f1 shrinks its room; garden keeps its floors =====
const gardenKeys = gardenCleanKeys();
out.ac2bGardenHasCleanFloors = gardenKeys.length >= 2;
await hp.switchSpace('f1');
await hp.setMode('plan');
const r3 = () => card._spaceModel().rooms.find((room) => room.id === 'r3');
const areaBeforeStair = card._roomArea(r3());
await hp.setTool('stairs');
const straight = root().querySelector('[data-hp="tray"] [data-group-item="straight"]');
straight?.click();
await settled();
// The default straight stair (240 × 100 cm) centred well inside r3.
const at = new DOMPoint(300, 680).matrixTransform(root().querySelector('.plan-svg').getScreenCTM());
root().querySelector('.stage').dispatchEvent(new MouseEvent('click', {
clientX: at.x, clientY: at.y, bubbles: true, composed: true, cancelable: true, button: 0,
}));
await settled();
await writesIdle();
const stairs = card._serverCfg.spaces.find((space) => space.id === 'f1')?.stairs || [];
out.ac2bTheStairIsPlaced = !!straight && stairs.length === 1;
const areaAfterStair = card._roomArea(r3());
const stairOracle = await oracle(structuredClone(card._serverCfg), 'f1');
const number = (text) => Number(String(text).replace(/[^\d.,-]/g, '').replace(',', '.'));
diag.ac2b = { areaBeforeStair, areaAfterStair, oracle: stairOracle.areas.r3, gardenKeys };
out.ac2bRoomAreaShrinks = number(areaAfterStair) < number(areaBeforeStair);
out.ac2bRoomAreaEqualsAFreshCard = stairOracle.ready && areaAfterStair === stairOracle.areas.r3;
out.ac2bGardenCleanFloorsKept = gardenKeys.filter((key) => !card._cleanFloorCache.has(key));
// ======== AC2c setup: the Resize tool and the r1/r2 shared wall handle =====
// Outer walls of this fixture are partial (their records span two rooms) and
// stay disabled; the shared wall moves the faces of both rooms.
// The drag starts on a quiet card. A host render ends the live layer of a
// held drag (`updated()` commits it) and nothing repaints it until the next
// accepted move. The rename's toast expires 3.5 s after the save; on a fast
// runner that landed after the last move (Validate run 36875756451), and the
// live frame below found no live layer. The settled frame is taken on
// purpose, by a state change, after the live one.
const toastDeadline = performance.now() + 5000;
while (root().querySelector('[data-hp="toast"]') && performance.now() < toastDeadline)
await new Promise((done) => setTimeout(done, 30));
out.ac2cStartsWithoutAToast = !root().querySelector('[data-hp="toast"]');
await settled();
await hp.setTool('resize');
diag.ac2cStored = shown();
const handle = [...root().querySelectorAll('.rszhandle:not(.rszcorner)')].find((node) =>
Math.abs(Number(node.getAttribute('cx')) - 500) < 0.5
&& Math.abs(Number(node.getAttribute('cy')) - 325) < 0.5);
out.ac2cHandleEnabled = handle?.getAttribute('aria-disabled') === 'false';
const box = handle?.getBoundingClientRect();
const leftBy = new DOMPoint(450, 325).matrixTransform(root().querySelector('.plan-svg').getScreenCTM());
out.ac2cGesture = box ? {
x: box.left + box.width / 2, y: box.top + box.height / 2, toX: leftBy.x,
} : null;
return out;
}, fixture);
// ======== AC2c: hold a real drag of the shared wall 50 units into r1 ==========
const { ac2cGesture: gesture, ...checks } = res;
if (gesture) {
await page.mouse.move(gesture.x, gesture.y);
await page.mouse.down();
await page.mouse.move((gesture.x + gesture.toX) / 2, gesture.y, { steps: 3 });
await page.mouse.move(gesture.toX, gesture.y, { steps: 3 });
Object.assign(checks, await page.evaluate(async () => {
const card = window.__card;
const { diag, shown, settled } = window.__hp744;
await settled();
const preview = card._resize.preview;
diag.ac2cPreview = shown();
diag.ac2cPreviewRecord = preview ? structuredClone(preview.sp) : null;
diag.ac2cLiveAreas = (card._resize.liveLabels || []).filter((label) => label.kind === 'area')
.map((label) => [label.roomId, label.text]);
return { ac2cPreviewIsLive: !!preview && preview.space === 'f1' && card._resize.dragging };
}));
// The settled frame of the held drag: a light switched in the house renders
// the host; the settled scene now draws the walls and areas of the preview.
Object.assign(checks, await page.evaluate(async () => {
const card = window.__card;
const { diag, shown, settled } = window.__hp744;
const record = JSON.stringify(card._resize.preview?.sp ?? null);
await card.hass.callService('light', 'toggle', { entity_id: 'light.ceiling' });
await settled();
diag.ac2cSettled = shown();
return {
ac2cStateChangeKeepsThePreview: card._resize.dragging
&& JSON.stringify(card._resize.preview?.sp ?? null) === record,
};
}));
await page.keyboard.press('Escape');
await page.mouse.up();
Object.assign(checks, await page.evaluate(async () => {
const card = window.__card;
const { diag, shown, sameShown, oracle, settled } = window.__hp744;
await settled();
const out = {};
const cancelled = shown();
out.ac2cCancelled = !card._resize.dragging && !card._resize.preview;
out.ac2cCancelRestoresTheStoredFrame = sameShown(cancelled, diag.ac2cStored);
const record = diag.ac2cPreviewRecord;
if (record) {
const config = structuredClone(card._serverCfg);
config.spaces = config.spaces.map((space) => (space.id === 'f1' ? record : space));
const fresh = await oracle(config, 'f1');
diag.ac2cOracle = fresh;
out.ac2cOracleReady = fresh.ready;
out.ac2cPreviewMovesBothRooms = diag.ac2cPreview.areas.r1 !== diag.ac2cStored.areas.r1
&& diag.ac2cPreview.areas.r2 !== diag.ac2cStored.areas.r2;
// The live layer draws the moving wall; its faces are where the fresh
// card on the preview record draws them, and not where they were. Should
// a host render still land after the last move, the settled scene draws
// the drag instead, and its walls are judged the same way: a stale
// (stored) union has none of the moved faces.
const numbers = (text) => new Set((text.match(/-?\d+(?:\.\d+)?/g) || []).map(Number));
const parts = (walls, kind) => walls.split(' | ').filter((part) => part.startsWith(`${kind}:`));
const stored = numbers(diag.ac2cStored.walls);
const moved = [...numbers(fresh.walls)].filter((value) => !stored.has(value));
const live = parts(diag.ac2cPreview.walls, 'preview');
const drawn = numbers((live.length ? live : parts(diag.ac2cPreview.walls, 'union')).join(' '));
const missing = moved.filter((value) => !drawn.has(value));
out.ac2cPreviewWallStandsWhereAFreshCardDrawsIt = moved.length > 0 && !missing.length;
// The settled scene mid-drag is the fresh card's frame on the preview
// record: the same union path, the same areas. A key that ignores the
// preview hands it the stored union and contours.
const union = (frame) => parts(frame?.walls || '', 'union').join(' | ');
const settledUnion = union(diag.ac2cSettled);
out.ac2cSettledFrameEqualsAFreshCard = !!settledUnion && settledUnion === union(fresh)
&& JSON.stringify(diag.ac2cSettled.areas) === JSON.stringify(fresh.areas);
diag.ac2cJudged = {
layer: live.length ? 'preview' : 'union', moved: moved.length, missing,
settledUnion: !settledUnion ? 'none' : settledUnion === union(fresh) ? 'fresh'
: settledUnion === union(diag.ac2cStored) ? 'stored' : 'other',
};
// Room areas through the cached contour, and the live labels beside the
// moving wall, are the fresh card's numbers for the preview record.
out.ac2cPreviewAreasEqualAFreshCard = JSON.stringify(diag.ac2cPreview.areas) === JSON.stringify(fresh.areas);
out.ac2cLiveAreaLabelsEqualAFreshCard = diag.ac2cLiveAreas.length === 2
&& diag.ac2cLiveAreas.every(([roomId, text]) => fresh.areas[roomId] === text);
}
diag.ac2cCancelled = cancelled;
return out;
}));
if (!checks.ac2cPreviewWallStandsWhereAFreshCardDrawsIt || !checks.ac2cSettledFrameEqualsAFreshCard) {
// Validate prints only `diagnostic` lines and the tail of a failed smoke.
const judged = await page.evaluate(() => window.__hp744.diag.ac2cJudged);
console.log(`diagnostic ac2c: ${JSON.stringify(judged)}`);
}
}
checkAll(checks, {
ac1GardenVisitBuildsNoWallUnion: 0,
ac1GardenVisitBuildsNoContour: 0,
ac1GardenVisitBuildsNoCleanFloor: 0,
ac2bGardenCleanFloorsKept: [],
});
if (process.env.HP_744_DIAG) console.log(JSON.stringify(await page.evaluate(() => window.__hp744.diag), null, 1));
await finish(browser, checks);