diff --git a/demo/smoke_led_strip_glow.mjs b/demo/smoke_led_strip_glow.mjs index 94640573..9ae5f11e 100644 --- a/demo/smoke_led_strip_glow.mjs +++ b/demo/smoke_led_strip_glow.mjs @@ -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); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a337d4ce..f45088e7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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): diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1123c0e0..8c4bcff2 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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)). diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 1bbb19e0..7fabcb75 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -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)). diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 519cb157..bf87dabc 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -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'`. diff --git a/docs/LIGHT.md b/docs/LIGHT.md index 6ab6d043..953247a1 100644 --- a/docs/LIGHT.md +++ b/docs/LIGHT.md @@ -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 diff --git a/docs/STATUS.md b/docs/STATUS.md index 469f0609..e0710595 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -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`. | diff --git a/docs/testing-notes/mutation-browser-guards.md b/docs/testing-notes/mutation-browser-guards.md index 21656911..e32cef7a 100644 --- a/docs/testing-notes/mutation-browser-guards.md +++ b/docs/testing-notes/mutation-browser-guards.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` diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index af3b6fa2..33fc4ec8 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -7387,7 +7387,7 @@ const MUTANT_DEFINITIONS = [ find: ' ${passageGlowTunnels}\n' + ' ${decorImages}\n' + ' ${stairShapes}\n' - + ' ${glowPools}\n ${ledVisible(', + + ' ${glowPools}\n ${showLed ?', replace: ' ${passageGlowTunnels}\n' + ' ${!space.bg && !disp.showNames ? svg`${space.rooms.map((room) => {\n' + ' const center = roomCenter(room);\n' @@ -7397,7 +7397,7 @@ const MUTANT_DEFINITIONS = [ + ' ${decorImages}\n' + ' ${stairShapes}\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', diff --git a/src/glow-scene.ts b/src/glow-scene.ts index 1476f928..791ccc68 100644 --- a/src/glow-scene.ts +++ b/src/glow-scene.ts @@ -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 | null = null; + + run(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; raf: number }; + export interface GlowRuntimeState { clipCache: Map; geometryWarnings: Set; @@ -108,7 +135,8 @@ export interface GlowRuntimeState { renderedSources: Map; lastAppearance: Map; enteringSources: Set; - enterRafs: Map; + enterBatches: Set; + collectingEntryBatch: GlowEntryBatch | null; fadeTimers: Map; 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(); diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 6a4bb89e..3af19a35 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -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(); 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 diff --git a/src/led-strip-runtime.ts b/src/led-strip-runtime.ts index 76895886..149533b1 100755 --- a/src/led-strip-runtime.ts +++ b/src/led-strip-runtime.ts @@ -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; } diff --git a/src/space-card.ts b/src/space-card.ts index 70323a0e..f4e26204 100644 --- a/src/space-card.ts +++ b/src/space-card.ts @@ -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; diff --git a/src/space-render.ts b/src/space-render.ts index 75649768..2ff70318 100644 --- a/src/space-render.ts +++ b/src/space-render.ts @@ -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 }) @@ -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 { ${decorImages} ${stairShapes} ${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 diff --git a/test/glow-entry-batch.test.mjs b/test/glow-entry-batch.test.mjs new file mode 100644 index 00000000..4f6b85a7 --- /dev/null +++ b/test/glow-entry-batch.test.mjs @@ -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(); +}); diff --git a/test/light-barrier-pass.test.mjs b/test/light-barrier-pass.test.mjs new file mode 100644 index 00000000..71eadd4b --- /dev/null +++ b/test/light-barrier-pass.test.mjs @@ -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); +});