mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 15:09:30 +00:00
The summary panel's Total room area was memoised on the global config epoch: any `_saveConfig()` or adopted server config — a rename on one floor, a device or a setting — made the memo stale and the next View render ran `cleanFloorAreaSteps` over every floor again (71 portions on large-house). The area of a floor reads only its stored record, the model built from its own record and constants, so each floor's value is now memoised by the content key of those records. The key comes from the helper extracted out of the #744 reader (`floorRecordKeyMemo`, src/floor-geometry-key.ts): one fingerprint per floor and epoch, both records when a preview or a duplicate id makes them differ, and independent of the shown floor. The total is the sum of the floor values in `models` order, summed anew on every pass, so it stays bitwise equal to a fresh `totalCleanFloorAreaM2`. A failed floor is memoised as `null` until its record changes; the freshness check stays one epoch comparison; the #509 portions, skeleton and the old value on screen during a recomputation are unchanged; a lifecycle reset clears the memo. The furniture and stairs magnet keeps its epoch key by measurement (its own edits change the floor record anyway); the decision is recorded at the key. Unit: test/summary-panel-area-memo.test.mjs (AC1–AC3 on a three-floor fixture with walls, a door, a partition, a column and a stair; floors counted through the injected geometry and portions through the step generator), and test/floor-geometry-key.test.mjs for the shared key helper, now in the test build. Two registry mutants (epoch key, rooms-only key); the #744 and #509 anchors follow the moved lines. Issue: #769 User-Visible: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
269 lines
12 KiB
JavaScript
269 lines
12 KiB
JavaScript
// #769: the summary panel's total area is memoised per floor, by the content
|
|
// key of the floor's records (`floorRecordKeyMemo`, src/floor-geometry-key.ts),
|
|
// instead of by the global config epoch.
|
|
//
|
|
// The runtime and the metrics module are the real ones. Work is counted at two
|
|
// seams the runtime already passes through: `geometryOf` (one call per floor
|
|
// computed) and the portion generator (one yield per room or stair batch). The
|
|
// oracle is a fresh `totalCleanFloorAreaM2(config, spaceModels(config))`.
|
|
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
|
|
import { LoadedSummaryPanelRuntime } from '../test-build/summary-panel-runtime-loaded.js';
|
|
import * as metrics from '../test-build/summary-panel-metrics.js';
|
|
import { spaceModels } from '../test-build/space-geometry.js';
|
|
import { floorRecordKeyMemo } from '../test-build/floor-geometry-key.js';
|
|
import { contentFingerprint } from '../test-build/visual-continuity.js';
|
|
import { fixtureWallKey } from '../demo/fixtures/wall-key.mjs';
|
|
|
|
const rect = (x0, y0, x1, y1) => [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
|
|
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, b });
|
|
});
|
|
}
|
|
return [...byKey.values()];
|
|
};
|
|
|
|
/** One floor: two rooms on a shared wall, wall records, a door on the shared
|
|
* wall, a partition, a column and, on B, a straight stair. */
|
|
const floor = (id, x0, stairs = false) => {
|
|
const rooms = [
|
|
{ id: `${id}1`, name: `${id} west`, poly: rect(x0, 0.1, x0 + 0.4, 0.5) },
|
|
{ id: `${id}2`, name: `${id} east`, poly: rect(x0 + 0.4, 0.1, x0 + 0.75, 0.5) },
|
|
];
|
|
return {
|
|
id, title: `Floor ${id}`, cell_cm: 5, view_box: [0, 0, 1, 1],
|
|
rooms,
|
|
walls: wallsOf(rooms, 15),
|
|
openings: [{ id: `${id}-door`, type: 'door', x: x0 + 0.4, y: 0.3, angle: 90, length: 0.05 }],
|
|
partitions: [{ id: `${id}-wall`, a: [x0 + 0.05, 0.3], b: [x0 + 0.25, 0.3], cm: 10 }],
|
|
wall_columns: [{ id: `${id}-column`, shape: 'circle', center: [x0 + 0.6, 0.3], cm: 20 }],
|
|
decor: [],
|
|
...(stairs ? { stairs: [{
|
|
id: `${id}-stair`, kind: 'straight', direction: 'forward',
|
|
x: x0 + 0.2, y: 0.42, angle: 0, length: 0.12, width: 0.045,
|
|
}] } : {}),
|
|
};
|
|
};
|
|
const house = () => ({
|
|
spaces: [floor('A', 0.1), floor('B', 0.12, true), floor('C', 0.15)],
|
|
markers: [], settings: {},
|
|
});
|
|
|
|
/** Rooms with a contour plus stair batches: the portions of one floor (#509). */
|
|
const portionsOf = (config, ids) => spaceModels(config)
|
|
.filter((model) => ids.includes(model.id))
|
|
.reduce((sum, model) => sum + model.rooms.filter((room) => room.id && room.poly).length
|
|
+ Math.ceil((model.stairs || []).length / 24), 0);
|
|
|
|
const fresh = (config) => metrics.totalCleanFloorAreaM2(config, spaceModels(config));
|
|
|
|
const harness = (config) => {
|
|
const counts = { floors: [], computed: [], portions: 0, fingerprints: 0 };
|
|
const module = {
|
|
...metrics,
|
|
spaceWallGeometry: (space, prepared) => {
|
|
counts.floors.push(space.id);
|
|
return metrics.spaceWallGeometry(space, prepared);
|
|
},
|
|
cleanFloorAreaSteps: function* counted(...args) {
|
|
const inner = metrics.cleanFloorAreaSteps(...args);
|
|
let step = inner.next();
|
|
while (!step.done) {
|
|
counts.portions++;
|
|
yield;
|
|
step = inner.next();
|
|
}
|
|
return step.value;
|
|
},
|
|
};
|
|
const host = {
|
|
hass: { states: {}, config: {} }, _config: {}, requestUpdate: () => undefined,
|
|
_mode: 'view', _space: config.spaces[0].id, _markers: [], _layoutRev: 0,
|
|
_haRegistry: { authoritative: true, revision: 0, devices: {}, entities: {} },
|
|
_areaToSpace: {}, _cfgEpoch: 1,
|
|
_serverCfg: config, _renderCfg: config, _model: spaceModels(config),
|
|
};
|
|
const runtime = new LoadedSummaryPanelRuntime(host);
|
|
runtime.metricsModule = module;
|
|
runtime.areaKeys = floorRecordKeyMemo((value) => {
|
|
counts.fingerprints++;
|
|
return contentFingerprint(value);
|
|
});
|
|
// Every floor computed — a failed one included — is written to the memo once.
|
|
const write = runtime.areaFloors.set.bind(runtime.areaFloors);
|
|
runtime.areaFloors.set = (key, value) => {
|
|
counts.computed.push(key.slice(0, key.indexOf('|')));
|
|
return write(key, value);
|
|
};
|
|
const reset = () => {
|
|
counts.floors = []; counts.computed = []; counts.portions = 0; counts.fingerprints = 0;
|
|
};
|
|
/** A new config as the card adopts it: new epoch, the model rebuilt. */
|
|
const adopt = (next) => {
|
|
host._serverCfg = next;
|
|
host._renderCfg = next;
|
|
host._model = spaceModels(next);
|
|
host._cfgEpoch++;
|
|
reset();
|
|
runtime.computeMetrics();
|
|
return {
|
|
floors: [...counts.floors].sort(), computed: [...counts.computed].sort(),
|
|
portions: counts.portions, value: runtime.areaMemo.value,
|
|
};
|
|
};
|
|
runtime.computeMetrics();
|
|
return { host, runtime, counts, reset, adopt };
|
|
};
|
|
|
|
const edit = (config, mutate) => {
|
|
const next = structuredClone(config);
|
|
mutate(next);
|
|
return next;
|
|
};
|
|
const B = (config) => config.spaces.find((space) => space.id === 'B');
|
|
|
|
/** AC1 table: the scenario, its edit, the floors it recomputes and the floors
|
|
* going back to the base recomputes (the memo keeps current floors only). */
|
|
const SCENARIOS = [
|
|
['rename a room of B', (c) => { B(c).rooms[0].name += ' renamed'; }, ['B']],
|
|
['colour of a room of B', (c) => { B(c).rooms[0].settings = { fill_mode: 'custom', custom_fill: '#123456' }; }, ['B']],
|
|
['furniture on B', (c) => { B(c).decor.push({ id: 'sofa', kind: 'furniture', symbol: 'sofa', x: 0.4, y: 0.3, w: 0.1, h: 0.05 }); }, ['B']],
|
|
['a vertex of a room of B', (c) => {
|
|
const room = B(c).rooms[1];
|
|
room.poly = room.poly.map((point, index) => (index === 1 || index === 2 ? [point[0] + 0.05, point[1]] : point));
|
|
}, ['B']],
|
|
['wall thickness on B', (c) => { for (const wall of B(c).walls) wall.cm = 30; }, ['B']],
|
|
['an opening on B', (c) => { B(c).openings[0].y = 0.25; }, ['B']],
|
|
['the stair of B', (c) => { B(c).stairs[0].length = 0.2; }, ['B']],
|
|
['the column of B', (c) => { B(c).wall_columns[0].cm = 40; }, ['B']],
|
|
['cell_cm of B', (c) => { B(c).cell_cm = 7; }, ['B']],
|
|
['markers and settings', (c) => {
|
|
c.markers.push({ id: 'probe', binding: 'virtual' });
|
|
c.settings = { ...c.settings, show_device_battery: true };
|
|
}, []],
|
|
['a server change of C', (c) => { c.spaces[2].rooms[0].name = 'remote'; }, ['C']],
|
|
['a new floor', (c) => { c.spaces.push({ ...structuredClone(B(c)), id: 'D', title: 'Floor D' }); }, ['D']],
|
|
['a deleted floor', (c) => { c.spaces.splice(1, 1); }, [], ['B']],
|
|
['floors reordered', (c) => { c.spaces.reverse(); }, []],
|
|
];
|
|
|
|
test('#769 AC1: an edit recomputes only the floors it changed, in the same portions', () => {
|
|
const base = house();
|
|
const { adopt } = harness(base);
|
|
for (const [name, mutate, changed, restored = changed.filter((id) => id !== 'D')] of SCENARIOS) {
|
|
const next = edit(base, mutate);
|
|
const after = adopt(next);
|
|
assert.deepEqual(after.floors, changed, `${name}: recomputed floors`);
|
|
assert.deepEqual(after.computed, changed, `${name}: floors written to the memo`);
|
|
assert.equal(after.portions, portionsOf(next, changed),
|
|
`${name}: portions = rooms and stair batches of the changed floors`);
|
|
// Back to the base: what the memo forgot is computed again, nothing else.
|
|
const back = adopt(structuredClone(base));
|
|
assert.deepEqual(back.computed, restored, `${name}: going back`);
|
|
}
|
|
});
|
|
|
|
test('#769 AC2: after every edit the total equals a fresh computation', () => {
|
|
const base = house();
|
|
const { adopt, runtime } = harness(base);
|
|
assert.equal(runtime.areaMemo.value, fresh(base), 'the first total is the fresh one');
|
|
assert.ok(runtime.areaMemo.value > 0);
|
|
let current = base;
|
|
for (const [name, mutate] of SCENARIOS) {
|
|
// Chained edits: each one starts from the previous result.
|
|
const next = edit(current, mutate);
|
|
assert.equal(adopt(next).value, fresh(next), `${name}: the total equals a fresh computation`);
|
|
current = next;
|
|
}
|
|
// The geometry edits move the total: the key misses no input kind.
|
|
for (const name of ['wall thickness on B', 'the stair of B', 'cell_cm of B', 'a vertex of a room of B']) {
|
|
const mutate = SCENARIOS.find(([label]) => label === name)[1];
|
|
assert.notEqual(fresh(edit(base, mutate)), fresh(base), `${name} changes the area`);
|
|
}
|
|
});
|
|
|
|
test('#769 AC1: a floor switch recomputes nothing, before and after the next epoch', () => {
|
|
const base = house();
|
|
const { host, runtime, counts, reset, adopt } = harness(base);
|
|
const value = runtime.areaMemo.value;
|
|
reset();
|
|
host._space = 'C';
|
|
runtime.computeMetrics();
|
|
assert.equal(runtime.metricsFresh(), true, 'a switch keeps the epoch');
|
|
assert.deepEqual(counts, { floors: [], computed: [], portions: 0, fingerprints: 0 });
|
|
// The next epoch with the same records (an edit elsewhere, e.g. a layout
|
|
// write): a key of the shown floor (`_floorKey`) would recompute the rest.
|
|
const after = adopt(base);
|
|
assert.deepEqual(after.floors, [], 'the same records after a switch');
|
|
assert.equal(after.portions, 0);
|
|
assert.equal(after.value, value);
|
|
});
|
|
|
|
test('#769 AC2: a failing floor gives null, its fix gives the number; duplicate ids stay exact', () => {
|
|
const base = house();
|
|
const { adopt } = harness(base);
|
|
const broken = edit(base, (c) => { B(c).walls = [null]; });
|
|
assert.equal(fresh(broken), null, 'the fixture really fails the computation');
|
|
let step = adopt(broken);
|
|
assert.deepEqual(step.computed, ['B'], 'only the broken floor is computed');
|
|
assert.equal(step.value, null, 'a failed floor makes the total null');
|
|
step = adopt(edit(broken, (c) => { c.spaces[2].rooms[0].name = 'other'; }));
|
|
assert.deepEqual(step.computed, ['C'], 'the failure is remembered by its key');
|
|
assert.equal(step.value, null);
|
|
step = adopt(structuredClone(base));
|
|
assert.deepEqual(step.computed, ['B', 'C'], 'the fixed floor and the renamed one');
|
|
assert.equal(step.value, fresh(base), 'the fix brings the number back');
|
|
|
|
// A duplicate id: the second `B` is drawn from its own record but reads the
|
|
// walls of the first. Its key covers both records.
|
|
const duplicate = edit(base, (c) => { c.spaces.push({ ...structuredClone(c.spaces[2]), id: 'B', title: 'Twin' }); });
|
|
step = adopt(duplicate);
|
|
assert.deepEqual(step.floors, ['B']);
|
|
assert.equal(step.value, fresh(duplicate));
|
|
const thicker = edit(duplicate, (c) => { for (const wall of c.spaces[1].walls) wall.cm = 30; });
|
|
step = adopt(thicker);
|
|
assert.deepEqual(step.floors, ['B', 'B'], 'walls of the first B move the twin too');
|
|
assert.equal(step.value, fresh(thicker));
|
|
});
|
|
|
|
test('#769 AC3: freshness checks are free; a new epoch costs one fingerprint per floor', () => {
|
|
const base = house();
|
|
const { host, runtime, counts, reset } = harness(base);
|
|
const total = { id: 'v', label: 'Area', source: { type: 'system', key: 'total_area' } };
|
|
reset();
|
|
for (let index = 0; index < 100; index++) {
|
|
assert.equal(runtime.metricsFresh(), true);
|
|
assert.equal(runtime.valueState(total).kind, 'ready');
|
|
}
|
|
assert.deepEqual(counts, { floors: [], computed: [], portions: 0, fingerprints: 0 },
|
|
'renders and HA ticks do no work');
|
|
|
|
host._cfgEpoch++;
|
|
host._serverCfg = structuredClone(base);
|
|
host._renderCfg = host._serverCfg;
|
|
host._model = spaceModels(host._serverCfg);
|
|
reset();
|
|
runtime.computeMetrics();
|
|
assert.equal(counts.fingerprints, 3, 'one fingerprint per floor for the new epoch');
|
|
assert.equal(counts.portions, 0, 'the same records cost no portion');
|
|
runtime.computeMetrics();
|
|
for (let index = 0; index < 100; index++) runtime.valueState(total);
|
|
assert.equal(counts.fingerprints, 3, 'and only once per epoch');
|
|
});
|
|
|
|
test('#769: a lifecycle reset forgets the per-floor memo', () => {
|
|
const base = house();
|
|
const { runtime, counts, reset } = harness(base);
|
|
runtime.resetLifecycle();
|
|
reset();
|
|
runtime.computeMetrics();
|
|
assert.deepEqual(counts.floors, ['A', 'B', 'C'], 'after a reset every floor is computed again');
|
|
assert.equal(runtime.areaMemo.value, fresh(base));
|
|
});
|