Files
houseplan-card/demo/smoke_floor_geometry_cache.mjs
T
Claude 5a1b0c20d4 refactor(cache): one source for the wall-union and bodies keys and pool entry (#769)
The #744 key formats were copied: the wall-union key `${floorKey}|${rooms}`
in the card's miss branch and three times in the resize runtime, the
physical-bodies key `${floorKey}|${cellCm}|${gridPitch}` in the card, the
runtime twice and the LED editor, the pool bound 8 as a literal three times
and the `sourceFingerprint` attachment twice. A copy that drifts makes the
resize lookup and the cancel alias stop hitting without any error.

src/floor-geometry-key.ts now owns `wallUnionKey`, `physicalBodiesKey`,
`WALL_UNION_POOL_LIMIT`, the entry factory `wallUnionPoolEntry` (it attaches
`lightGeometryFingerprint` of the record the union was built from) and
`writeWallUnionPool`. The card's miss branch, `_rszEdgeDown`, the
`_rszCancelDrag` alias and both bodies re-keys use only them; the LED editor
keys its own bodies with the same helper. `_rszEdgeDown` now reads the key
of the shown floor's model, as the card does, instead of its own room list.

The union seed in `_rszAcceptPreview` is removed, not moved: since #451 the
live preflight publishes no artifact (`artifact: null`), so the seed never
ran. A host render mid-drag builds the preview union once through the card's
miss branch and every later render hits it. The bodies re-key stays: a
resize never moves partitions, columns or a partition opening's host, and
the smoke proves the re-keyed bodies equal a fresh card's on the preview
record, a door cut into a partition next to the moving wall included.

AC8 in demo/smoke_floor_geometry_cache.mjs on the held AC2c drag: (a) host
renders mid-drag build no union and the drawn union equals a fresh card's on
the preview record, paths and `sourceFingerprint`; (b) cancel and the way back
to View build no union, bodies, contours or clean floors, and the union and
bodies equal a fresh card's on the stored record; (c) the drag never rebuilds
the bodies. Unit: the key formats, the bound and the entry
(test/floor-geometry-key.test.mjs) and a source contract that no copy of the
formats, the pool write or the fingerprint attachment is left outside the
module (test/performance-contract.test.mjs, red on dev). Two registry mutants
(bodies re-key in its own format, union fingerprint from the stored record),
caught by the smoke and checked by hand, as were the two #744 mutants whose
anchor moved; browser inventory 247/200.

Budgets: initial View 303032 B gzip (ceiling 302803 + 2000), lazy editor
245094 B (ceiling 244872 + 2000); monolith ratchet unchanged.

Issue: #769
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-07 05:21:52 +03:00

524 lines
28 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.
// #769 AC8 one source of the union and bodies keys (src/floor-geometry-key.ts),
// judged on the AC2c drag. (a) A host render mid-drag draws the union a
// fresh card builds on the preview record — paths and `sourceFingerprint`
// — building it once (no seed since #451, see `_rszAcceptPreview`); a
// second host render at the same preview builds nothing. (b) Cancel and
// the way back to View build no union, bodies, contours or floors, and the
// union is the fresh card's on the stored record. (c) The bodies re-keyed
// for the preview are the fresh card's, a door cut into a partition next
// to the moving wall included.
// #769 AC7 a planted cold build the size guard cannot see: the union pool is
// full (8), `garden`'s union is gone while its contours and clean floors
// stay warm. A four-switch cycle builds exactly that union — the build
// counter grows by one, it evicts one entry, every size stays — and the
// #735 switch-cycle decision names the wall union.
//
// 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';
import { floorCacheBuilds, floorCacheSnapshot, judgeSwitchCycle } from './performance/switch-cycle-guard.mjs';
const { page, browser } = await launch({ width: 1100, height: 850 });
// #769: the same snapshot, counters and decision as the benchmark's #735 guard.
await page.addScriptTag({
content: `window.__hpSwitchCycleGuard = { snapshot: ${floorCacheSnapshot.toString()}, `
+ `builds: ${floorCacheBuilds.toString()}, judge: ${judgeSwitchCycle.toString()} };`,
});
/** 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 },
// #769 AC8 c: a door cut into a partition beside the wall the drag moves.
{ id: 'f1-door-wall', a: [0.52, 0.32], b: [0.77, 0.32], cm: 10 },
],
openings: [{
id: 'f1-door', type: 'door', x: 0.5575, y: 0.32, angle: 0, length: 0.04,
host: { kind: 'partition', id: 'f1-door-wall', t: 0.15 },
}],
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);
// #769 AC8: the union and the physical bodies the floor's caches hold.
const unionOf = (host = card) => {
const value = host._wallUnionCache?.value;
return value ? JSON.stringify({
status: value.status, d: value.d, paths: value.paths, paperD: value.paperD,
depthUnits: value.depthUnits, openingPadUnits: value.openingPadUnits ?? null,
fillRule: value.fillRule, sourceFingerprint: value.sourceFingerprint ?? null,
}) : null;
};
const bodiesOf = (host = card) => {
const frame = host._physicalBodiesCache;
return frame ? JSON.stringify({
partitions: frame.partitions, columns: frame.columns, patches: frame.patches, all: frame.all,
}) : null;
};
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), union: unionOf(fresh), bodies: bodiesOf(fresh), ready: fresh._cfgRev === rev,
};
host.remove();
return value;
};
window.__hp744 = { diag, shown, sameShown, oracle, settled, writesIdle, unionOf, bodiesOf };
// ---- 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();
const buildsBeforeVisit = window.__hpSwitchCycleGuard.builds(card);
await hp.switchSpace('garden');
const afterVisit = sizes();
const buildsAfterVisit = window.__hpSwitchCycleGuard.builds(card);
// #769: the same visit by the build counters, which a full LRU cannot hide.
out.ac1GardenVisitBuildsNothingByCounter = Object.fromEntries(['wallUnion', 'innerContour', 'cleanFloor']
.map((family) => [family, buildsAfterVisit[family] - buildsBeforeVisit[family]]));
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;
diag.ac8BuildsBeforeDrag = window.__hpSwitchCycleGuard.builds(card);
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 { unionOf, bodiesOf } = window.__hp744;
const builds = () => window.__hpSwitchCycleGuard.builds(card);
const growth = (before, after) => Object.fromEntries(Object.keys(after)
.map((family) => [family, after[family] - before[family]]));
const record = JSON.stringify(card._resize.preview?.sp ?? null);
const beforeHostRender = builds();
await card.hass.callService('light', 'toggle', { entity_id: 'light.ceiling' });
await settled();
diag.ac2cSettled = shown();
// #769 AC8 a/c: the union of this preview already exists — built once by
// its first reader, nothing seeds it (see `_rszAcceptPreview`) — so host
// renders mid-drag build no union, and the bodies re-keyed on every
// accepted frame are never rebuilt through the whole drag. Clean floors
// are uncached during a preview by design (#744) and are not judged here.
diag.ac8aUnion = unionOf();
diag.ac8cBodies = bodiesOf();
await card.hass.callService('light', 'toggle', { entity_id: 'light.ceiling' });
await settled();
diag.ac8bBuildsBeforeCancel = builds();
diag.ac8aUnionBuildsOnTheWayToTheFirstHostRender = beforeHostRender.wallUnion - diag.ac8BuildsBeforeDrag.wallUnion;
return {
ac2cStateChangeKeepsThePreview: card._resize.dragging
&& JSON.stringify(card._resize.preview?.sp ?? null) === record,
ac8aHostRendersMidDragBuildNoUnion: growth(beforeHostRender, diag.ac8bBuildsBeforeCancel).wallUnion,
ac8aTheUnionIsStillTheDrawnOne: unionOf() === diag.ac8aUnion,
ac8cTheDragNeverRebuildsTheBodies: growth(diag.ac8BuildsBeforeDrag, diag.ac8bBuildsBeforeCancel).physicalBodies,
};
}));
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;
// #769 AC8 b: back in View after the cancel, nothing of the floor is built
// and the union is the fresh card's on the stored record.
const { unionOf, bodiesOf } = window.__hp744;
await window.__hpTest.setMode('view');
const after = window.__hpSwitchCycleGuard.builds(card);
out.ac8bCancelAndViewBuildNoUnionBodiesContoursOrFloors = ['wallUnion', 'physicalBodies', 'innerContour', 'cleanFloor']
.map((family) => after[family] - diag.ac8bBuildsBeforeCancel[family]);
const stored = await oracle(structuredClone(card._serverCfg), 'f1');
out.ac8bStoredOracleReady = stored.ready;
out.ac8bTheUnionEqualsAFreshCardOnTheStoredRecord = !!stored.union && unionOf() === stored.union;
out.ac8bTheBodiesEqualAFreshCardOnTheStoredRecord = !!stored.bodies && bodiesOf() === stored.bodies;
if (diag.ac2cOracle) {
const fresh = diag.ac2cOracle;
out.ac8aTheDrawnUnionEqualsAFreshCardOnThePreview = !!fresh.union && diag.ac8aUnion === fresh.union;
out.ac8cTheReKeyedBodiesEqualAFreshCardOnThePreview = !!fresh.bodies && diag.ac8cBodies === fresh.bodies;
const bodies = JSON.parse(fresh.bodies || 'null');
out.ac8cTheFixtureCutsAPartitionWithADoor = !!bodies
&& bodies.all.length > bodies.partitions.length + bodies.columns.length + bodies.patches.length;
}
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)}`);
}
}
// ======== AC7 (#769): a cold union in a full pool, every size unchanged =======
Object.assign(checks, await page.evaluate(async () => {
const card = window.__card;
const hp = window.__hpTest;
const guard = window.__hpSwitchCycleGuard;
const out = {};
await hp.setMode('view');
await hp.switchSpace('garden');
const gardenKey = card._wallUnionCache?.key;
await hp.switchSpace('f1');
const f1Key = card._wallUnionCache?.key;
const pool = card._wallUnionPool;
out.ac7BothFloorsPooled = !!gardenKey && !!f1Key && pool.has(gardenKey) && pool.has(f1Key);
// The planted state of the contract (#769 AC7): no product path evicts one
// floor's union while its contours stay warm on a two-floor plan, so the pool
// is filled by hand. Read-only everywhere else.
pool.delete(gardenKey); // private-ok: #769 AC7 planted eviction of garden's union
for (let filler = 0; pool.size < 8; filler++) {
pool.set(`filler-${filler}`, { key: `filler-${filler}`, value: null }); // private-ok: #769 AC7 fills the LRU to its limit
}
const f1Entry = pool.get(f1Key);
pool.delete(f1Key); // private-ok: #769 AC7 keeps the shown floor most recent
pool.set(f1Key, f1Entry); // private-ok: #769 AC7 keeps the shown floor most recent
out.ac7PoolFullWithoutGarden = pool.size === 8 && !pool.has(gardenKey) && pool.has(f1Key);
const sizesBefore = guard.snapshot(card);
const buildsBefore = guard.builds(card);
const switches = 4;
for (let index = 0; index < switches; index++) await hp.switchSpace(index % 2 ? 'f1' : 'garden');
const sizesAfter = guard.snapshot(card);
const buildsAfter = guard.builds(card);
const verdict = guard.judge({ before: buildsBefore, after: buildsAfter, switches });
out.ac7TheCycleBuildsTheUnionOnce = buildsAfter.wallUnion - buildsBefore.wallUnion;
out.ac7ItsContoursAndFloorsStayWarm = [
buildsAfter.innerContour - buildsBefore.innerContour, buildsAfter.cleanFloor - buildsBefore.cleanFloor,
];
out.ac7TheUnionIsBackAndOneEntryEvicted = pool.has(gardenKey) && pool.size === 8;
out.ac7EverySizeStays = JSON.stringify(sizesAfter) === JSON.stringify(sizesBefore);
// The guard before #769: green, nothing grew.
out.ac7TheSizeGuardSeesNothing = Object.keys(sizesAfter).filter((key) => sizesAfter[key] > sizesBefore[key]);
out.ac7TheDecisionNamesTheWallUnion = verdict.failures;
return out;
}));
checkAll(checks, {
ac1GardenVisitBuildsNothingByCounter: { wallUnion: 0, innerContour: 0, cleanFloor: 0 },
ac8aHostRendersMidDragBuildNoUnion: 0,
ac8cTheDragNeverRebuildsTheBodies: 0,
ac8bCancelAndViewBuildNoUnionBodiesContoursOrFloors: [0, 0, 0, 0],
ac7TheCycleBuildsTheUnionOnce: 1,
ac7ItsContoursAndFloorsStayWarm: [0, 0],
ac7TheSizeGuardSeesNothing: [],
ac7TheDecisionNamesTheWallUnion: ['wall union +1'],
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);