mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 23:19:14 +00:00
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
524 lines
28 KiB
JavaScript
524 lines
28 KiB
JavaScript
// #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);
|