test(geometry): smoke and mutants for floor cache reuse (#814)

demo/smoke_floor_cache_reuse.mjs judges the shown card against a fresh
card on the same config (no cache to reuse) and by its build counters:
- AC1: a Home Assistant update, a pushed edit of another floor and a
  pushed shared setting build no opening index, wedges, tunnels or
  bodies; a window moved in place without a new epoch rebuilds the
  wedges once; a Resize commit with its WS write held shows the commit
  before the ack, and the rejected write rolls back to the fresh card's
  geometry;
- AC2: three warm floors, the twelve-switch cycle judged with `pooled`
  builds no bodies or tunnels; full planted pools show the evicting
  miss by its counter with every size unchanged; a pushed partition
  builds the current bodies;
- AC3: one union per Resize gesture (cancel, commit, Undo, Redo,
  another floor, a server push mid-drag that ends the gesture), the
  live areas and the opening wall index of each held preview are the
  fresh card's on the preview record;
- AC7: config replacement, a mode round trip, disconnect/reconnect and
  a warm remount keep no stale geometry and bounded pools.
The fixture copies wall endpoints: a config from the server never
shares arrays, and the in-place adoption of a commit rewrites a room
vertex through an alias (recorded as a #814 finding).

Thirteen registry mutants, each checked by hand (patch, guard, revert):
the epoch back in either key, keys without wall or window geometry, a
single-slot pool, a bypassed bodies pool, an uncounted evicting tunnel
miss, a guard blind to pools, the union memo off, keyed by scale only,
a failed union read as empty, a union kept after a reset, and the live
Resize preflight skipped (AC4, smoke_room_resize). Six are browser
guards, recorded in the inventory (263 -> 269 of the 200 guideline).
The #577 origin mutant follows the moved sun key.

Issue: #814
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Claude
2026-10-07 20:19:14 +00:00
committed by claude[bot]
parent a02939cbb8
commit 883376a043
3 changed files with 738 additions and 5 deletions
+566
View File
@@ -0,0 +1,566 @@
// #814: prepared floor geometry stays usable; nothing stale is ever shown.
//
// AC1 the opening wall index and the sun wedges are keyed by their inputs,
// not by the global config epoch. A Home Assistant update, an edit of
// another floor and a shared setting build nothing on the shown floor; a
// window moved in place (no new epoch), a Resize commit whose WS write is
// still held, and the rollback of that write after the server rejects it
// each show what an independent card computes on the same config.
// AC2 physical bodies and opening tunnels are pooled per floor (≤ 8, LRU):
// after the three floors are warm, the twelve-switch cycle builds
// neither, judged by the #735 guard with `pooled`; a planted full pool
// shows the evicting miss by its counter while every size stays; a
// server push that moves a partition builds the new bodies, and they are
// the independent card's.
// AC3 Resize measures every resized room on every accepted step against one
// union of the unchanged bodies: one union per gesture (cancel, commit,
// a repeated gesture, Undo, Redo, another floor), a server push mid-drag
// ends the gesture and drops its union, the live areas are the
// independent card's, and so is the opening wall index of every held
// preview (AC1).
// AC7 config replacement, a mode change, disconnect/reconnect and a warm
// remount keep no stale geometry and no unbounded pool.
//
// 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 except where a line says `private-ok`; writes
// go through the harness facade, real pointer input and `card.hass`.
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 });
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`. The endpoints
* are copies: a config read from the server never shares arrays, and the
* in-place adoption of a commit would rewrite a room vertex through an alias.
*/
const wallsOf = (rooms, cm) => {
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, a: [...a], b: [...b] });
});
}
return [...byKey.values()];
};
const rect = (x0, y0, x1, y1) => [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
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 twoRooms = (prefix) => [
{ id: `${prefix}1`, name: `${prefix} west`, poly: rect(0.1, 0.2, 0.5, 0.8) },
{ id: `${prefix}2`, name: `${prefix} east`, poly: rect(0.5, 0.2, 0.9, 0.8) },
];
const floor = (rooms, extra) => ({ rooms, walls: wallsOf(rooms, 15), cell_cm: 5, ...extra });
// The sun stands south-east of a north-up plan: it enters the east and south windows.
const fixture = {
f1: floor(f1Rooms, {
partitions: [
{ id: 'f1-wall', a: [0.6, 0.62], b: [0.8, 0.62], cm: 10 },
{ 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 } },
{ id: 'f1-east', type: 'window', x: 0.9, y: 0.3, angle: 90, length: 0.06 },
{ id: 'f1-south', type: 'window', x: 0.7, y: 0.85, angle: 0, length: 0.06 },
],
wall_columns: [{ id: 'f1-column', shape: 'circle', center: [0.7, 0.4], cm: 20 }],
}),
garden: floor(twoRooms('g'), {
partitions: [{ id: 'garden-wall', a: [0.2, 0.5], b: [0.4, 0.5], cm: 10 }],
openings: [{ id: 'g-east', type: 'window', x: 0.9, y: 0.5, angle: 90, length: 0.06 }],
wall_columns: [{ id: 'garden-column', shape: 'square', center: [0.7, 0.5], cm: 20, angle: 0 }],
}),
loft: floor(twoRooms('l'), {
partitions: [{ id: 'loft-wall', a: [0.6, 0.4], b: [0.8, 0.4], cm: 12 }],
openings: [{ id: 'l-south', type: 'window', x: 0.3, y: 0.8, angle: 0, length: 0.06 }],
wall_columns: [{ id: 'loft-column', shape: 'circle', center: [0.3, 0.4], cm: 25 }],
}),
};
// ---- in-page helpers, shared by every part ----------------------------------
await page.evaluate(async (fixture) => {
const card = window.__card;
const hp = window.__hpTest;
const frames = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const settled = async () => { await hp.settled(); await frames(); await (window.__card.updateComplete); };
// What a floor shows and holds: walls, areas, bodies, tunnels, index, sun.
const look = (host = window.__card) => {
const space = host._spaceModel();
return {
space: space?.id ?? null,
walls: [...host.renderRoot.querySelectorAll('[data-hp="wall"]')]
.map((node) => `${node.getAttribute('data-kind')}:${node.getAttribute('d')}`).join(' | '),
areas: JSON.stringify(Object.fromEntries((space?.rooms || []).map((room) => [room.id, host._roomArea(room)]))),
bodies: JSON.stringify(host._physicalBodiesCache?.all ?? null),
tunnels: JSON.stringify(host._openingTunnelCache?.value ?? null),
index: space ? JSON.stringify(host._openingWallIndexFor(space, host._openCuts()).value) : null,
sun: JSON.stringify(host._sunRaysCache?.rays ?? null),
};
};
const differs = (a, b) => Object.keys(a).filter((key) => a[key] !== b[key]);
let oracleRev = 900000;
/** A fresh card on `config`, shown on `spaceId` in View: what it looks like. */
const oracle = async (config, spaceId) => {
const rev = ++oracleRev;
const base = window.__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 = { ...look(fresh), ready: fresh._cfgRev === rev };
host.remove();
return value;
};
/** The shown floor against a fresh card on the card's own config. */
const sameAsFresh = async (host = window.__card) => {
const shown = look(host);
const fresh = await oracle(structuredClone(host._serverCfg), shown.space);
const { ready, ...expected } = fresh;
return ready ? differs(shown, expected) : ['oracle not ready'];
};
const builds = () => ({ ...window.__card._floorCacheBuilds });
const grew = (before, after = builds(), families = Object.keys(after)) => Object.fromEntries(
families.map((family) => [family, after[family] - before[family]]));
const sunState = (azimuth, elevation) => ({
entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth, elevation, rising: false },
});
/** A Home Assistant update that touches no geometry (a public `hass` assignment). */
const haTick = async (extra = {}) => {
const current = window.__card.hass;
const light = current.states['light.ceiling'];
window.__card.hass = { ...current, states: { ...current.states,
'light.ceiling': { ...light, state: light.state === 'on' ? 'off' : 'on' }, ...extra } };
await settled();
};
window.__hp814 = { look, differs, oracle, sameAsFresh, builds, grew, sunState, haTick, settled, frames };
// ---- fixture: three floors, walls on every edge, bodies, windows, sun ------
await hp.setServerConfig((cfg) => {
const template = cfg.spaces.find((space) => space.id === 'garden');
if (!cfg.spaces.some((space) => space.id === 'loft'))
cfg.spaces.push({ ...structuredClone(template), id: 'loft', title: 'Loft' });
for (const space of cfg.spaces) {
const own = fixture[space.id];
if (!own) continue;
Object.assign(space, structuredClone(own));
space.settings = { ...(space.settings || {}), show_borders: true };
delete space.stairs;
delete space.segments;
}
cfg.settings = { ...(cfg.settings || {}), sun_rays: true, north_deg: 0 };
return cfg;
});
card.hass = { ...card.hass, states: { ...card.hass.states, 'sun.sun': sunState(135, 30) } };
await hp.switchSpace('f1');
await settled();
}, fixture);
// ======================= AC1: keys, not the global epoch =====================
const ac1 = await page.evaluate(async () => {
const card = window.__card;
const hp = window.__hpTest;
const { look, sameAsFresh, builds, grew, haTick, settled } = window.__hp814;
const out = {};
const families = ['openingWallIndex', 'sunRays', 'openingTunnel', 'physicalBodies'];
const shown = look();
out.fixtureShowsSunWallsBodiesAndTunnels = shown.space === 'f1' && JSON.parse(shown.sun)?.length > 0
&& shown.walls.includes('union:') && JSON.parse(shown.bodies)?.length >= 3 && JSON.parse(shown.tunnels)?.length >= 2;
out.fixtureIsTheFreshCards = await sameAsFresh();
// A Home Assistant update, another floor's edit, a shared setting.
let before = builds();
const sunBefore = card._sunRaysCache;
await haTick();
out.haUpdateBuildsNothing = grew(before, builds(), families);
out.haUpdateKeepsTheWedges = card._sunRaysCache === sunBefore;
before = builds();
const epoch = card._cfgEpoch;
await hp.setServerConfig((cfg) => {
cfg.spaces.find((space) => space.id === 'garden').partitions[0].b = [0.45, 0.5];
return cfg;
});
out.foreignFloorPushIsANewEpoch = card._cfgEpoch > epoch;
out.foreignFloorPushBuildsNothing = grew(before, builds(), families);
before = builds();
await hp.setServerConfig((cfg) => {
cfg.settings = { ...cfg.settings, fill_colors: { ...(cfg.settings.fill_colors || {}), light_on: '#ffcc00' } };
return cfg;
});
out.sharedSettingBuildsNothing = grew(before, builds(), families);
out.stillTheFreshCards = await sameAsFresh();
// A window moved in place, without a new epoch: the wedges follow it.
before = builds();
const epochInPlace = card._cfgEpoch;
const east = card._serverCfg.spaces.find((space) => space.id === 'f1').openings.find((o) => o.id === 'f1-east');
east.y = 0.36; // private-ok: #814 AC1 a same-object mutation, no new epoch
card.requestUpdate();
await settled();
out.inPlaceMoveKeepsTheEpoch = card._cfgEpoch === epochInPlace;
out.inPlaceMoveRebuildsTheWedgesOnce = grew(before, builds(), ['sunRays']).sunRays;
// The wall union and the clean floors keep the #744 floor-record contract
// (a product edit is a new epoch, see AC1 below); the index, the tunnels and
// the wedges read their inputs on every frame.
out.inPlaceMoveIsTheFreshCards = (await sameAsFresh()).filter((key) => key !== 'walls' && key !== 'areas');
return out;
});
/** Real pointer drag of the Resize handle at plan point (x, y) to plan x `toX`. */
const dragHandle = async ({ x, y, toX, steps = 3, end = 'up' }) => {
const gesture = await page.evaluate(([x, y, toX]) => {
const root = window.__card.renderRoot;
const handle = [...root.querySelectorAll('.rszhandle:not(.rszcorner)')].find((node) =>
Math.abs(Number(node.getAttribute('cx')) - x) < 0.5 && Math.abs(Number(node.getAttribute('cy')) - y) < 0.5);
if (!handle || handle.getAttribute('aria-disabled') !== 'false') {
return { missing: [...root.querySelectorAll('.rszhandle:not(.rszcorner)')].map((node) =>
`${node.getAttribute('cx')},${node.getAttribute('cy')}:${node.getAttribute('aria-disabled')}`).join(' '),
space: window.__card._space, tool: window.__card._tool,
rooms: (window.__card._spaceModel()?.rooms || []).map((room) => `${room.id}:${JSON.stringify(room.poly)}`),
record: JSON.stringify(window.__card._serverCfg.spaces.find((sp) => sp.id === window.__card._space)?.rooms) };
}
const box = handle.getBoundingClientRect();
const ctm = root.querySelector('.plan-svg').getScreenCTM();
const target = new DOMPoint(toX, y).matrixTransform(ctm);
return { x: box.left + box.width / 2, y: box.top + box.height / 2, toX: target.x };
}, [x, y, toX]);
if (gesture.missing !== undefined) {
// Validate prints only `diagnostic` lines and the tail of a failed smoke.
console.log(`diagnostic resize handle ${x},${y}: ${JSON.stringify(gesture)}`);
return false;
}
await page.mouse.move(gesture.x, gesture.y);
await page.mouse.down();
for (let step = 1; step <= steps; step++) {
await page.mouse.move(gesture.x + ((gesture.toX - gesture.x) * step) / steps, gesture.y, { steps: 2 });
await page.evaluate(() => window.__hp814.frames());
}
if (end === 'escape') await page.keyboard.press('Escape');
if (end !== 'hold') await page.mouse.up();
await page.evaluate(() => window.__hp814.settled());
return true;
};
// ======================= AC2: warm floors, bounded pools =====================
const ac2 = await page.evaluate(async () => {
const card = window.__card;
const hp = window.__hpTest;
const guard = window.__hpSwitchCycleGuard;
const { look, sameAsFresh, builds, grew } = window.__hp814;
const out = {};
const floors = ['f1', 'garden', 'loft'];
for (const id of floors) await hp.switchSpace(id);
await hp.switchSpace('garden');
const before = guard.builds(card);
const switches = 12;
for (let index = 0; index < switches; index++) await hp.switchSpace(floors[index % floors.length]);
const verdict = guard.judge({ before, after: guard.builds(card), switches, pooled: true });
out.warmCycleVerdict = verdict.failures;
out.warmCycleBuildsNoBodiesOrTunnels = [verdict.builds.physicalBodies, verdict.builds.openingTunnel];
const pools = () => [card._physicalBodiesPool.size, card._openingTunnelPool.size];
out.poolsHoldTheFloorsWithinEight = pools().every((size) => size >= floors.length && size <= 8);
// The planted state: both pools full (8), garden's entries gone, f1 most recent.
await hp.switchSpace('garden');
const garden = [card._physicalBodiesCache.key, card._openingTunnelCache.key];
await hp.switchSpace('f1');
const f1 = [card._physicalBodiesCache.key, card._openingTunnelCache.key];
const plant = (pool, gone, kept, filler) => {
pool.delete(gone); // private-ok: #814 AC2 planted eviction of garden's entry
for (let index = 0; pool.size < 8; index++) pool.set(`filler-${index}`, filler(`filler-${index}`)); // private-ok: #814 AC2 fills the pool to its limit
const entry = pool.get(kept);
pool.delete(kept); // private-ok: #814 AC2 keeps the shown floor most recent
pool.set(kept, entry); // private-ok: #814 AC2 keeps the shown floor most recent
};
plant(card._physicalBodiesPool, garden[0], f1[0],
(key) => ({ key, partitions: [], columns: [], patches: [], all: [] }));
plant(card._openingTunnelPool, garden[1], f1[1], (key) => ({ key, value: [] }));
out.plantedPoolsAreFullWithoutGarden = pools().join() === '8,8'
&& !card._physicalBodiesPool.has(garden[0]) && !card._openingTunnelPool.has(garden[1]);
const sizesBefore = guard.snapshot(card);
const plantedBefore = guard.builds(card);
for (let index = 0; index < 4; index++) await hp.switchSpace(index % 2 ? 'f1' : 'garden');
const planted = guard.judge({ before: plantedBefore, after: guard.builds(card), switches: 4, pooled: true });
out.evictingMissIsCounted = planted.failures;
out.everySizeStays = JSON.stringify(guard.snapshot(card)) === JSON.stringify(sizesBefore);
out.gardenIsBackInBothPools = card._physicalBodiesPool.has(garden[0]) && card._openingTunnelPool.has(garden[1]);
// A geometry change builds the current value: a partition pushed while the loft is shown.
await hp.switchSpace('loft');
const loftBefore = builds();
await hp.setServerConfig((cfg) => {
cfg.spaces.find((space) => space.id === 'loft').partitions[0].a = [0.55, 0.45];
return cfg;
});
out.movedPartitionBuildsTheLoftBodiesOnce = grew(loftBefore, builds(), ['physicalBodies']).physicalBodies;
out.movedPartitionIsTheFreshCards = await sameAsFresh();
out.poolsStayWithinEight = pools().every((size) => size <= 8);
return out;
});
checkAll(ac2, {
warmCycleVerdict: [],
warmCycleBuildsNoBodiesOrTunnels: [0, 0],
evictingMissIsCounted: ['physical bodies +1', 'opening tunnel +1'],
movedPartitionBuildsTheLoftBodiesOnce: 1,
movedPartitionIsTheFreshCards: [],
});
// ======================= AC3: one union per Resize gesture ===================
await page.evaluate(async () => {
const card = window.__card;
window.__hp814.unions = () => card._editorRuntime.rszBodiesUnion.builds;
/** The live area labels of the held drag against a fresh card on its preview record. */
window.__hp814.liveAreasAgainstFresh = async () => {
const preview = card._resize.preview;
if (!preview || !card._resize.dragging) return { error: 'no held preview' };
const config = structuredClone(card._serverCfg);
config.spaces = config.spaces.map((space) => (space.id === preview.space ? structuredClone(preview.sp) : space));
const fresh = await window.__hp814.oracle(config, preview.space);
const areas = JSON.parse(fresh.areas);
const labels = (card._resize.liveLabels || []).filter((label) => label.kind === 'area');
return { rooms: labels.length, wrong: labels.filter((label) => areas[label.roomId] !== label.text)
.map((label) => `${label.roomId}: ${label.text} vs ${areas[label.roomId]}`), ready: fresh.ready,
// #814 AC1: the opening wall index of the preview is the fresh card's.
index: window.__hp814.look().index === fresh.index };
};
await window.__hpTest.setMode('plan');
await window.__hpTest.switchSpace('f1');
await window.__hpTest.setTool('resize');
});
/** One held gesture: its union count, accepted steps and live areas. */
const heldGesture = async (handle, end, during) => {
const before = await page.evaluate(() => ({ unions: window.__hp814.unions(), epoch: window.__card._cfgEpoch }));
const dragged = await dragHandle({ ...handle, end: 'hold' });
if (during) await during();
const held = await page.evaluate(async (before) => ({
unions: window.__hp814.unions() - before.unions,
steps: window.__card._cfgEpoch - before.epoch,
areas: await window.__hp814.liveAreasAgainstFresh(),
}), before);
if (end === 'escape') await page.keyboard.press('Escape');
await page.mouse.up();
await page.evaluate(() => window.__hp814.settled());
const cleared = await page.evaluate(() => window.__card._editorRuntime.rszBodiesUnion.last === null);
return { dragged, ...held, cleared };
};
const f1Wall = { x: 500, y: 325, toX: 440, steps: 3 };
const gardenWall = { x: 500, y: 500, toX: 440, steps: 3 };
// Plan mode draws neither the wedges nor the tunnels: the fresh card's View does.
const planFresh = () => page.evaluate(async () => (await window.__hp814.sameAsFresh())
.filter((key) => key !== 'sun' && key !== 'tunnels'));
const ac3 = {};
ac3.cancelledGesture = await heldGesture(f1Wall, 'escape');
await page.evaluate(async () => {
await window.__hpTest.switchSpace('garden');
await window.__hpTest.setTool('resize');
});
ac3.otherFloorGesture = await heldGesture(gardenWall, 'escape');
// A server push mid-drag that changes the cell size (so the bodies) ends the
// gesture and drops its union; the next gesture unites the new bodies.
ac3.serverPushMidDrag = {};
await dragHandle({ ...gardenWall, end: 'hold' });
Object.assign(ac3.serverPushMidDrag, await page.evaluate(async () => {
const card = window.__card;
const held = card._resize.dragging && card._editorRuntime.rszBodiesUnion.last !== null;
await window.__hpTest.setServerConfig((cfg) => {
cfg.spaces.find((space) => space.id === 'garden').cell_cm = 10;
return cfg;
});
return { held, endsTheGesture: !card._resize.dragging, dropsTheUnion: card._editorRuntime.rszBodiesUnion.last === null };
}));
await page.mouse.up();
await page.evaluate(() => window.__hp814.settled());
ac3.gestureAfterServerPush = await heldGesture(gardenWall, 'escape');
// ---- AC1: a Resize commit whose WS write is held, then rejected --------------
await page.evaluate(async () => {
const card = window.__card;
// Hold every config write until the smoke releases or rejects it.
const base = card.hass;
const held = [];
window.__hp814.held = held;
card.hass = { ...base, callWS: (message) => message.type !== 'houseplan/config/set' ? base.callWS(message)
: new Promise((resolve, reject) => held.push({ resolve: () => resolve(base.callWS(message)), reject })) };
await window.__hpTest.setMode('view');
await window.__hpTest.switchSpace('f1');
window.__hp814.ac1Before = window.__hp814.look();
await window.__hpTest.setMode('plan');
await window.__hpTest.setTool('resize');
window.__hp814.ac1Rev = card._cfgRev;
});
const ac1Committed = await dragHandle({ x: 500, y: 325, toX: 450 });
Object.assign(ac1, await page.evaluate(async (committed) => {
const card = window.__card;
const { look, sameAsFresh, held, settled } = window.__hp814;
const out = { heldCommitDragged: committed };
const deadline = performance.now() + 3000;
while (!held.length && performance.now() < deadline) await new Promise((done) => setTimeout(done, 20));
out.theWriteIsHeld = held.length === 1 && card._cfgRev === window.__hp814.ac1Rev;
await window.__hpTest.setMode('view');
const local = look();
out.beforeTheAckTheCommitIsShown = local.index !== window.__hp814.ac1Before.index;
out.beforeTheAckItIsTheFreshCards = await sameAsFresh();
// The server rejects the write: the card rolls the geometry back.
held.shift().reject(Object.assign(new Error('rejected by the smoke'), { code: 'invalid' }));
const until = performance.now() + 5000;
while ((card._writesPending > 0 || card._saveConfigDebounced.pending()) && performance.now() < until)
await new Promise((done) => setTimeout(done, 20));
await settled();
await settled();
const restored = look();
out.theRejectionRestoresTheGeometry = restored.index === window.__hp814.ac1Before.index
&& restored.sun === window.__hp814.ac1Before.sun;
out.afterTheRejectionItIsTheFreshCards = await sameAsFresh();
card.hass = { ...window.__mkHass(), states: { ...card.hass.states } };
await settled();
return out;
}, ac1Committed));
checkAll(ac1, {
fixtureIsTheFreshCards: [],
haUpdateBuildsNothing: { openingWallIndex: 0, sunRays: 0, openingTunnel: 0, physicalBodies: 0 },
foreignFloorPushBuildsNothing: { openingWallIndex: 0, sunRays: 0, openingTunnel: 0, physicalBodies: 0 },
sharedSettingBuildsNothing: { openingWallIndex: 0, sunRays: 0, openingTunnel: 0, physicalBodies: 0 },
stillTheFreshCards: [],
inPlaceMoveRebuildsTheWedgesOnce: 1,
inPlaceMoveIsTheFreshCards: [],
beforeTheAckItIsTheFreshCards: [],
afterTheRejectionItIsTheFreshCards: [],
});
await page.evaluate(async () => {
await window.__hpTest.setMode('plan');
await window.__hpTest.switchSpace('f1');
await window.__hpTest.setTool('resize');
});
ac3.committedGesture = await heldGesture(f1Wall, 'up');
ac3.committedIsTheFreshCards = await planFresh();
const wallAt = (x) => page.evaluate((x) => [...window.__card.renderRoot.querySelectorAll('.rszhandle:not(.rszcorner)')]
.some((node) => Math.abs(Number(node.getAttribute('cx')) - x) < 0.5 && Math.abs(Number(node.getAttribute('cy')) - 325) < 0.5), x);
ac3.commitMovesTheWall = !(await wallAt(500));
await page.keyboard.press('Control+z');
await page.evaluate(() => window.__hp814.settled());
ac3.undoRestoresTheWall = await wallAt(500);
ac3.undoIsTheFreshCards = await planFresh();
ac3.gestureAfterUndo = await heldGesture(f1Wall, 'escape');
await page.keyboard.press('Control+y');
await page.evaluate(() => window.__hp814.settled());
ac3.redoMovesTheWallAgain = !(await wallAt(500));
ac3.redoIsTheFreshCards = await planFresh();
const gesture = (dragged, unions, extra = {}) => ({
dragged, unions, steps: true, areas: { rooms: 2, wrong: [], ready: true, index: true }, cleared: true, ...extra,
});
for (const name of ['cancelledGesture', 'committedGesture', 'gestureAfterUndo', 'otherFloorGesture', 'gestureAfterServerPush'])
ac3[name] = { ...ac3[name], steps: ac3[name].steps >= 2 };
checkAll(ac3, {
cancelledGesture: gesture(true, 1),
committedGesture: gesture(true, 1),
committedIsTheFreshCards: [],
undoIsTheFreshCards: [],
gestureAfterUndo: gesture(true, 1),
redoIsTheFreshCards: [],
otherFloorGesture: gesture(true, 1),
serverPushMidDrag: { held: true, endsTheGesture: true, dropsTheUnion: true },
gestureAfterServerPush: gesture(true, 1),
});
// ======================= AC7: lifecycle ======================================
const ac7 = await page.evaluate(async () => {
const hp = window.__hpTest;
const { sameAsFresh, builds, grew, settled } = window.__hp814;
const out = {};
let card = window.__card;
const pools = (host = card) => [host._physicalBodiesPool.size, host._openingTunnelPool.size];
await hp.setMode('view');
await hp.switchSpace('f1');
// Config replacement: a pushed f1 partition replaces the shown bodies.
await hp.setServerConfig((cfg) => {
cfg.spaces.find((space) => space.id === 'f1').partitions[0].b = [0.85, 0.62];
return cfg;
});
out.replacementIsTheFreshCards = await sameAsFresh();
out.replacementPoolsWithinEight = pools().every((size) => size <= 8);
// A mode change and back: nothing rebuilt, nothing stale.
const beforeModes = builds();
await hp.setMode('plan');
await hp.setMode('view');
out.modeRoundTripBuildsNoBodiesOrTunnels = grew(beforeModes, builds(), ['physicalBodies', 'openingTunnel']);
out.modeRoundTripIsTheFreshCards = await sameAsFresh();
// Disconnect and reconnect: the same instance, warm and current; then a push.
const parent = card.parentNode;
const beforeReconnect = builds();
card.remove();
await settled();
parent.appendChild(card);
await settled();
out.reconnectBuildsNoBodiesOrTunnels = grew(beforeReconnect, builds(), ['physicalBodies', 'openingTunnel']);
out.reconnectIsTheFreshCards = await sameAsFresh();
await hp.setServerConfig((cfg) => {
cfg.spaces.find((space) => space.id === 'f1').partitions[0].a = [0.62, 0.62];
return cfg;
});
out.pushAfterReconnectIsTheFreshCards = await sameAsFresh();
out.reconnectPoolsWithinEight = pools().every((size) => size <= 8);
// A warm remount: Lovelace replaces the element in the same slot.
const config = { type: 'custom:houseplan-card', title: 'House Plan', icon_size: 3.4 };
const hass = card.hass;
const oldPools = [card._physicalBodiesPool, card._openingTunnelPool];
card.remove();
const next = document.createElement('houseplan-card');
next.setConfig(config);
parent.appendChild(next);
next.hass = hass;
window.__card = next;
card = next;
const ready = performance.now() + 9000;
while (!(card._loadOk && card._booting === false && card._spaceModel()) && performance.now() < ready)
await new Promise((done) => setTimeout(done, 30));
await settled();
out.remountHasItsOwnBoundedPools = card._physicalBodiesPool !== oldPools[0]
&& card._openingTunnelPool !== oldPools[1] && pools().every((size) => size <= 8);
out.remountIsTheFreshCards = await sameAsFresh();
return out;
});
checkAll(ac7, {
replacementIsTheFreshCards: [],
modeRoundTripBuildsNoBodiesOrTunnels: { physicalBodies: 0, openingTunnel: 0 },
modeRoundTripIsTheFreshCards: [],
reconnectBuildsNoBodiesOrTunnels: { physicalBodies: 0, openingTunnel: 0 },
reconnectIsTheFreshCards: [],
pushAfterReconnectIsTheFreshCards: [],
remountIsTheFreshCards: [],
});
await finish(browser, { ac1, ac2, ac3, ac7 });
+19 -3
View File
@@ -34,10 +34,10 @@ that transition events alone prove disposal.
| Performance threshold | 4 | The witness measures real browser wall-time or frame work; a pure assertion cannot prove the budget. |
| Browser harness integrity | 3 | The mutation breaks page-error, round-trip or page-registration observation in the browser harness itself. |
| Paint, cascade and layer composition | 47 | The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels produced by Chromium. |
| Pointer geometry and trusted interaction | 53 | The invariant depends on hit testing, pointer capture, touch/keyboard dispatch or live DOM geometry. |
| Pointer geometry and trusted interaction | 54 | The invariant depends on hit testing, pointer capture, touch/keyboard dispatch or live DOM geometry. |
| Responsive DOM layout | 41 | The invariant depends on measured element boxes, responsive breakpoints, native/HA dialog shells or focusable target size. |
| Custom-element and HA browser lifecycle | 119 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. |
| **Total** | **267 / 200** | Above the guideline `mutation-gate --check` warns rather than fails (#699); each guard above it is held by its own reason in this inventory and its `because`. |
| Custom-element and HA browser lifecycle | 124 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. |
| **Total** | **273 / 200** | Above the guideline `mutation-gate --check` warns rather than fails (#699); each guard above it is held by its own reason in this inventory and its `because`. |
## Measured effect
@@ -192,6 +192,7 @@ The invariant depends on hit testing, pointer capture, touch/keyboard dispatch o
- `resize-history-boundary-repair-removed`
- `resize-label-uses-old-room-gear-centre`
- `resize-labels-hide-narrow-area`
- `resize-live-preflight-skipped`
- `resize-pointer-capture-removed`
- `resize-preview-reject-silent`
- `room-fit-html-overlay-jumps-ahead`
@@ -289,6 +290,16 @@ and a warm remount on one page: per-root accounting of the editor sheets shows o
the second root's adoptedStyleSheets and computed styles. The fake-root unit test pins
the same accounting; the smoke proves it on Lit's own roots.
`opening-wall-index-key-global-epoch` and `sun-key-global-epoch` (#814 AC1) need the
real card adopting a pushed config through its server event (a new epoch with the shown
floor's record unchanged) and rendering the shown floor in View with a lit sun: only the
card's build counters across that adoption show the index and the wedges rebuilt.
`physical-bodies-pool-bypassed` and `opening-tunnel-evicting-miss-uncounted` (#814 AC2)
need the real tab cycle over three warm floors and a full pool: only the rendered floor
switch reaches the miss branches, judged by the #735 guard with `pooled`.
`resize-union-kept-after-reset` (#814 AC3) needs a held real Resize drag ended by a config
event, the only path that resets the session outside pointer up and cancel.
The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition.
- `accepted-marker-rolled-back-by-layout-failure`
@@ -354,7 +365,10 @@ The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy
- `namespace-loader-returns-english`
- `near-axis-authoring-snap-bypassed`
- `onboarding-loader-skips-namespace-ensure`
- `opening-tunnel-evicting-miss-uncounted`
- `opening-wall-index-key-global-epoch`
- `openings-rendered-without-keys`
- `physical-bodies-pool-bypassed`
- `plan-only-preview-label-hidden`
- `plan-room-area-icon-hidden`
- `plan-room-area-icon-navigates`
@@ -367,6 +381,7 @@ The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy
- `render-reconcile-restarts-editor-runtime-cycle`
- `reopened-room-from-registry-space`
- `resize-bodies-rekey-format-diverges`
- `resize-union-kept-after-reset`
- `room-accept-leaves-coincident-partitions`
- `room-gear-second-touch-keeps-drag`
- `room-settings-click-does-not-open`
@@ -382,6 +397,7 @@ The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy
- `space-create-hidden-display-override`
- `stairs-view-pan-opens-target-floor`
- `stairs-view-tread-lines`
- `sun-key-global-epoch`
- `support-invalid-response-leaks-issued-token`
- `support-stale-preview-response-revives-consent`
- `support-timeout-claims-success`
+153 -2
View File
@@ -7235,8 +7235,9 @@ const MUTANT_DEFINITIONS = [
+ 'пользователь до следующей записи видит закэшированную старую геометрию (#577)',
patches: [{
file: 'src/houseplan-card.ts',
find: '`${space.id}|${sun.azimuth}|${sun.elevation}|${north}|${origin}|${this._cfgEpoch}`',
replace: '`${space.id}|${sun.azimuth}|${sun.elevation}|${north}|${this._cfgEpoch}`',
// #814: the key lists its inputs in sunGeometryKey (floor-geometry-key.ts).
find: ' [sun.azimuth, sun.elevation, north, origin], zeroWalls);\n',
replace: ' [sun.azimuth, sun.elevation, north], zeroWalls); // mutant: the origin is not an input\n',
}],
},
{
@@ -15516,6 +15517,156 @@ const MUTANT_DEFINITIONS = [
replace: ' .stage.projection-iso.mode-view .dev:focus-visible:not(#hp-mutant) { z-index: 5; }',
}],
},
// #814: ключи индекса проёмов и солнца — их входы, не глобальная эпоха.
{
id: 'opening-wall-index-key-global-epoch',
guard: 'node demo/smoke_floor_cache_reuse.mjs',
because: '#814 AC1: the global epoch back in the opening wall index key rebuilds the shown floor\'s '
+ 'index on another floor\'s edit; only the real card pushed a config by its server event and '
+ 'rendering the shown floor counts that build',
patches: [{
file: 'src/houseplan-card.ts',
find: ' const key = openingWallIndexKey(space.id, space.rooms, this._spaceWalls, openCuts,\n',
replace: ' const key = openingWallIndexKey(`${space.id}|${this._cfgEpoch}`, space.rooms, this._spaceWalls, openCuts, // mutant: the epoch is back\n',
}],
},
{
id: 'sun-key-global-epoch',
guard: 'node demo/smoke_floor_cache_reuse.mjs',
because: '#814 AC1: the global epoch back in the sun key recomputes the wedges of the shown floor on '
+ 'another floor\'s edit or a shared setting; only the rendered View with a lit sun counts it',
patches: [{
file: 'src/houseplan-card.ts',
find: ' [sun.azimuth, sun.elevation, north, origin], zeroWalls);\n',
replace: ' [sun.azimuth, sun.elevation, north, origin, this._cfgEpoch], zeroWalls); // mutant: the epoch is back\n',
}],
},
{
id: 'opening-wall-index-key-drops-wall-geometry',
guard: 'node --test --test-name-pattern="#814 AC1" test/floor-geometry-key.test.mjs',
because: '#814 AC1: a key without the wall thickness and endpoints serves an index built for the old '
+ 'walls after a thickness edit; the cached index must equal an uncached build',
patches: [{
file: 'src/floor-geometry-key.ts',
find: " walls.map((wall) => `${wall.key}:${wall.a}:${wall.b}:${wall.cm}`).join(';'),\n",
replace: " walls.map((wall) => `${wall.key}`).join(';'), // mutant: thickness and endpoints are not inputs\n",
}],
},
{
id: 'sun-key-drops-window-geometry',
guard: 'node --test --test-name-pattern="#814 AC1" test/floor-geometry-key.test.mjs',
because: '#814 AC1: a sun key that knows the windows only by id keeps the wedges of a window moved '
+ 'in place; the cached wedges must equal an uncached computation',
patches: [{
file: 'src/floor-geometry-key.ts',
find: " index, bodies, windows.map((w) => `${w.id}:${w.x},${w.y},${w.angle},${w.length}`).join(';'),\n",
replace: " index, bodies, windows.map((w) => w.id).join(';'), // mutant: a moved window keeps the key\n",
}],
},
// #814: пулы тел и туннелей по этажам.
{
id: 'floor-pool-single-slot',
guard: 'node --test --test-name-pattern="#814 AC2" test/floor-geometry-key.test.mjs',
because: '#814 AC2: a pool bounded to one entry is the old single-slot storage: twelve warm switches '
+ 'between three floors build again on every switch',
patches: [{
file: 'src/floor-geometry-key.ts',
find: ' lruWrite(pool, key, entry, WALL_UNION_POOL_LIMIT);\n return { ...entry };\n',
replace: ' lruWrite(pool, key, entry, 1); // mutant: the old single-slot storage\n return { ...entry };\n',
}],
},
{
id: 'physical-bodies-pool-bypassed',
guard: 'node demo/smoke_floor_cache_reuse.mjs',
because: '#814 AC2: a card that keeps only the active bodies rebuilds them on every warm floor '
+ 'switch; only the real tab cycle judged by the #735 guard with `pooled` counts it',
patches: [{
file: 'src/houseplan-card.ts',
find: ' return (this._physicalBodiesCache = floorPoolEntry(this._physicalBodiesPool, this._physicalBodiesCache, key, () => {\n',
replace: ' return (this._physicalBodiesCache = floorPoolEntry(new Map(), this._physicalBodiesCache, key, () => { // mutant: single slot\n',
}],
},
{
id: 'opening-tunnel-evicting-miss-uncounted',
guard: 'node demo/smoke_floor_cache_reuse.mjs',
because: '#814 AC2: a tunnel built on a miss that evicts from the full pool must count; a counter '
+ 'that skips that branch hides a cold floor while every size stays the same',
patches: [{
file: 'src/houseplan-card.ts',
find: ' this._floorCacheBuilds.openingTunnel++;\n',
replace: ' if (this._openingTunnelPool.size < 8) this._floorCacheBuilds.openingTunnel++; // mutant: not on an evicting miss\n',
}],
},
{
id: 'switch-cycle-guard-ignores-pools',
guard: 'node --test --test-name-pattern="#814 AC2" test/switch-cycle-guard.test.mjs',
because: '#814 AC2/AC5: a guard that keeps the pooled families reported lets a candidate fall back '
+ 'to single-slot storage with twelve builds in the warm cycle',
patches: [{
file: 'demo/performance/switch-cycle-guard.mjs',
find: ' if (pooled) Object.assign(judged, perFloor);\n',
replace: ' // mutant: a pooled card is judged like a single-slot one\n',
}],
},
// #814: один union неизменных тел на жест Resize.
{
id: 'resize-union-memo-disabled',
guard: 'node --test --test-name-pattern="#814 AC3" test/physical-geometry.test.mjs',
because: '#814 AC3: without the memo every resized room on every accepted step unites all bodies of '
+ 'the floor again; the union count of one gesture shows it',
patches: [{
file: 'src/physical-geometry.ts',
find: ' if (this.last?.key !== key) {\n',
replace: ' if (true) { // mutant: a union per room and step\n',
}],
},
{
id: 'resize-union-key-ignores-bodies',
guard: 'node --test --test-name-pattern="#814 AC3" test/physical-geometry.test.mjs',
because: '#814 AC3: a union keyed by the scale alone survives a changed body set and subtracts the '
+ 'old bodies from the live areas',
patches: [{
file: 'src/physical-geometry.ts',
find: " const key = `${scale}|${bodies.join('|')}`;\n",
replace: ' const key = scale; // mutant: the scale alone\n',
}],
},
{
id: 'resize-failed-union-is-no-obstacle',
guard: 'node --test --test-name-pattern="#814 AC3" test/physical-geometry.test.mjs',
because: '#814 AC3: a failed union read as an empty set restores the floor under every body; it '
+ 'must take the lossless sequential path',
patches: [{
file: 'src/physical-geometry.ts',
find: ' if (!bodies.length) return [closedRing(floor)];\n try {\n if (obstacles)',
replace: ' if (!obstacles) return [closedRing(floor)]; // mutant: a failed union is no obstacle\n try {\n if (obstacles)',
}],
},
{
id: 'resize-union-kept-after-reset',
guard: 'node demo/smoke_floor_cache_reuse.mjs',
because: '#814 AC3: a gesture ended by a reset (a server push mid-drag) must drop its union; only a '
+ 'held real drag interrupted by the config event reaches that path',
patches: [{
file: 'src/houseplan-editor-runtime.ts',
find: ' this.host._resize.reset();\n this.rszBodiesUnion.clear();\n',
replace: ' this.host._resize.reset(); // mutant: the union outlives the reset\n',
}],
},
// #814 AC4: живой preflight Resize не ослаблен.
{
id: 'resize-live-preflight-skipped',
guard: 'node demo/smoke_room_resize.mjs',
because: '#814 AC4: a live step must run the full physical preflight; skipped, a candidate the '
+ 'preflight rejects is previewed and the drag no longer stops with its reason',
patches: [{
file: 'src/houseplan-editor-runtime.ts',
find: " if (!liveSpace || !this._rszSpaceCandidateGeometry(this.host._space, liveSpace).ok)\n"
+ " return { ok: false, reason: 'physical-geometry' };\n",
replace: " if (!liveSpace) // mutant: the live physical preflight is skipped\n"
+ " return { ok: false, reason: 'physical-geometry' };\n",
}],
},
];
const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8');