mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-08 07:29:13 +00:00
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:
@@ -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 });
|
||||
@@ -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`
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user