Files
houseplan-card/test/glow-scene.test.mjs
T
Matysh 416d2fae71 perf(led): avoid redundant tube and barrier calculations
Preserve exact light fields while culling provably distant occluders,
reusing physical tube paths across camera updates and excluding LED emitter
data from aligned architectural fingerprints. Cover output equivalence,
in-place invalidation, resize tags and hyphenated benchmark chunk hashes.

Issue: #788
User-Visible: yes
2026-10-03 19:18:49 +03:00

226 lines
9.7 KiB
JavaScript

import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import {
buildLightBarrierScene, createGlowRuntimeState, disposeGlowRuntime,
lightGeometryFingerprint, readGlowClip,
resolveGlowCandidates, resolveLightBarrierRevision,
transitionGlowSource, writeGlowClip,
} from '../test-build/glow-scene.js';
import { contentFingerprint } from '../test-build/visual-continuity.js';
import { wallBodiesGeometry, wallKey } from '../test-build/wall-thickness.js';
const square = (id, x0, x1) => ({
id,
poly: [[x0, 0], [x1, 0], [x1, 100], [x0, 100]],
});
test('light geometry excludes only LED sources and never visits or mutates their data', () => {
const geometry = Object.freeze({
id: 's', rooms: Object.freeze([square('room', 0, 100)]),
walls: [], wall_segments: [], openings: [], open_spans: [],
partitions: [], wall_columns: [], wall_style: 'zero',
future_geometry_key: { x: 1 },
});
const expected = contentFingerprint([geometry, 5, 20]);
assert.equal(lightGeometryFingerprint(geometry, 5, 20), expected);
const raw = Object.freeze(Object.defineProperty({ ...geometry }, 'led_strips', {
enumerable: true,
get() { throw new Error('LED source data must not enter a masonry hash'); },
}));
assert.equal(lightGeometryFingerprint(raw, 5, 20), expected);
assert.equal(raw.rooms, geometry.rooms, 'the caller-owned geometry is not cloned or mutated');
for (const led_strips of [[], [{ id: 'a', points: [[0, 0], [100, 1]] }],
[{ id: 'b', active: false, entity: 'light.changed', points: [[0, 1]] }]]) {
assert.equal(lightGeometryFingerprint({ ...geometry, led_strips }, 5, 20), expected);
}
for (const key of Object.keys(geometry)) {
assert.notEqual(lightGeometryFingerprint({ ...geometry, [key]: ['changed'] }, 5, 20), expected,
`all non-LED raw keys still invalidate, including ${key}`);
}
assert.notEqual(lightGeometryFingerprint(geometry, 10, 20), expected);
assert.notEqual(lightGeometryFingerprint(geometry, 5, 40), expected);
for (const invalid of [null, undefined, false, 0, 's', [], [{ led_strips: [1] }]]) {
assert.equal(lightGeometryFingerprint(invalid, 5, 20), contentFingerprint([invalid, 5, 20]),
'non-object and array inputs keep their prior fingerprint semantics');
}
const mutable = { rooms: [square('room', 0, 100)], led_strips: [] };
const before = lightGeometryFingerprint(mutable, 5, 20);
mutable.rooms[0].poly[0][0] = 1;
assert.notEqual(lightGeometryFingerprint(mutable, 5, 20), before,
'in-place architectural edits must not reuse stale masonry');
});
test('LED-only edits retain the aligned shared-masonry recut fast path', () => {
const space = {
id: 's', rooms: [square('left', 0, 100), square('right', 100, 200)],
partitions: [], room_drafts: [], wall_columns: [],
};
const unique = new Map();
for (const room of space.rooms) for (let index = 0; index < room.poly.length; index++) {
const a = room.poly[index], b = room.poly[(index + 1) % room.poly.length];
const key = wallKey(a, b, 1);
if (!unique.has(key)) unique.set(key, { key, a, b, cm: 20 });
}
const walls = [...unique.values()];
const raw = { ...space, walls, led_strips: [{ id: 'led', points: [[0, 0], [100, 0]] }] };
const sharedWallGeometry = wallBodiesGeometry(space.rooms, walls, [], [], 1, 5, 5, 1, []);
assert.equal(sharedWallGeometry.status, 'ok');
Object.defineProperty(sharedWallGeometry, 'sourceFingerprint', {
value: lightGeometryFingerprint(raw, 5, 5), enumerable: false,
});
const revisionFor = (rawSpaceConfig) => resolveLightBarrierRevision({
rawSpaceConfig, space, openings: [], cellCm: 5, gridPitch: 5, openingAmount: () => 0,
});
const revision = revisionFor(raw);
raw.led_strips[0].points[1][1] = 90;
const edited = revisionFor(raw);
assert.equal(edited.geometryFingerprint, sharedWallGeometry.sourceFingerprint);
assert.equal(edited.fingerprint, revision.fingerprint);
let wallIterations = 0;
Object.defineProperty(walls, Symbol.iterator, { value() {
wallIterations++;
return Array.prototype[Symbol.iterator].call(this);
} });
const input = {
space, revision: edited, walls,
zeroWalls: { contour: [], barriers: [], transmissive: [] },
wallKeyPitch: 1, cellCm: 5, gridPitch: 5, coordScale: 1,
physicalBodies: () => [],
};
const reused = buildLightBarrierScene({ ...input, sharedWallGeometry });
assert.equal(wallIterations, 1, 'matching source tag avoids the second wall rebuild traversal');
assert.ok(reused.masonryGeometry.length);
wallIterations = 0;
const rebuilt = buildLightBarrierScene(input);
assert.equal(wallIterations, 2, 'a scene without shared geometry must rebuild masonry');
assert.deepEqual(reused, rebuilt, 'recut and uncached rebuild retain identical light geometry');
const changedRevision = revisionFor({ ...raw, wall_style: 'changed' });
wallIterations = 0;
buildLightBarrierScene({
...input, revision: changedRevision, sharedWallGeometry,
});
assert.equal(wallIterations, 2, 'architectural changes reject the old shared-masonry tag');
});
test('shared light revision admits only floor-to-floor architectural passages', () => {
const space = {
id: 's', rooms: [square('left', 0, 100), square('right', 100, 200)],
partitions: [], room_drafts: [], wall_columns: [],
};
const opening = (id, type, rx, amount) => ({
id, type, rx, ry: 50, rlen: 40, angle: 90, amount,
contact: type === 'door' ? `binary_sensor.${id}` : null,
});
const openings = [
opening('inside-door', 'door', 100, 0.51),
opening('outside-door', 'door', 0, 1),
opening('window', 'window', 100, 1),
opening('passage', 'passage', 100, 1),
];
const revision = resolveLightBarrierRevision({
rawSpaceConfig: { id: 's', rooms: space.rooms },
space,
openings,
cellCm: 5,
gridPitch: 20,
openingAmount: (candidate) => candidate.amount,
});
assert.deepEqual(
revision.passageStates.map(({ opening: candidate }) => candidate.id),
['inside-door', 'passage'],
);
assert.equal(revision.passageStates[0].amount, 0.5);
const sameBucket = resolveLightBarrierRevision({
rawSpaceConfig: { id: 's', rooms: space.rooms }, space,
openings: openings.map((candidate) => candidate.id === 'inside-door'
? { ...candidate, amount: 0.52 } : candidate),
cellCm: 5, gridPitch: 20,
openingAmount: (candidate) => candidate.amount,
});
assert.equal(sameBucket.fingerprint, revision.fingerprint,
'leaf animation inside one quantised aperture bucket must reuse barriers');
const nextBucket = resolveLightBarrierRevision({
rawSpaceConfig: { id: 's', rooms: space.rooms }, space,
openings: openings.map((candidate) => candidate.id === 'inside-door'
? { ...candidate, amount: 0.8 } : candidate),
cellCm: 5, gridPitch: 20,
openingAmount: (candidate) => candidate.amount,
});
assert.notEqual(nextBucket.fingerprint, revision.fingerprint);
});
test('one shared source projection owns radius, colour and marker-stable identity', () => {
const device = {
id: 'lamp', name: 'Lamp', model: '', area: 'living', space: 's',
icon: 'mdi:lightbulb', entities: [], virtual: true,
marker: {
id: 'lamp', binding: 'virtual', is_light: true,
glow_radius_cm: 175, glow_color: { c: '#123456' },
},
};
const [candidate] = resolveGlowCandidates({
hass: { states: {} }, devices: [device], spaceId: 's',
defaultColor: '#ffffff', paletteAlpha: 0.7,
defaultRadiusUnits: 1200, cellCm: 5, gridPitch: 20,
position: () => ({ x: 25, y: 75 }),
});
assert.equal(candidate.key, 's|lamp');
assert.equal(candidate.sourceEid, '');
assert.deepEqual(candidate.pos, { x: 25, y: 75 });
assert.equal(candidate.radius, 700);
assert.equal(candidate.appearance.c, '#123456');
assert.ok(candidate.appearance.alpha > 0 && candidate.appearance.alpha <= 0.7);
});
test('shared Glow runtime is bounded and tears down every timer and source', () => {
let seq = 0;
const timers = new Set();
const rafs = new Set();
const fakeWindow = {
setTimeout: () => { const id = ++seq; timers.add(id); return id; },
clearTimeout: (id) => timers.delete(id),
requestAnimationFrame: () => { const id = ++seq; rafs.add(id); return id; },
cancelAnimationFrame: (id) => rafs.delete(id),
};
const host = {
window: () => fakeWindow,
isConnected: () => true,
requestUpdate: () => undefined,
reducedMotion: () => false,
};
const state = createGlowRuntimeState();
const entering = transitionGlowSource(state, host, 's|lamp', true);
assert.equal(entering.entering, true);
assert.equal(state.renderedSources.has('s|lamp'), true);
transitionGlowSource(state, host, 's|lamp', false);
assert.equal(state.fadeTimers.has('s|lamp'), true);
for (let index = 0; index < 300; index++) {
writeGlowClip(state, `clip-${index}`, { lit: [`M ${index} 0 Z`] }, 256);
}
assert.equal(state.clipCache.size, 256);
assert.equal(readGlowClip(state, 'clip-299').hit, true);
assert.equal(readGlowClip(state, 'clip-0').hit, false);
disposeGlowRuntime(state, host);
assert.equal(state.renderedSources.size, 0);
assert.equal(state.clipCache.size, 0);
assert.equal(state.fadeTimers.size, 0);
assert.equal(timers.size, 0);
assert.equal(rafs.size, 0);
});
test('space card exposes one explicit default-off visual-editor flag', () => {
const card = readFileSync(new URL('../src/space-card.ts', import.meta.url), 'utf8');
const editor = readFileSync(new URL('../src/space-editor.ts', import.meta.url), 'utf8');
assert.match(card, /light_pools\?: boolean/);
assert.match(card, /light_pools: false/);
assert.match(card, /lightPools: this\._config\.light_pools === true/);
assert.match(editor, /name: 'light_pools', selector: \{ boolean: \{\} \}/);
assert.match(editor, /light_pools: t\(L, 'editor\.light_pools'\)/);
});