mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 06:38:57 +00:00
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
226 lines
9.7 KiB
JavaScript
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'\)/);
|
|
});
|