mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 14:39:22 +00:00
@@ -0,0 +1,133 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createGlowRuntimeState, disposeGlowRuntime, readGlowClip,
|
||||
resolveGlowCandidates, resolveLightBarrierRevision,
|
||||
transitionGlowSource, writeGlowClip,
|
||||
} from '../test-build/glow-scene.js';
|
||||
|
||||
const square = (id, x0, x1) => ({
|
||||
id,
|
||||
poly: [[x0, 0], [x1, 0], [x1, 100], [x0, 100]],
|
||||
});
|
||||
|
||||
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'\)/);
|
||||
});
|
||||
+21
-14
@@ -760,34 +760,41 @@ test('golden overrides fail closed on misspelled fixture references', () => {
|
||||
|
||||
test('a light source paints exactly one region: the floor it can see', () => {
|
||||
const source = readHouseplanProductionSource();
|
||||
const glow = readFileSync(new URL('../src/glow-scene.ts', import.meta.url), 'utf8');
|
||||
// One region per source, and it is the visibility polygon clipped to floor.
|
||||
assert.match(source, /visibilityPolygon\(\[pos\.x, pos\.y\], R, occluders/);
|
||||
assert.match(source,
|
||||
/lit: seen\.length >= 3\s*\? intersectionPaths\(\[seen\], floor, \{/);
|
||||
assert.match(source, /onBoundsFailure: \(\{ boundIndex, phase \}\) =>/,
|
||||
assert.match(glow,
|
||||
/visibilityPolygon\(\s*\[input\.source\.x, input\.source\.y\], input\.radius/);
|
||||
assert.match(glow,
|
||||
/lit: seen\.length >= 3\s*\? intersectionPaths\(\[seen\], input\.scene\.floor, \{/);
|
||||
assert.match(glow, /onBoundsFailure: \(\{ boundIndex, phase \}\) =>/,
|
||||
'room-local boolean failures must be observable without restoring an unclipped fan');
|
||||
assert.match(source, /<circle class="glow-pool"/);
|
||||
assert.match(glow, /<circle class="glow-pool"/);
|
||||
assert.match(source, /return renderGlowPools\(\{/,
|
||||
'the full card must consume the shared pool renderer');
|
||||
// No second layer of LIGHT may come back: a spill path or a shadow mask of
|
||||
// its own is how the rendered result and the computed geometry got to
|
||||
// disagree (#71, #73). The single blur below is the penumbra of the one
|
||||
// region, measured in screen pixels and applied in one pass.
|
||||
const layer = source.slice(source.indexOf('_renderGlowLayer'));
|
||||
const glow = layer.slice(0, layer.indexOf('_renderAlignDialog'));
|
||||
assert.doesNotMatch(glow, /glow-spill|glow-shadow|glow-blocker/);
|
||||
// Exactly one blur, for the whole layer, sized in screen pixels.
|
||||
assert.equal((glow.match(/<feGaussianBlur/g) || []).length, 1);
|
||||
assert.doesNotMatch(glow, /<mask /);
|
||||
assert.match(source, /const nextFeather = GLOW_EDGE_FEATHER_PX \/ 2 \/ \(perUnit > 0 \? perUnit : 1\)/);
|
||||
assert.match(source, /const featherEnabled = !this\._pinchStart && !this\._panStart[\s\S]*_glowFeatherSuspendUntil/);
|
||||
assert.match(source, /filter=\$\{featherEnabled \? 'url\(#hp-glowfeather\)' : nothing\}/,
|
||||
assert.match(glow, /const next = GLOW_EDGE_FEATHER_PX \/ 2 \/ \(perUnit > 0 \? perUnit : 1\)/);
|
||||
assert.match(source,
|
||||
/resolveGlowFeather\(\s*this\._glowRuntimeState, perUnit, !this\._pinchStart && !this\._panStart/);
|
||||
assert.match(glow,
|
||||
/filter=\$\{input\.featherEnabled \? 'url\(#hp-glowfeather\)' : nothing\}/,
|
||||
'the expensive whole-layer filter must be bypassed during a viewport gesture/transition');
|
||||
// Barriers are keyed by their own content: `_cfgEpoch` lags behind geometry
|
||||
// edited in place, and a stale barrier set lights straight through a wall.
|
||||
assert.doesNotMatch(glow, /_cfgEpoch/);
|
||||
assert.match(source, /const geometryFingerprint = contentFingerprint\(\[raw, this\._cellCm, this\._gridPitch\]\)/);
|
||||
assert.match(source, /const fingerprint = contentFingerprint\(\[geometryFingerprint, openingStateSignature\]\)/);
|
||||
assert.match(source, /const cacheKey = `\$\{space\.id\}\|\$\{fingerprint\}`/);
|
||||
assert.match(source, /sharedFingerprint === geometryFingerprint/,
|
||||
assert.match(glow,
|
||||
/const geometryFingerprint = contentFingerprint\(\[\s*input\.rawSpaceConfig, input\.cellCm, input\.gridPitch/);
|
||||
assert.match(glow,
|
||||
/fingerprint: contentFingerprint\(\[geometryFingerprint, openingStateSignature\]\)/);
|
||||
assert.match(glow,
|
||||
/const cacheKey = `\$\{input\.space\.id\}\|\$\{revision\.fingerprint\}`/);
|
||||
assert.match(glow, /sharedFingerprint === revision\.geometryFingerprint/,
|
||||
'an in-place edit must never recut stale shared masonry');
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
assertCardContract,
|
||||
GLOW_CARD_CONTRACT,
|
||||
LARGE_HOUSE_CARD_CONTRACT,
|
||||
SPACE_GLOW_CARD_CONTRACT,
|
||||
} from '../demo/performance/card-contract.mjs';
|
||||
import { readHouseplanProductionSource } from './houseplan-source.mjs';
|
||||
|
||||
@@ -31,7 +32,10 @@ test('large-house benchmark declares every private card member it consumes', ()
|
||||
});
|
||||
|
||||
test('Glow benchmark declares every private card member it consumes', () => {
|
||||
const declared = declaredMembers(GLOW_CARD_CONTRACT);
|
||||
const declared = new Set([
|
||||
...declaredMembers(GLOW_CARD_CONTRACT),
|
||||
...declaredMembers(SPACE_GLOW_CARD_CONTRACT),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
directCardMembers('demo/benchmark_glow.mjs').filter((name) => !declared.has(name)),
|
||||
[],
|
||||
@@ -46,6 +50,11 @@ test('performance contracts reference real production members', () => {
|
||||
`${contract.label} declares missing production member ${name}`);
|
||||
}
|
||||
}
|
||||
const staticSource = readFileSync(new URL('../src/space-card.ts', import.meta.url), 'utf8');
|
||||
for (const name of declaredMembers(SPACE_GLOW_CARD_CONTRACT)) {
|
||||
assert.match(staticSource, new RegExp(`\\b(?:private\\s+(?:(?:declare|readonly|get)\\s+)*|get\\s+)${name}\\b`),
|
||||
`${SPACE_GLOW_CARD_CONTRACT.label} declares missing production member ${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('contract accepts recent optional fields only when their runtime type is valid', () => {
|
||||
@@ -133,10 +142,13 @@ test('wall and light geometry reuse bounded caches before structural work', () =
|
||||
const light = source.slice(lightStart, lightEnd);
|
||||
assert.ok(
|
||||
light.indexOf('lruRead(this._lightBarrierPool, cacheKey)')
|
||||
< light.indexOf('const zeroWalls = this._zeroWalls();'),
|
||||
< light.indexOf('buildLightBarrierScene({'),
|
||||
'a cached light barrier must return before geometry classification',
|
||||
);
|
||||
assert.match(light, /contentFingerprint\(\[/);
|
||||
assert.match(light, /recutWallBodiesGeometry\(sharedWallGeometry, roomPassages, lightPhysical\)/);
|
||||
assert.match(light, /resolveLightBarrierRevision\(\{/);
|
||||
const shared = readFileSync(new URL('../src/glow-scene.ts', import.meta.url), 'utf8');
|
||||
assert.match(shared, /contentFingerprint\(\[/);
|
||||
assert.match(shared,
|
||||
/recutWallBodiesGeometry\(input\.sharedWallGeometry, roomPassages, opaqueBodies\)/);
|
||||
assert.match(light, /lruWrite\(this\._lightBarrierPool, cacheKey, entry, 8\)/);
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ test('ordinary Validate keeps only the bounded candidate performance smoke', ()
|
||||
'--variants=60 --samples=3 --warmups=1',
|
||||
'--absolute-only',
|
||||
'budgets-glow-smoke.json',
|
||||
'budgets-space-glow-smoke.json',
|
||||
'cancel-in-progress: true',
|
||||
]) assert.ok(workflow.includes(contract), `missing fast-gate contract: ${contract}`);
|
||||
assert.ok(!workflow.includes('Capture base and candidate profiles'));
|
||||
@@ -34,13 +35,15 @@ test('full performance is isolated to stable, scheduled and manual entry points'
|
||||
'- plan-snap',
|
||||
'- blend',
|
||||
'- overlay',
|
||||
'- space-default',
|
||||
'- space-glow',
|
||||
'PROFILE: ${{ matrix.profile }}',
|
||||
'name: full-performance-${{ matrix.profile }}',
|
||||
'--samples=7 --warmups=1',
|
||||
]) assert.ok(workflow.includes(contract), `missing full-gate contract: ${contract}`);
|
||||
|
||||
assert.ok(workflow.includes('if [ -f baseline/scripts/bundle-sync.mjs ]; then'));
|
||||
assert.equal((workflow.match(/--samples=7 --warmups=1/g) || []).length, 10);
|
||||
assert.equal((workflow.match(/--samples=7 --warmups=1/g) || []).length, 14);
|
||||
|
||||
const release = readWorkflow('release.yml');
|
||||
assert.ok(release.includes('if: ${{ !github.event.release.prerelease }}'));
|
||||
|
||||
Reference in New Issue
Block a user