perf(light): batch source entry and reuse render-local barriers

Coalesce source-entry updates without changing the 500 ms transition or
field geometry. Resolve shared barrier revisions once within a synchronous
render and always recheck content on the next pass. Release main/static
LED owners on no-strip exits and static owner/config teardown.

Protect scheduling, real browser fades, cold imports and the bundled
render-pass wiring. Performance acceptance remains a separate exact-SHA
full Linux run; this commit alone does not assert that AC3 has passed.

Issue: #789
User-Visible: yes
This commit is contained in:
Matysh
2026-10-04 07:13:27 +03:00
parent 45aeadb38e
commit 7777b8a0a8
16 changed files with 855 additions and 36 deletions
+377
View File
@@ -347,6 +347,322 @@ check('static live_states:true + light_pools:true — source colour and the fiel
JSON.stringify({ state: 'on', core: '#80d5ff', field: true, passive: true, badge: true }));
check('a click on any static stripe toggles nothing', await evaluate(() => window.__card.hass.states['light.ceiling'].state), 'on');
// #789 AC2: real transition events, not merely the declared CSS duration.
// Do not read computed style/layout between insertion and its entering rAF:
// that flush can manufacture the initial opacity and hide a lost fade.
await page.emulateMedia({ reducedMotion: 'no-preference' });
await evaluate(async () => {
const main = window.__card;
const host = document.createElement('div');
host.style.width = '700px'; document.body.appendChild(host);
const stat = document.createElement('houseplan-space-card');
stat.setConfig({ type: 'custom:houseplan-space-card', space: 'f1', light_pools: true, live_states: true });
stat.hass = main.hass; host.appendChild(stat);
await stat.updateComplete;
const proof = window.__ledFadeProof = {
cards: [main, stat], host, savedHass: main.hass, log: [], listeners: [],
selectors: ['[data-led-field="ceiling"]', '[data-glow-source="light.floor_lamp"]'],
parent: main.parentNode, next: main.nextSibling, updates: [0, 0], late: [], descriptors: [],
};
proof.nodes = () => proof.cards.map(card => proof.selectors.map(selector => card.shadowRoot.querySelector(selector)));
proof.set = async state => {
const states = { ...main.hass.states };
for (const eid of ['light.ceiling', 'light.floor_lamp']) states[eid] = { ...states[eid], state };
const hass = { ...main.hass, states };
for (const card of proof.cards) card.hass = hass;
await Promise.all(proof.cards.map(card => card.updateComplete));
};
for (const [cardIndex, card] of proof.cards.entries()) {
const listener = event => {
if (event.propertyName !== 'opacity') return;
const kind = proof.selectors.findIndex(selector => event.target.matches(selector));
if (kind >= 0) proof.log.push({ card: cardIndex, kind, event: event.type, elapsed: event.elapsedTime });
};
for (const name of ['transitionrun', 'transitionend']) card.shadowRoot.addEventListener(name, listener);
proof.listeners.push(listener);
}
await proof.set('off');
});
try {
await page.waitForFunction(() => window.__ledFadeProof.cards.every(card =>
card.shadowRoot.querySelector('[data-led-strip]')));
await page.waitForTimeout(650);
check('#789: both kinds start absent on both surfaces', await evaluate(() =>
window.__ledFadeProof.nodes().every(nodes => nodes.every(node => !node))), true);
const fadeSet = state => evaluate(async state => {
const proof = window.__ledFadeProof; proof.log = []; await proof.set(state);
}, state);
const fadeSnapshot = () => evaluate(() => {
const proof = window.__ledFadeProof;
return { log: proof.log, nodes: proof.nodes().map(nodes => nodes.map(node => node ? {
opacity: Number(getComputedStyle(node).opacity), leaving: node.classList.contains('is-leaving'),
entering: node.classList.contains('is-entering'),
} : null)) };
});
const checkFadeEvents = (phase, result, cards = [0, 1], kinds = [0, 1]) => {
for (const card of cards) for (const kind of kinds) {
const events = result.log.filter(event => event.card === card && event.kind === kind);
const label = `#789: ${card ? 'static' : 'main'} ${kind ? 'ordinary' : 'LED'} ${phase}`;
check(`${label} actually starts an opacity transition`, events.some(event => event.event === 'transitionrun'), true);
check(`${label} completes the unchanged 500 ms transition`, events.some(event =>
event.event === 'transitionend' && Math.abs(event.elapsed - 0.5) < 0.01), true);
}
};
await fadeSet('on');
await page.waitForTimeout(750);
const fadeOn = await fadeSnapshot();
checkFadeEvents('on', fadeOn);
check('#789: all four fields finish on at full opacity', fadeOn.nodes.every(nodes =>
nodes.every(node => node?.opacity === 1 && !node.leaving)), true);
// Observe the real bundled call graph, without wrapping private methods or
// inferring render-pass wiring from source text. Coverage is not timing data.
const coverageSession = await page.context().newCDPSession(page);
try {
await coverageSession.send('Profiler.enable');
await coverageSession.send('Profiler.startPreciseCoverage', { callCount: true, detailed: true });
for (const pass of [1, 2]) {
await coverageSession.send('Profiler.takePreciseCoverage');
await evaluate(async () => {
const card = window.__card;
card.requestUpdate();
for (let attempt = 0; attempt < 10; attempt++) {
const complete = await card.updateComplete;
if (complete !== false && !card.isUpdatePending) return;
}
throw new Error('#789: observed main render did not settle');
});
const result = await coverageSession.send('Profiler.takePreciseCoverage');
const functions = result.result.flatMap(script => script.functions);
const calls = name => functions.filter(fn => fn.functionName === name)
.reduce((sum, fn) => sum + (fn.ranges[0]?.count || 0), 0);
const counts = { render: calls('_renderLightPass'), reads: calls('_lightBarriers'), builds: calls('_resolveLightBarriers') };
check(`#789: observed main render pass ${pass} executes`, counts.render > 0, true);
check(`#789: Glow and LED share barrier reads in pass ${pass}`, counts.reads >= 2 * counts.render, true);
check(`#789: pass ${pass} resolves barriers exactly once per render, never across renders`, counts.builds, counts.render);
console.log('#789 render-pass coverage', JSON.stringify({ pass, ...counts }));
}
} finally {
await coverageSession.send('Profiler.stopPreciseCoverage');
await coverageSession.send('Profiler.disable');
await coverageSession.detach();
}
await fadeSet('off');
check('#789: normal off retains all four nodes for their fade', await evaluate(() =>
window.__ledFadeProof.nodes().every(nodes => nodes.every(node => node?.classList.contains('is-leaving')))), true);
await page.waitForTimeout(750);
const fadeOff = await fadeSnapshot();
checkFadeEvents('off', fadeOff);
check('#789: normal off removes all four fields after fading', fadeOff.nodes.every(nodes => nodes.every(node => !node)), true);
await page.emulateMedia({ reducedMotion: 'reduce' });
await fadeSet('on');
await page.waitForTimeout(200);
const reducedOn = await fadeSnapshot();
check('#789: reduced motion shows LED and ordinary immediately on both surfaces', reducedOn.nodes.every(nodes =>
nodes.every(node => node?.opacity === 1)), true);
check('#789: reduced on starts no CSS opacity animation', reducedOn.log.length, 0);
await fadeSet('off');
await page.waitForTimeout(200);
const reducedOff = await fadeSnapshot();
check('#789: reduced off is immediately dark on both surfaces', reducedOff.nodes.every(nodes =>
nodes.every(node => node?.opacity === 0)), true);
check('#789: reduced off starts no CSS opacity animation', reducedOff.log.length, 0);
await page.waitForTimeout(450);
check('#789: reduced off still removes every field', await evaluate(() =>
window.__ledFadeProof.nodes().every(nodes => nodes.every(node => !node))), true);
await page.emulateMedia({ reducedMotion: 'no-preference' });
await fadeSet('on');
await page.waitForTimeout(750);
await evaluate(() => { window.__ledFadeProof.savedNodes = window.__ledFadeProof.nodes(); });
await fadeSet('off');
await page.waitForTimeout(100);
await fadeSet('on');
await page.waitForTimeout(750); // beyond the cancelled off timer as well as the returning fade
check('#789: rapid off/on retains every DOM node beyond the stale fade deadline', await evaluate(() => {
const proof = window.__ledFadeProof;
return proof.nodes().every((nodes, card) => nodes.every((node, kind) => node
&& node === proof.savedNodes[card][kind] && !node.classList.contains('is-leaving')
&& Number(getComputedStyle(node).opacity) === 1));
}), true);
await fadeSet('off');
await page.waitForTimeout(650);
await evaluate(async () => {
// Both Lit commits finish in microtasks, before a browser entering-frame.
await window.__ledFadeProof.set('on'); await window.__ledFadeProof.set('off');
});
await page.waitForTimeout(650);
check('#789: off before the entry frame cannot resurrect a field', await evaluate(() =>
window.__ledFadeProof.nodes().every(nodes => nodes.every(node => !node))), true);
await evaluate(async () => {
const proof = window.__ledFadeProof;
await proof.set('on');
// Lit's updated() can enqueue a header/summary follow-up. Drain only its
// microtasks before observing disconnect: entry rAFs must still be pending.
const drainUpdates = async phase => {
for (let attempt = 0; attempt < 10; attempt++) {
const complete = await Promise.all(proof.cards.map(card => card.updateComplete));
if (complete.every(result => result !== false) && proof.cards.every(card => !card.isUpdatePending)) return;
}
throw new Error(`#789: pending Lit microtasks did not settle ${phase} disconnect`);
};
await drainUpdates('before');
if (!proof.nodes().every(nodes => nodes.every(node => node?.classList.contains('is-entering')))) {
throw new Error('#789: disconnect witness must precede every entering frame');
}
for (const [index, card] of proof.cards.entries()) {
proof.descriptors[index] = Object.getOwnPropertyDescriptor(card, 'requestUpdate');
const original = card.requestUpdate;
card.requestUpdate = function (...args) {
proof.updates[index]++;
if (!this.isConnected) proof.late.push({ card: index, stack: new Error().stack });
return original.apply(this, args);
};
}
// Disconnect in this microtask, with entry rAFs/feather timers still pending.
for (const card of proof.cards) card.remove();
// Teardown itself mutates reactive header/summary state. Finish that same
// microtask chain before measuring late callbacks; no rAF/timer can run yet.
await drainUpdates('after');
proof.updates = [0, 0];
proof.late = [];
});
await page.waitForTimeout(750);
check('#789: disconnected main/static owners receive no late transition updates', await evaluate(() =>
window.__ledFadeProof.updates), [0, 0]);
const late = await evaluate(() => window.__ledFadeProof.late);
if (late.length) console.log('#789 late update diagnostic', JSON.stringify(late, null, 2));
await evaluate(async () => {
const proof = window.__ledFadeProof; proof.log = [];
proof.parent.insertBefore(proof.cards[0], proof.next?.parentNode === proof.parent ? proof.next : null);
proof.host.appendChild(proof.cards[1]);
await Promise.all(proof.cards.map(card => card.updateComplete));
});
await page.waitForTimeout(750);
const reconnected = await fadeSnapshot();
checkFadeEvents('reconnected', reconnected);
check('#789: reconnect starts clean lifecycles and returns all four fields', reconnected.nodes.every(nodes =>
nodes.every(node => node?.opacity === 1 && !node.leaving)), true);
for (const exit of [
{ label: 'disabled pools', config: { space: 'f1', light_pools: false } },
{ label: 'another space', config: { space: 'garden', light_pools: true } },
]) {
await fadeSet('off');
await page.waitForTimeout(650);
await evaluate(async config => {
const proof = window.__ledFadeProof;
await proof.set('on');
if (!proof.nodes()[1].every(node => node?.classList.contains('is-entering'))) {
throw new Error('#789: config exit must precede the static entering frame');
}
proof.cards[1].setConfig({ type: 'custom:houseplan-space-card', live_states: true, ...config });
await proof.cards[1].updateComplete;
}, exit.config);
await page.waitForTimeout(750);
check(`#789: ${exit.label} removes previous static fields beyond their old callbacks`, await evaluate(() =>
window.__ledFadeProof.nodes()[1].every(node => !node)), true);
await evaluate(async () => {
const proof = window.__ledFadeProof; proof.log = [];
proof.cards[1].setConfig({ type: 'custom:houseplan-space-card', space: 'f1', light_pools: true, live_states: true });
await proof.cards[1].updateComplete;
});
await page.waitForTimeout(750);
const returned = await fadeSnapshot();
checkFadeEvents(`return from ${exit.label}`, returned, [1]);
check(`#789: return from ${exit.label} starts fresh static fields`, returned.nodes[1].every(node =>
node?.opacity === 1 && !node.leaving), true);
}
await fadeSet('off');
await page.waitForTimeout(650);
await evaluate(async () => {
const proof = window.__ledFadeProof;
await proof.set('on');
if (!proof.nodes()[0].every(node => node?.classList.contains('is-entering'))) {
throw new Error('#789: main navigation must start before the entering frame');
}
await window.__hpTest.switchSpace('garden');
});
await page.waitForTimeout(750);
check('#789: main LED-to-empty navigation removes both former sources beyond stale callbacks', await evaluate(() =>
window.__ledFadeProof.nodes()[0].every(node => !node)), true);
await evaluate(async () => {
window.__ledFadeProof.log = [];
await window.__hpTest.switchSpace('f1');
});
await page.waitForTimeout(750);
const mainReturned = await fadeSnapshot();
checkFadeEvents('return from LED-free space', mainReturned, [0], [0]);
// Main ordinary Glow has retained its per-space appearance without replay on
// navigation since before #789. Preserve that UX; only LED starts a fresh
// lifecycle here. The real on/off fade assertions above still cover both.
check('#789: main navigation back restores the LED field at full opacity',
mainReturned.nodes[0][0]?.opacity === 1 && !mainReturned.nodes[0][0].leaving, true);
const retainedOrdinary = mainReturned.nodes[0][1];
check('#789: main navigation preserves steady ordinary Glow without replay',
retainedOrdinary?.opacity === 1 && !retainedOrdinary.entering && !retainedOrdinary.leaving, true);
// Keep the same mounted static card while its server-side space vanishes.
// This exercises renderSpaceStatic's !space exit, not setConfig/disconnect.
await fadeSet('off');
await page.waitForTimeout(650);
await evaluate(async () => {
const proof = window.__ledFadeProof;
await proof.set('on');
if (!proof.nodes()[1].every(node => node?.classList.contains('is-entering'))) {
throw new Error('#789: server removal must start before the static entering frame');
}
await window.__hpTest.setServerConfig(cfg => {
proof.savedConfig = structuredClone(cfg);
cfg.spaces = cfg.spaces.filter(space => space.id !== 'f1');
cfg.markers = (cfg.markers || []).filter(marker => marker.space !== 'f1');
});
});
await page.waitForFunction(() => !window.__ledFadeProof.cards[1].shadowRoot.querySelector('[data-led-strip]'));
await page.waitForTimeout(750);
check('#789: a missing server space clears both fields without disconnecting the static card', await evaluate(() => {
const proof = window.__ledFadeProof;
return proof.cards[1].isConnected && proof.nodes()[1].every(node => !node);
}), true);
await evaluate(async () => {
const proof = window.__ledFadeProof; proof.log = [];
await window.__hpTest.setServerConfig(proof.savedConfig);
delete proof.savedConfig;
});
await page.waitForTimeout(750);
const restoredSpace = await fadeSnapshot();
checkFadeEvents('restored server space', restoredSpace, [1]);
check('#789: restoring a server space starts fresh static LED and ordinary fields', restoredSpace.nodes[1].every(node =>
node?.opacity === 1 && !node.leaving), true);
await evaluate(() => window.__hpTest.switchSpace('f1'));
await page.waitForTimeout(750);
} finally {
await page.emulateMedia({ reducedMotion: 'no-preference' });
await evaluate(async () => {
const proof = window.__ledFadeProof;
if (proof.savedConfig) {
await window.__hpTest.setServerConfig(proof.savedConfig);
delete proof.savedConfig;
}
for (const [index, card] of proof.cards.entries()) {
for (const name of ['transitionrun', 'transitionend']) card.shadowRoot.removeEventListener(name, proof.listeners[index]);
if (index < proof.descriptors.length) {
if (proof.descriptors[index]) Object.defineProperty(card, 'requestUpdate', proof.descriptors[index]);
else delete card.requestUpdate;
}
}
const main = proof.cards[0];
if (!main.isConnected) proof.parent.insertBefore(main, proof.next?.parentNode === proof.parent ? proof.next : null);
main.hass = proof.savedHass;
proof.cards[1].remove(); proof.host.remove();
delete window.__ledFadeProof;
await main.updateComplete;
});
await page.waitForFunction(() => window.__card.shadowRoot.querySelector('[data-led-strip]'));
await settle();
}
for (const mode of ['plan', 'decor']) {
await evaluate(mode => window.__hpTest.setMode(mode), mode);
check(`#790: ${mode} keeps the coloured core passive and translucent`, await evaluate(() => {
@@ -401,4 +717,65 @@ check('the press loads the tool and draws for the same marker', await evaluate((
return !!led?.tool && led.chain?.convert === 'd_tv' && !window.__card._markerDialog;
}));
// A fresh document makes both lazy imports genuinely cold. Hold each network
// response, disconnect its requesting static card, then let the actual module
// finish loading: no private runtime hooks or synthetic callback.
await evaluate(() => localStorage.clear()); // isolated demo browser storage only
ledRequests.length = 0;
await page.reload();
await page.waitForFunction(() => window.__card?._loadOk);
await installHpTestOnPage(page);
await evaluate(async () => {
window.__card.setConfig({ type: 'custom:houseplan-card', floor: 'garden' });
await window.__card.updateComplete;
});
await setStrips(ceilingStrip, { glow_enabled: true });
check('#789: fixed non-LED main card leaves both LED imports cold', ledRequests.filter(url =>
/^led-strip-(runtime|field)-/.test(url)).length, 0);
for (const kind of ['runtime', 'field']) {
let releaseImport;
const barrier = new Promise(resolve => { releaseImport = resolve; });
const pattern = new RegExp(`/led-strip-${kind}-[^/]+\\.js(?:\\?.*)?$`);
const holdImport = async route => { await barrier; await route.fallback(); };
await page.route(pattern, holdImport);
try {
const requested = page.waitForRequest(pattern);
await evaluate(async () => {
const card = document.createElement('houseplan-space-card');
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1', light_pools: true, live_states: true });
card.hass = window.__card.hass;
document.body.appendChild(card);
window.__ledColdProof = { card, updates: 0, original: card.requestUpdate };
await card.updateComplete;
});
await requested;
check(`#789: cold static ${kind} import has not painted its content yet`, await evaluate(kind =>
!window.__ledColdProof.card.shadowRoot.querySelector(kind === 'runtime' ? '[data-led-strip]' : '[data-led-field]'), kind), true);
await evaluate(async () => {
const proof = window.__ledColdProof, card = proof.card;
card.remove();
let settled = false;
for (let attempt = 0; attempt < 10; attempt++) {
const complete = await card.updateComplete;
if (complete !== false && !card.isUpdatePending) { settled = true; break; }
}
if (!settled) throw new Error('#789: cold static teardown did not settle');
card.requestUpdate = function (...args) { proof.updates++; return proof.original.apply(this, args); };
});
const arrived = page.waitForResponse(pattern);
releaseImport();
await (await arrived).finished();
await page.waitForTimeout(750);
check(`#789: a cold ${kind} arriving after static disconnect requests no update`, await evaluate(() =>
window.__ledColdProof.updates), 0);
} finally {
releaseImport();
await page.unroute(pattern, holdImport);
await evaluate(() => {
const proof = window.__ledColdProof;
if (proof) { delete proof.card.requestUpdate; proof.card.remove(); delete window.__ledColdProof; }
});
}
}
await finish(browser);
+4 -1
View File
@@ -520,7 +520,10 @@ ignores `#space=`.
visual layer with one selection/transform/history pipeline; image bytes live
only in the asset store: [DECOR-EDITOR](DECOR-EDITOR.md), [FURNITURE](FURNITURE.md).
- **Light and Glow** — one visibility region per source (#71); Glow is an overlay
independent of the data fill (#55): [LIGHT](LIGHT.md). **Room fill** —
independent of the data fill (#55): [LIGHT](LIGHT.md). Shared source entry
scheduling is owner-local; `LightBarrierPass` shares revisions only inside
the main card's synchronous render, never across subsequent edits (#789).
**Room fill** —
`resolveEffectiveRoomFill()` is the single projection for room floors,
clean-floor holes and opening tunnels; `room_color` styles only borders and
names; custom colour and legacy tokens (#56, #581):
+5
View File
@@ -2,6 +2,11 @@
## Unreleased
- Removed redundant card updates when many lights appear together and repeated
light-barrier calculations within a frame; the light geometry and fades are
unchanged. Pending LED updates are also cleared when leaving their space or
disconnecting a card ([#789](https://github.com/Matysh/houseplan-card/issues/789)).
- An on LED strip now shows its light colour inside the outline even when
Glow is enabled; off strips stay white
([#790](https://github.com/Matysh/houseplan-card/issues/790)).
+6
View File
@@ -8,6 +8,12 @@
## Не выпущено
- Убраны лишние обновления карточки при одновременном появлении множества
источников света и повторные расчёты препятствий в одном кадре. Форма света
и плавность переходов сохранены. Отложенные обновления LED также очищаются
при уходе с пространства или отключении карточки
([#789](https://github.com/Matysh/houseplan-card/issues/789)).
- Ядро включённой LED-ленты внутри обводки теперь показывает цвет её свечения
и при включённом Glow; выключенная лента остаётся белой
([#790](https://github.com/Matysh/houseplan-card/issues/790)).
+9
View File
@@ -183,6 +183,15 @@ new `size-pack` with 467.63 MiB; the accepted upper bound is 487.63 MiB.
- Frontend: `npm test` — compiles src/logic.ts+rules.ts (tsconfig.test.json) and runs node:test
(test/*.test.mjs). Strict typing: `npm run typecheck` (tsc --noEmit, part of `npm run build`).
- Shared Glow scheduling and render-local barrier reuse (#789) have separate
scheduler/memo unit witnesses. `smoke_led_strip_glow` observes real opacity
transition events on main/static LED and ordinary pools, without a forced
style read between insertion and the entering frame. A declared 500 ms CSS
duration alone is not evidence that a fade actually runs. Main-card ordinary
Glow retains its previous no-replay behavior on return to a visited space;
that navigation witness checks its steady state, not a newly invented fade.
The complete
`led-strips-v1` performance gate remains separate from these correctness checks.
- Pure backend on native Windows (with no HA plugin autoload): use the explicit
`python -Arguments @(...)` invocation above after setting
`$env:PYTEST_DISABLE_PLUGIN_AUTOLOAD='1'`.
+17
View File
@@ -429,6 +429,23 @@ only the geometry differs:
luminance bands of the shared `GLOW_FALLOFF` in one mask, so corners and the
closing of a loop neither seam nor double the brightness. Intensity and the 500 ms fade
are the shared `glowAlpha` / `GLOW_FADE_MS`.
- **Scheduling.** Sources first seen by one synchronous render share one
entering-frame callback per owner/runtime, not one complete update per
source (#789). A microtask seals the batch so a later rAF cannot bring a
newly inserted source into an earlier entry frame. Forget/disconnect cancels
empty batches; identity checks reject late callbacks. Normal 500 ms fade and
reduced-motion behavior remain shared with ordinary Glow.
- **Render-local scene reuse.** The main card shares a resolved barrier scene
between its Glow/LED consumers within one synchronous `render()` only
(`LightBarrierPass`). `finally` clears the memo even on early return or error.
The next render, callbacks and handlers still resolve the content-based
revision; this is not a cross-frame cache keyed only by object identity.
- **Owner teardown.** Main and static cards release LED state when their next
space has no visible strips, not only when another field renders. The static
card also releases it on disconnect, space change or disabling light pools;
switching off live states releases the field while retaining the neutral tube.
Both lazy runtime and field readiness callbacks check the actual owner's
connection before requesting an update. Reconnect creates a fresh lifecycle.
- **Visibility.** Classify and sample the full polyline at radius/4 or finer,
retaining every actual vertex and both ends; never thin a short final run
or an acute corner. The continuous field is clipped to the union of the
+1
View File
@@ -31,6 +31,7 @@ Everything computable from the tree and git; regenerate, never edit by hand
| Branches | `main` carries stable releases only; pre-release tags point at `dev`. Work lands on `dev`, which is equal to or ahead of `main`, never behind. |
| LED regression acceptance | #788 covers mixed face/free and fractional-coordinate joins, winding-safe field unions, retained endpoints/acute vertices and circle–wall transition events. The actual owner export is exercised locally; public fixtures are synthetic. Pixel oracles, not just SVG counts, protect the visible falloff. |
| LED core colour | #790 makes the on core follow the resolved light colour with or without Glow. Off, unavailable and non-live static strips keep their neutral presentation; geometry and field brightness are unchanged. |
| LED performance work | #789 batches entering callbacks per owner and reuses light-barrier revisions within one synchronous render. No change to bands, emitters, clips or raster precision. The complete 50×50 performance acceptance remains mandatory; these structural changes alone do not certify the camera budget. |
| Warm remount | Delayed editor restoration preserves the original View camera, reserves pending chrome space, and yields synchronously to mode/space navigation (#762). Header/stage dimensions are published as one settled pair. Canonical: `docs/WARM-REMOUNT.md`. |
| 2.5D View | Public since #649: the installation-wide General settings switch `settings.volumetric_view` (Display). Flat stays the default and byte-for-byte unchanged; editors and `houseplan-space-card` stay Flat. Canonical: `docs/ISOMETRIC.md`. |
| Input support | Owner's rule since 2026-08-08: View and kiosk are fully supported and release-blocking on touch; the three editors are desktop-first, touch editing is best effort. Canonical: `docs/TOUCH-SUPPORT.md`. |
@@ -14,11 +14,11 @@ to prove that the Node witness actually kills it.
| --- | ---: | --- |
| Performance threshold | 4 | The witness measures real browser wall-time or frame work; a pure assertion cannot prove the budget. |
| Browser harness integrity | 4 | The mutation breaks page-error, round-trip or page-registration observation in the browser harness itself. |
| Paint, cascade and layer composition | 36 | The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels produced by Chromium. |
| Paint, cascade and layer composition | 37 | The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels produced by Chromium. |
| Pointer geometry and trusted interaction | 47 | The invariant depends on hit testing, pointer capture, touch/keyboard dispatch or live DOM geometry. |
| Responsive DOM layout | 38 | The invariant depends on measured element boxes, responsive breakpoints, native/HA dialog shells or focusable target size. |
| Custom-element and HA browser lifecycle | 101 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. |
| **Total** | **230 / 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 | 103 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. |
| **Total** | **233 / 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
@@ -81,6 +81,7 @@ The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels p
- `device-keyboard-bypasses-click-path`
- `device-long-value-ellipsis-restored`
- `device-unavailable-hover-restored`
- `glow-entry-initial-opacity-skipped`
- `golden-filled-tunnel-removed`
- `golden-lamp-out-of-reach`
- `hatch-static-renderer-untouched`
@@ -250,6 +251,8 @@ The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy
- `floor-geometry-key-global-epoch`
- `floor-geometry-key-ignores-content`
- `french-locale-wrong-dictionary`
- `glow-barrier-render-pass-wiring-skipped`
- `glow-static-led-release-skipped`
- `household-enter-stops-acting`
- `household-marker-drops-keyboard-reach`
- `hp-dialog-escape-does-not-close`
+57 -2
View File
@@ -7387,7 +7387,7 @@ const MUTANT_DEFINITIONS = [
find: ' ${passageGlowTunnels}\n'
+ ' <g class="decorlayer" pointer-events="none">${decorImages}</g>\n'
+ ' <g class="hp-stairs-layer" pointer-events="none">${stairShapes}</g>\n'
+ ' ${glowPools}\n ${ledVisible(',
+ ' ${glowPools}\n ${showLed ?',
replace: ' ${passageGlowTunnels}\n'
+ ' ${!space.bg && !disp.showNames ? svg`<g class="room-svg-labels" pointer-events="none">${space.rooms.map((room) => {\n'
+ ' const center = roomCenter(room);\n'
@@ -7397,7 +7397,7 @@ const MUTANT_DEFINITIONS = [
+ ' <g class="decorlayer" pointer-events="none">${decorImages}</g>\n'
+ ' <g class="hp-stairs-layer" pointer-events="none">${stairShapes}</g>\n'
+ ' ${glowPools}\n'
+ ' ${ledVisible(',
+ ' ${showLed ?',
}],
},
{
@@ -14234,6 +14234,61 @@ const MUTANT_DEFINITIONS = [
replace: " ? body.slice(0, at).replace(/\\s+$/, '')",
}],
},
{
id: 'glow-barrier-render-pass-wiring-skipped',
guard: 'node demo/smoke_led_strip_glow.mjs',
because: '#789 AC4: precise coverage of the real main-card Glow plus LED render must resolve barriers '
+ 'once per pass; only this bundled custom-element path proves the pass cache is actually wired to its consumers',
patches: [{
file: 'src/houseplan-card.ts',
find: ' return this._lightBarrierPass.read(space, () => this._resolveLightBarriers(space));',
replace: ' return this._resolveLightBarriers(space);',
}],
},
{
id: 'glow-barrier-render-pass-reuse-skipped',
guard: 'node --test test/light-barrier-pass.test.mjs',
because: '#789 AC4: a synchronous render pass must fingerprint/build each space once while still '
+ 'invalidating across passes; disabling its local read cache repeats expensive work without changing pixels',
patches: [{
file: 'src/glow-scene.ts',
find: ' const retained = this.scenes?.get(space);',
replace: ' const retained = undefined;',
}],
},
{
id: 'glow-entry-initial-opacity-skipped',
guard: 'node demo/smoke_led_strip_glow.mjs',
because: '#789 AC2: only browser CSS paint and real opacity transitionrun/transitionend events '
+ 'prove that LED and ordinary pools on both surfaces paint their initial entering frame without a forced style flush',
patches: [{
file: 'src/glow-scene.ts',
find: ' state.enteringSources.add(key);',
replace: ' // mutant: render the new source fully visible before its entry frame',
}],
},
{
id: 'glow-static-led-release-skipped',
guard: 'node demo/smoke_led_strip_glow.mjs',
because: '#789 AC4: disconnecting and reconnecting the actual static custom element must start a fresh '
+ 'LED entering transition; a host connection guard alone cannot release the retained field lifecycle',
patches: [{
file: 'src/space-card.ts',
find: ' ledRelease(this._glowRuntimeState);\n this._continuityHistory =',
replace: ' // mutant: retain the static LED field lifecycle across disconnect\n this._continuityHistory =',
}],
},
{
id: 'glow-entry-per-source-frame',
guard: 'node --test test/glow-entry-batch.test.mjs',
because: '#789 AC1: the controlled scheduler must see one frame and one update for 50 simultaneous '
+ 'sources; disabling batch reuse restores the former linear callback fan-out without needing a browser',
patches: [{
file: 'src/glow-scene.ts',
find: ' let batch = state.collectingEntryBatch;',
replace: ' let batch: GlowEntryBatch | null = null;',
}],
},
{
id: 'led-icon-not-suppressed',
guard: 'node demo/smoke_led_strip_glow.mjs',
+71 -19
View File
@@ -88,6 +88,31 @@ export interface LightBarrierScene {
opaqueBodies: number[][][];
}
/**
* One synchronous render reads the same scene for ordinary Glow and LED.
* Share that read, including its content fingerprint, only inside the render.
* Handlers, subsequent renders and exception paths never retain the memo:
* in-place edits and changed opening states must still rebuild the revision.
*/
export class LightBarrierPass {
private scenes: Map<SpaceModel, LightBarrierScene> | null = null;
run<T>(render: () => T): T {
const previous = this.scenes;
this.scenes = new Map();
try { return render(); }
finally { this.scenes.clear(); this.scenes = previous; }
}
read(space: SpaceModel, build: () => LightBarrierScene): LightBarrierScene {
const retained = this.scenes?.get(space);
if (retained) return retained;
const scene = build();
this.scenes?.set(space, scene);
return scene;
}
}
export interface LightZeroWalls {
contour: number[][];
barriers: number[][];
@@ -101,6 +126,8 @@ export interface GlowRuntimeHost {
reducedMotion: () => boolean;
}
type GlowEntryBatch = { keys: Set<string>; raf: number };
export interface GlowRuntimeState {
clipCache: Map<string, GlowClipGeometry | null>;
geometryWarnings: Set<string>;
@@ -108,7 +135,8 @@ export interface GlowRuntimeState {
renderedSources: Map<string, number>;
lastAppearance: Map<string, { c: string; alpha: number }>;
enteringSources: Set<string>;
enterRafs: Map<string, number>;
enterBatches: Set<GlowEntryBatch>;
collectingEntryBatch: GlowEntryBatch | null;
fadeTimers: Map<string, number>;
featherSuspendUntil: number;
featherResumeTimer: number;
@@ -129,7 +157,8 @@ export function createGlowRuntimeState(): GlowRuntimeState {
renderedSources: new Map(),
lastAppearance: new Map(),
enteringSources: new Set(),
enterRafs: new Map(),
enterBatches: new Set(),
collectingEntryBatch: null,
fadeTimers: new Map(),
featherSuspendUntil: 0,
featherResumeTimer: 0,
@@ -435,6 +464,40 @@ function suspendGlowFeather(
state.featherResumeTimer = win.setTimeout(resume, delay);
}
/** All sources discovered by one render enter in one frame, not N renders. */
function scheduleGlowEntry(state: GlowRuntimeState, host: GlowRuntimeHost, key: string): void {
let batch = state.collectingEntryBatch;
if (!batch) {
const next: GlowEntryBatch = { keys: new Set(), raf: 0 };
state.collectingEntryBatch = next;
state.enterBatches.add(next);
next.raf = host.window().requestAnimationFrame(() => {
if (!state.enterBatches.delete(next)) return;
if (state.collectingEntryBatch === next) state.collectingEntryBatch = null;
for (const source of next.keys) state.enteringSources.delete(source);
if (next.keys.size && host.isConnected()) host.requestUpdate();
});
// Seal this synchronous render before other rAF callbacks can discover
// new sources: those need their own next frame, not this earlier batch.
queueMicrotask(() => {
if (state.collectingEntryBatch === next) state.collectingEntryBatch = null;
});
batch = next;
}
batch.keys.add(key);
}
function forgetGlowEntry(state: GlowRuntimeState, host: GlowRuntimeHost, key: string): void {
state.enteringSources.delete(key);
for (const batch of state.enterBatches) {
batch.keys.delete(key);
if (batch.keys.size) continue;
host.window().cancelAnimationFrame(batch.raf);
state.enterBatches.delete(batch);
if (state.collectingEntryBatch === batch) state.collectingEntryBatch = null;
}
}
export function transitionGlowSource(
state: GlowRuntimeState,
host: GlowRuntimeHost,
@@ -454,21 +517,12 @@ export function transitionGlowSource(
domId = ++state.sourceSeq;
state.renderedSources.set(key, domId);
state.enteringSources.add(key);
const raf = win.requestAnimationFrame(() => {
if (state.enterRafs.get(key) !== raf) return;
state.enterRafs.delete(key);
state.enteringSources.delete(key);
if (host.isConnected()) host.requestUpdate();
});
state.enterRafs.set(key, raf);
scheduleGlowEntry(state, host, key);
}
return { domId, entering: state.enteringSources.has(key), leaving: false };
}
if (domId == null) return null;
const enterRaf = state.enterRafs.get(key);
if (enterRaf != null) win.cancelAnimationFrame(enterRaf);
state.enterRafs.delete(key);
state.enteringSources.delete(key);
forgetGlowEntry(state, host, key);
if (!state.fadeTimers.has(key)) {
suspendGlowFeather(state, host);
const timer = win.setTimeout(() => {
@@ -488,12 +542,9 @@ export function forgetGlowSource(
): void {
const win = host.window();
const timer = state.fadeTimers.get(key);
const raf = state.enterRafs.get(key);
if (timer != null) win.clearTimeout(timer);
if (raf != null) win.cancelAnimationFrame(raf);
state.fadeTimers.delete(key);
state.enterRafs.delete(key);
state.enteringSources.delete(key);
forgetGlowEntry(state, host, key);
state.renderedSources.delete(key);
state.lastAppearance.delete(key);
}
@@ -524,12 +575,13 @@ export function disposeGlowRuntime(
): void {
const win = host.window();
for (const timer of state.fadeTimers.values()) win.clearTimeout(timer);
for (const raf of state.enterRafs.values()) win.cancelAnimationFrame(raf);
for (const batch of state.enterBatches) win.cancelAnimationFrame(batch.raf);
win.clearTimeout(state.featherResumeTimer);
state.clipCache.clear();
state.geometryWarnings.clear();
state.fadeTimers.clear();
state.enterRafs.clear();
state.enterBatches.clear();
state.collectingEntryBatch = null;
state.enteringSources.clear();
state.renderedSources.clear();
state.lastAppearance.clear();
+17 -3
View File
@@ -180,7 +180,7 @@ import {
buildGlowClipGeometry, buildLightBarrierScene, createGlowRuntimeState,
disposeGlowRuntime, forgetGlowSource, forgetGlowSpace, glowSourceInOpaqueBody,
pruneGlowSources, readGlowClip, renderGlowPools, resolveGlowCandidates, resolveGlowFeather,
lightGeometryFingerprint, resolveLightBarrierRevision, transitionGlowSource,
LightBarrierPass, lightGeometryFingerprint, resolveLightBarrierRevision, transitionGlowSource,
warnGlowGeometryFallback, writeGlowClip,
type GlowRuntimeHost, type GlowRuntimeState, type GlowSpot,
} from './glow-scene';
@@ -1804,6 +1804,7 @@ export class HouseplanCard extends LitElement {
private _innerContourCache = new Map<string, number[][] | null>();
private readonly _floorKey = floorGeometryKeyReader(this as unknown as FloorKeySource); // #744: key of the four caches above
private readonly _glowRuntimeState: GlowRuntimeState = createGlowRuntimeState();
private readonly _lightBarrierPass = new LightBarrierPass();
private readonly _glowRuntimeHost: GlowRuntimeHost = {
window: () => this.ownerDocument.defaultView || window,
isConnected: () => this.isConnected,
@@ -10325,6 +10326,10 @@ export class HouseplanCard extends LitElement {
occluders: LightSegment[]; floor: number[][][]; fingerprint: string;
masonryGeometry: any; opaqueBodies: number[][][];
} {
return this._lightBarrierPass.read(space, () => this._resolveLightBarriers(space));
}
private _resolveLightBarriers(space: SpaceModel) {
const raw = this._curSpaceCfg;
const revision = resolveLightBarrierRevision({
rawSpaceConfig: raw,
@@ -10511,8 +10516,13 @@ export class HouseplanCard extends LitElement {
/** #780: the lazy LED chunk when the space shows a strip (ТЗ §13.1). */
private _ledRt(space: SpaceModel) {
// A disconnected card never applies the chunk (a late import, r1 M5).
return this.isConnected && ledVisible(space, this._renderDevices, this._mode === 'devices' && this._showAll, this._serverCfg?.markers)
? ledRuntime(space.id, () => this.isConnected && this.requestUpdate()) : null;
if (!this.isConnected || !ledVisible(space, this._renderDevices, this._mode === 'devices' && this._showAll, this._serverCfg?.markers)) {
// The next space may have no strips, so no field render can prune the
// previous owner's entry frames, fade timers or cached geometry.
ledRelease(this);
return null;
}
return ledRuntime(space.id, () => this.isConnected && this.requestUpdate());
}
/** #780: the Devices-editor LED tool, its own lazy chunk (src/led-strip-card.ts). */
@@ -10617,6 +10627,10 @@ export class HouseplanCard extends LitElement {
}
protected render(): TemplateResult | typeof nothing | typeof noChange {
return this._lightBarrierPass.run(() => this._renderLightPass());
}
private _renderLightPass(): TemplateResult | typeof nothing | typeof noChange {
try {
const body = this._renderBody();
// `nothing` is the only root that has no decision surface. `noChange` is
+5 -3
View File
@@ -613,6 +613,7 @@ export interface StaticLedInput {
owner: object;
/** Re-render when the field chunk lands. */
ready: () => void;
isConnected: () => boolean;
}
export function renderStaticLed(input: StaticLedInput): TemplateResult {
@@ -642,10 +643,11 @@ export function renderStaticLed(input: StaticLedInput): TemplateResult {
occluders: [], floor: [], fingerprint: '',
masonryGeometry: input.bodies.masonryGeometry, opaqueBodies: input.bodies.opaqueBodies,
}, (LED_EPSILON_CM / input.cellCm) * input.gridPitch);
const module = input.scene && input.live && (fieldWanted(views) || field?.hasLedField(input.owner))
? ledField(input.space.id, input.ready) : null;
const module = input.isConnected() && input.scene && input.live && (fieldWanted(views) || field?.hasLedField(input.owner))
? ledField(input.space.id, () => input.isConnected() && input.ready()) : null;
if (!module) field?.releaseLedField(input.owner);
return svg`${module ? module.renderLedField({
views, scene: input.scene as LightBarrierScene, polygons, faces, spaceId: input.space.id, owner: input.owner,
requestUpdate: input.ready, isConnected: () => true,
requestUpdate: input.ready, isConnected: input.isConnected,
}) : nothing}${renderLedStripes({ views, d: frame.d, faces, perUnit: input.perUnit, handlers: null })}` as unknown as TemplateResult;
}
+7
View File
@@ -18,6 +18,7 @@ import { ContentSigner } from './signing';
import { normalizeDeviceDisplay, openingEntityReferences, referencedContentUrls } from './logic';
import { acquireHaRegistries, activeRegistryHass, haRegistrySnapshot } from './ha-binding-status';
import { resolvedLightSources } from './devices';
import { ledRelease } from './led-strip-gate';
import {
activitySourceSignature, resolveDevicePresentation, resolvePresentationSources,
} from './device-presentation';
@@ -297,6 +298,10 @@ class HouseplanSpaceCard extends LitElement {
if (!config || !config.space) {
throw new Error('houseplan-space-card: "space" is required');
}
if (this._config?.space !== config.space) {
disposeGlowRuntime(this._glowRuntimeState, this._glowRuntimeHost);
ledRelease(this._glowRuntimeState);
}
this._config = {
show_button: true, button_target: '/plan-doma',
live_states: true, show_temperature: true, show_signal: true,
@@ -309,6 +314,7 @@ class HouseplanSpaceCard extends LitElement {
// and a blend probe alive.
if (this._config.light_pools !== true) {
disposeGlowRuntime(this._glowRuntimeState, this._glowRuntimeHost);
ledRelease(this._glowRuntimeState);
} else if (this.isConnected) {
this._resolveGlowBlend();
}
@@ -369,6 +375,7 @@ class HouseplanSpaceCard extends LitElement {
for (const runtime of this._activityRuntime.values()) window.clearTimeout(runtime.timer);
this._activityRuntime.clear();
disposeGlowRuntime(this._glowRuntimeState, this._glowRuntimeHost);
ledRelease(this._glowRuntimeState);
this._continuityHistory = [...this._continuityHistory, ...this._continuity.trace].slice(-80);
this._continuity.dispose();
this._continuityDisposed = true;
+16 -5
View File
@@ -59,14 +59,14 @@ import {
type Layout, type ContentItem, type SpaceCardFit,
} from './space-geometry';
import { resolveZeroWalls } from './zero-walls';
import { ledAnchor, ledRuntime, ledStripsByMarker, ledVisible } from './led-strip-gate';
import { ledAnchor, ledRelease, ledRuntime, ledStripsByMarker, ledVisible } from './led-strip-gate';
import { geometryOpenings } from './plan-geometry-preflight';
import { resolveDeviceAreaRelocations } from './device-area-relocation';
import { projectDecorImage } from './decor-assets';
import type { DecorShape } from './editors/decor/types';
import { cachedStairRenderGeometry, stairOutline, stairStyleVars } from './stairs';
import {
buildGlowClipGeometry, buildLightBarrierScene, forgetGlowSource, forgetGlowSpace,
buildGlowClipGeometry, buildLightBarrierScene, disposeGlowRuntime, forgetGlowSource, forgetGlowSpace,
glowSourceInOpaqueBody, pruneGlowSources, readGlowClip, renderGlowPools,
resolveGlowCandidates, resolveGlowFeather, lightGeometryFingerprint,
resolveLightBarrierRevision, transitionGlowSource, warnGlowGeometryFallback, writeGlowClip,
@@ -282,7 +282,13 @@ export function buildSpaceDevices(o: StaticDeviceBuildOpts): DevItem[] {
export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
const models = spaceModels(o.cfg);
const space = models.find((s) => s.id === o.spaceId);
if (!space) return null;
if (!space) {
if (o.glowRuntime) {
disposeGlowRuntime(o.glowRuntime.state, o.glowRuntime.host);
ledRelease(o.glowRuntime.state);
}
return null;
}
const disp = spaceDisplayOf(o.cfg.spaces.find((s: any) => s.id === o.spaceId));
const colors = fillColorsOf(o.cfg.settings);
const storedDecor = (o.cfg.settings as { decor_default_style?: Record<string, unknown> })
@@ -310,6 +316,10 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
// the same room show different Zigbee health on the two cards.
const spaceDevs = all.filter((d) => d.space === o.spaceId);
const devs = spaceDevs.filter((d) => !d.hidden);
const showLed = ledVisible(space, devs, false, o.cfg.markers);
const ledConnected = o.glowRuntime?.host.isConnected ?? (() => false);
const ledReady = () => { if (ledConnected()) o.moonHost?.requestUpdate(); };
if (!showLed && o.glowRuntime) ledRelease(o.glowRuntime.state);
// #780: a marker shown as an LED strip draws no icon here and takes no slot.
const leds = ledStripsByMarker(space);
const iconDevs = leds.size ? devs.filter((d) => !leds.has(d.id)) : devs;
@@ -1001,13 +1011,14 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
<g class="decorlayer" pointer-events="none">${decorImages}</g>
<g class="hp-stairs-layer" pointer-events="none">${stairShapes}</g>
${glowPools}
${ledVisible(space, devs, false, o.cfg.markers) ? ledRuntime(space.id, () => o.moonHost?.requestUpdate())?.renderStaticLed({
${showLed ? ledRuntime(space.id, ledReady)?.renderStaticLed({
space, devices: spaceDevs, hass: planHass, virtualLights: o.virtualLights,
defaultColor: colors.glow_light.c, paletteAlpha: colors.glow_light.a, cellCm,
gridPitch: GRID_PITCH, iconPct, glowFor: (room) => !!o.lightPools && roomGlowOf(disp.glow, room),
inRoom: (point, room) => pointInPolygon(point, roomPoly(room) || []), live: o.liveStates !== false,
scene: lightScene, perUnit: pxPerUnit, owner: o.glowRuntime?.state || space,
ready: () => o.moonHost?.requestUpdate(),
ready: ledReady,
isConnected: ledConnected,
bodies: { masonryGeometry: canonicalWallGeometry?.components.flatMap((c) => c.geom) || [], opaqueBodies: extras },
}) ?? nothing : nothing}
${wallUnion
+193
View File
@@ -0,0 +1,193 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createGlowRuntimeState, disposeGlowRuntime, forgetGlowSource, forgetGlowSpace,
GLOW_FADE_MS, pruneGlowSources, transitionGlowSource,
} from '../test-build/glow-scene.js';
function runtime(reducedMotion = false) {
let seq = 0, updates = 0, connected = true;
const frames = new Map(), timers = new Map();
const win = {
requestAnimationFrame(cb) { const id = ++seq; frames.set(id, cb); return id; },
cancelAnimationFrame(id) { frames.delete(id); },
setTimeout(cb, delay) { const id = ++seq; timers.set(id, { cb, delay }); return id; },
clearTimeout(id) { timers.delete(id); },
};
const host = { window: () => win, reducedMotion: () => reducedMotion,
isConnected: () => connected, requestUpdate: () => { updates++; } };
const state = createGlowRuntimeState();
return { state, host, frames, timers,
get updates() { return updates; },
disconnect() { connected = false; },
enter(key) { return transitionGlowSource(state, host, key, true); },
off(key) { return transitionGlowSource(state, host, key, false); },
frame() { for (const [id, cb] of [...frames]) { frames.delete(id); cb(0); } },
dispose() { disposeGlowRuntime(state, host); },
};
}
for (const reduced of [false, true]) {
test(`50 Glow/LED entries share one frame and one update, reduced motion=${reduced}`, () => {
const r = runtime(reduced);
for (let i = 0; i < 50; i++) {
const result = r.enter(`floor|${i % 2 ? 'lamp' : 'led'}-${i}`);
assert.equal(result.entering, true);
}
assert.equal(r.frames.size, 1, 'old per-source scheduler produces 50 callbacks here');
assert.equal(r.updates, 0, 'entering state is painted before its next frame');
assert.equal(r.state.enteringSources.size, 50);
r.frame();
assert.equal(r.updates, 1);
assert.equal(r.state.enteringSources.size, 0);
assert.equal(r.frames.size, 0);
for (const key of r.state.renderedSources.keys()) assert.equal(r.enter(key).entering, false);
assert.equal(r.frames.size, 0, 'steady renders do not schedule entry again');
r.dispose();
assert.equal(r.timers.size, 0);
});
}
test('owners do not share their scheduler or their entering sources', () => {
const a = runtime(), b = runtime();
a.enter('floor|same'); b.enter('floor|same');
a.frame();
assert.equal(a.updates, 1); assert.equal(b.updates, 0);
assert.equal(b.state.enteringSources.size, 1);
b.frame();
assert.equal(b.updates, 1);
a.dispose(); b.dispose();
});
test('off before the entry frame preserves its peers and fade-out duration', () => {
const r = runtime();
const first = r.enter('s|a'); r.enter('s|b');
const off = r.off('s|a');
assert.deepEqual(off, { domId: first.domId, entering: false, leaving: true });
assert.equal(r.frames.size, 1, 'cancelling one member must not cancel another');
const timer = r.state.fadeTimers.get('s|a');
assert.equal(r.timers.get(timer).delay, GLOW_FADE_MS + 34);
r.frame();
assert.equal(r.updates, 1);
assert.equal(r.state.renderedSources.has('s|a'), true, 'off node survives through fade');
r.timers.get(timer).cb();
assert.equal(r.state.renderedSources.has('s|a'), false);
assert.equal(r.state.renderedSources.has('s|b'), true);
r.dispose();
});
test('rapid off/on keeps DOM identity and rejects its stale fade callback', () => {
const r = runtime();
const first = r.enter('s|a');
r.off('s|a');
const stale = r.timers.get(r.state.fadeTimers.get('s|a')).cb;
assert.equal(r.frames.size, 0, 'last departing entry cancels the empty batch');
assert.deepEqual(r.enter('s|a'), { domId: first.domId, entering: false, leaving: false });
stale();
assert.equal(r.state.renderedSources.get('s|a'), first.domId);
assert.equal(r.updates, 0);
r.dispose();
});
test('prune/space switch remove only their sources; cancelled old frame cannot consume the new batch', () => {
const r = runtime();
r.enter('old|a'); r.enter('old|b');
const stale = [...r.frames.values()][0];
pruneGlowSources(r.state, r.host, 'old', new Set(['old|b']));
assert.equal(r.frames.size, 1);
forgetGlowSpace(r.state, r.host, 'old');
assert.equal(r.frames.size, 0);
assert.equal(r.state.renderedSources.size, 0);
r.enter('new|c');
stale();
assert.equal(r.state.enteringSources.has('new|c'), true);
assert.equal(r.updates, 0);
r.frame();
assert.equal(r.updates, 1);
r.dispose();
});
test('disconnect/dispose prevent queued entry and fade callbacks from updating their owner', () => {
const r = runtime();
r.enter('s|a');
const staleFrame = [...r.frames.values()][0];
r.off('s|a');
const staleFade = r.timers.get(r.state.fadeTimers.get('s|a')).cb;
r.enter('s|b'); r.disconnect();
r.frame();
assert.equal(r.updates, 0);
r.dispose();
staleFrame(); staleFade();
assert.equal(r.updates, 0);
assert.equal(r.frames.size, 0); assert.equal(r.timers.size, 0);
assert.equal(r.state.enteringSources.size, 0);
assert.equal(r.state.renderedSources.size, 0);
assert.equal(r.state.enterBatches.size, 0);
assert.equal(r.state.collectingEntryBatch, null);
});
test('forget the last entry cancels its frame without affecting an already visible source', () => {
const r = runtime();
r.enter('s|visible'); r.frame();
r.enter('s|new');
forgetGlowSource(r.state, r.host, 's|new');
assert.equal(r.frames.size, 0);
assert.equal(r.state.renderedSources.has('s|visible'), true);
assert.equal(r.updates, 1);
r.dispose();
});
test('a source discovered by an earlier rAF is not consumed by an older pending batch', async () => {
const r = runtime();
r.host.window().requestAnimationFrame(() => r.enter('s|later'));
r.enter('s|first');
await Promise.resolve(); // finish the synchronous render before the browser frame
r.frame(); // earlier external callback, then the old entry batch
assert.equal(r.state.enteringSources.has('s|first'), false);
assert.equal(r.state.enteringSources.has('s|later'), true,
'the newly rendered source needs its own initial entering frame');
assert.equal(r.frames.size, 1);
assert.equal(r.updates, 1);
await Promise.resolve();
r.frame();
assert.equal(r.state.enteringSources.size, 0);
assert.equal(r.updates, 2);
r.dispose();
});
test('forget/re-add of the same key cannot let an earlier batch consume its new entry', async () => {
const r = runtime();
r.enter('s|a'); r.enter('s|peer');
await Promise.resolve();
forgetGlowSource(r.state, r.host, 's|a');
const oldFrame = [...r.frames.values()][0];
r.enter('s|a');
oldFrame();
assert.equal(r.state.enteringSources.has('s|a'), true);
assert.equal(r.state.enteringSources.has('s|peer'), false);
r.frame();
assert.equal(r.state.enteringSources.size, 0);
assert.equal(r.updates, 2);
r.dispose();
});
test('a reused runtime ignores old frames and microtasks after dispose', async () => {
const r = runtime();
r.enter('s|same');
const oldFrame = [...r.frames.values()][0];
r.dispose();
// Runs between the old seal-microtask and the new one. A stale seal must
// not clear the new collecting batch and split this peer into a second rAF.
queueMicrotask(() => r.enter('s|peer'));
r.enter('s|same');
oldFrame();
await Promise.resolve();
assert.equal(r.state.enteringSources.has('s|same'), true);
assert.equal(r.state.enteringSources.has('s|peer'), true);
assert.equal(r.frames.size, 1);
assert.equal(r.updates, 0);
r.frame();
assert.equal(r.updates, 1);
assert.equal(r.state.enteringSources.size, 0);
r.dispose();
});
+64
View File
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { LightBarrierPass } from '../test-build/glow-scene.js';
test('ordinary Glow and LED share one revision within a synchronous render only', () => {
const pass = new LightBarrierPass(), space = { id: 'floor' };
let builds = 0;
const build = () => ({ fingerprint: String(++builds) });
pass.run(() => {
const first = pass.read(space, build);
assert.equal(pass.read(space, build), first);
assert.equal(pass.read(space, build), first);
assert.equal(builds, 1);
});
assert.equal(pass.read(space, build).fingerprint, '2');
assert.equal(pass.read(space, build).fingerprint, '3');
pass.run(() => assert.equal(pass.read(space, build).fingerprint, '4'));
});
test('in-place plan and opening changes are fingerprinted again next render', () => {
const pass = new LightBarrierPass(), space = { id: 'floor', wall: 10, opening: 0 };
const build = () => ({ fingerprint: `${space.wall}/${space.opening}` });
const render = () => pass.run(() => pass.read(space, build));
assert.equal(render().fingerprint, '10/0');
space.wall = 12;
assert.equal(render().fingerprint, '12/0');
space.opening = 1;
assert.equal(render().fingerprint, '12/1');
});
test('owners and different space objects never share a memo, even with the same id', () => {
const a = new LightBarrierPass(), b = new LightBarrierPass();
const one = { id: 'floor' }, two = { id: 'floor' };
let n = 0;
const build = () => ({ fingerprint: String(++n) });
a.run(() => b.run(() => {
assert.equal(a.read(one, build).fingerprint, '1');
assert.equal(a.read(two, build).fingerprint, '2');
assert.equal(b.read(one, build).fingerprint, '3');
assert.equal(a.read(one, build).fingerprint, '1');
}));
});
test('throwing renders and failed builds cannot retain or poison the next read', () => {
const pass = new LightBarrierPass(), space = { id: 'floor' };
const failure = new Error('render failed');
assert.throws(() => pass.run(() => {
assert.throws(() => pass.read(space, () => { throw failure; }), failure);
pass.read(space, () => ({ fingerprint: 'old' }));
throw failure;
}), failure);
assert.equal(pass.read(space, () => ({ fingerprint: 'new' })).fingerprint, 'new');
});
test('nested synchronous passes restore the outer memo without leaking the inner one', () => {
const pass = new LightBarrierPass(), space = { id: 'floor' };
const outer = { fingerprint: 'outer' }, inner = { fingerprint: 'inner' };
pass.run(() => {
assert.equal(pass.read(space, () => outer), outer);
pass.run(() => assert.equal(pass.read(space, () => inner), inner));
assert.equal(pass.read(space, () => { throw Error('outer memo missing'); }), outer);
});
assert.equal(pass.read(space, () => inner), inner);
});