From d19bac39663e09285bf034c67ec1d1036f111652 Mon Sep 17 00:00:00 2001 From: Matysh Date: Mon, 5 Oct 2026 11:09:15 +0300 Subject: [PATCH] test: prove static LED disconnect owns disposal Issue: #794 User-Visible: no --- demo/smoke_led_strip_glow.mjs | 5 +- docs/LIGHT.md | 5 + docs/testing-notes/mutation-browser-guards.md | 12 +- scripts/mutation-registry.mjs | 7 +- test/space-card-led-disconnect.test.mjs | 140 ++++++++++++++++++ tsconfig.test.json | 2 +- 6 files changed, 163 insertions(+), 8 deletions(-) create mode 100644 test/space-card-led-disconnect.test.mjs diff --git a/demo/smoke_led_strip_glow.mjs b/demo/smoke_led_strip_glow.mjs index 9ae5f11e..7e919cb8 100644 --- a/demo/smoke_led_strip_glow.mjs +++ b/demo/smoke_led_strip_glow.mjs @@ -543,7 +543,10 @@ try { 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 => + // #794: visible fade is a browser oracle, but events after reconnect can also + // finish an old entry. space-card-led-disconnect.test.mjs proves synchronous + // owner disposal independently, before a detached render can clean up. + check('#789: reconnect visibly fades 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 } }, diff --git a/docs/LIGHT.md b/docs/LIGHT.md index 1f525e0d..f0a8c715 100644 --- a/docs/LIGHT.md +++ b/docs/LIGHT.md @@ -456,6 +456,11 @@ only the geometry differs: 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. + `test/space-card-led-disconnect.test.mjs` executes the real static-card + disconnect callback with populated lazy slots and checks disposal synchronously, + before any detached render. Its connection-only negative case retains the old + lifecycle despite suppressing late updates; `smoke_led_strip_glow` separately + keeps the browser's visible fade/transition-event coverage (#794). - **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/testing-notes/mutation-browser-guards.md b/docs/testing-notes/mutation-browser-guards.md index c5e750c7..cb5210e4 100644 --- a/docs/testing-notes/mutation-browser-guards.md +++ b/docs/testing-notes/mutation-browser-guards.md @@ -22,6 +22,13 @@ requires a non-null marker absent from runtime devices, whereas the browser's exact surviving strip/owner and its live light; the browser smoke still covers unbound-strip visibility and cold loading. +`glow-static-led-release-skipped` moved to `test/space-card-led-disconnect.test.mjs` +in #794: the real static-card callback must release populated LED state +synchronously, before a detached render can hide missing teardown. A +connection-only negative case suppresses late updates but retains its old +lifecycle; the browser smoke retains visible fade coverage without claiming +that transition events alone prove disposal. + | Category | Count | Why a browser is still required | | --- | ---: | --- | | Performance threshold | 4 | The witness measures real browser wall-time or frame work; a pure assertion cannot prove the budget. | @@ -29,8 +36,8 @@ unbound-strip visibility and cold loading. | 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 | 49 | 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 | 104 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. | -| **Total** | **236 / 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** | **235 / 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 @@ -266,7 +273,6 @@ The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy - `floor-geometry-key-ignores-content` - `french-locale-wrong-dictionary` - `glow-barrier-render-pass-wiring-skipped` -- `glow-static-led-release-skipped` - `glow-static-ready-after-disconnect` - `household-enter-stops-acting` - `household-marker-drops-keyboard-reach` diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 845e81bb..c1d24375 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -14325,9 +14325,10 @@ const MUTANT_DEFINITIONS = [ }, { 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', + guard: 'node --test test/space-card-led-disconnect.test.mjs', + because: '#794: execute the real static-card disconnectedCallback with populated LED lazy slots; ' + + 'it must synchronously release owner state and cancel callbacks before any detached render, ' + + 'then create a fresh lifecycle on reconnect. A connection-only negative case retains the old lifecycle', patches: [{ file: 'src/space-card.ts', find: ' ledRelease(this._glowRuntimeState);\n this._continuityHistory =', diff --git a/test/space-card-led-disconnect.test.mjs b/test/space-card-led-disconnect.test.mjs new file mode 100644 index 00000000..8810188e --- /dev/null +++ b/test/space-card-led-disconnect.test.mjs @@ -0,0 +1,140 @@ +// #794: execute the real static-card teardown without a detached Lit render or +// a browser CSS transition being able to clean up / finish the old lifecycle. +import assert from 'node:assert/strict'; +import { after, test } from 'node:test'; +import { ledRuntime } from '../test-build/led-strip-gate.js'; +import { hasLedField, ledFieldCache, ledFieldStats } from '../test-build/led-strip-field.js'; + +const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); +globalThis.window = { customCards: [] }; +after(() => { + if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow); + else delete globalThis.window; +}); +// Lit's Node entry supplies its own HTMLElement/customElements shim. No custom +// class or copied method substitutes for the production disconnectedCallback. +const { HouseplanSpaceCard } = await import('../test-build/space-card.js'); + +async function loaded(read) { + await new Promise(resolve => { if (read(resolve)) resolve(); }); + const module = read(() => {}); + assert.ok(module, 'the production lazy slot must be installed, not just imported'); + return module; +} +const runtime = await loaded(ready => ledRuntime('disconnect-witness', ready)); +await loaded(ready => runtime.ledField('disconnect-witness', ready)); + +function clock() { + let sequence = 0; + const frames = new Map(), timers = new Map(); + const win = { + requestAnimationFrame(cb) { const id = ++sequence; frames.set(id, cb); return id; }, + cancelAnimationFrame(id) { frames.delete(id); }, + setTimeout(cb, delay) { const id = ++sequence; timers.set(id, { cb, delay }); return id; }, + clearTimeout(id) { timers.delete(id); }, + matchMedia: () => ({ matches: false }), + }; + return { win, frames, timers, + frame() { for (const [id, cb] of [...frames]) { frames.delete(id); cb(0); } }, + }; +} + +function fixture(scheduler) { + let connected = true, updates = 0; + const card = new HouseplanSpaceCard(); + // Only browser-owned properties are supplied. The actual constructor owns + // every private field; the LED owner is read, never replaced or pre-cleared. + Object.defineProperties(card, { + ownerDocument: { value: { defaultView: scheduler.win } }, + isConnected: { get: () => connected }, + }); + const owner = card._glowRuntimeState; + const floor = [[0, 0], [1000, 0], [1000, 1000], [0, 1000]]; + const input = { + space: { id: 's', vb: [0, 0, 1000, 1000], rooms: [{ id: 'room', poly: floor }], + led_strips: [{ id: 'strip', marker: 'lamp', points: [[0.2, 0.2], [0.4, 0.2]] }] }, + devices: [{ id: 'lamp', name: 'LED', primary: 'light.led', entities: ['light.led'], space: 's', + marker: { id: 'lamp', binding: 'device:lamp', room_id: 'room' } }], + defaultColor: '#ffd27b', paletteAlpha: 0.7, cellCm: 5, gridPitch: 1000 / 240, iconPct: 3.4, + glowFor: () => true, inRoom: () => true, live: true, perUnit: 1, + scene: { floor: [floor], occluders: [], fingerprint: 'empty-room', masonryGeometry: [], opaqueBodies: [] }, + bodies: { masonryGeometry: [], opaqueBodies: [] }, + owner, ready: () => { updates++; }, isConnected: () => card.isConnected, + }; + return { card, owner, + get updates() { return updates; }, + connect(value) { connected = value; }, + render(state = 'on') { + return runtime.renderStaticLed({ ...input, + hass: { states: { 'light.led': { state, attributes: {} } } }, + }); + }, + disconnect() { connected = false; card.disconnectedCallback(); }, + }; +} + +const emptyStats = { visibility: 0, sources: 0, visibilityPaths: 0, pathChars: 0, recomputes: 0 }; + +for (const phase of ['entering', 'visible', 'leaving']) { + test(`#794: real space-card disconnect releases ${phase} LED state before any render`, t => { + const scheduler = clock(); + globalThis.window = scheduler.win; + const subject = fixture(scheduler), neighbour = fixture(scheduler); + t.after(() => { subject.disconnect(); neighbour.disconnect(); }); + subject.render(); + assert.equal(hasLedField(subject.owner), true); + const oldCache = ledFieldCache(subject.owner); + assert.ok(ledFieldStats(subject.owner).sources > 0, 'populate real field geometry, not a synthetic cache entry'); + if (phase !== 'entering') scheduler.frame(); + if (phase === 'leaving') subject.render('off'); + const oldFrames = [...scheduler.frames.values()]; + const subjectFrameIds = [...scheduler.frames.keys()]; + const subjectTimerIds = [...scheduler.timers.keys()]; + neighbour.render(); + const neighbourCache = ledFieldCache(neighbour.owner); + const neighbourStats = ledFieldStats(neighbour.owner); + + subject.disconnect(); + // Synchronous boundary: neither Lit/renderStaticLed nor a rAF/timer has run + // since the actual callback. A connection guard alone is insufficient. + assert.equal(hasLedField(subject.owner), false, 'disconnect itself must discard the retained LED lifecycle'); + assert.deepEqual(ledFieldStats(subject.owner), emptyStats); + assert.ok(subjectFrameIds.every(id => !scheduler.frames.has(id)), 'cancel this owner\'s entering frames'); + assert.ok(subjectTimerIds.every(id => !scheduler.timers.has(id)), 'cancel this owner\'s fade/feather timers'); + assert.equal(hasLedField(neighbour.owner), true, 'another card still owns its field'); + assert.equal(ledFieldCache(neighbour.owner), neighbourCache); + assert.deepEqual(ledFieldStats(neighbour.owner), neighbourStats); + + subject.connect(true); + const pending = scheduler.frames.size; + subject.render(); + const freshCache = ledFieldCache(subject.owner); + assert.notEqual(freshCache, oldCache, 'reconnect must get a fresh owner cache'); + assert.equal(scheduler.frames.size, pending + 1, 'the same strip starts a fresh entering lifecycle'); + const updates = subject.updates; + for (const callback of oldFrames) callback(); + assert.equal(subject.updates, updates, 'old entry frames cannot update the reconnected owner'); + assert.equal(ledFieldCache(subject.owner), freshCache, 'old entry frames cannot discard the new cache'); + assert.equal(scheduler.frames.size, pending + 1, 'old entry frames cannot consume the new entry'); + scheduler.frame(); + assert.equal(subject.updates, updates + 1, 'only the fresh entry frame requests the update'); + }); +} + +test('#794 negative case: a disconnected connection guard leaves the old LED lifecycle reusable', t => { + const scheduler = clock(); + globalThis.window = scheduler.win; + const subject = fixture(scheduler); + t.after(() => subject.disconnect()); + subject.render(); + const oldCache = ledFieldCache(subject.owner); + subject.connect(false); // Deliberately no owner teardown: normal executable control, not a source mutation. + scheduler.frame(); + assert.equal(subject.updates, 0, 'the connection guard suppresses late updates even without disposal'); + assert.equal(hasLedField(subject.owner), true, 'no updates does not mean the lifecycle was released'); + assert.ok(ledFieldStats(subject.owner).sources > 0); + subject.connect(true); + subject.render(); + assert.equal(ledFieldCache(subject.owner), oldCache, 'the old cache survives without disconnect cleanup'); + assert.equal(scheduler.frames.size, 0, 'reusing the settled old entry is not a fresh lifecycle'); +}); diff --git a/tsconfig.test.json b/tsconfig.test.json index f4d62e6d..54229681 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -17,7 +17,7 @@ "src/radar-model.ts", "src/radar-editor.ts", "src/radar-geometry.ts", "src/radar-setup.ts", "src/radar-render.ts", "src/integration-provider.ts", "src/vacuum.ts", "src/vacuum-routes.ts", "src/vacuum-route-edit.ts", "src/sun.ts", "src/moon.ts", "src/moon-gate.ts", "src/moon-runtime.ts", "src/moon-art.generated.ts", - "src/light-visibility.ts", "src/led-strip-geometry.ts", "src/led-strip-gate.ts", "src/led-strip-runtime.ts", "src/led-strip-field.ts", "src/led-strip-editor.ts", "src/led-strip-card.ts", "src/glow-scene.ts", "src/space-render.ts", + "src/light-visibility.ts", "src/led-strip-geometry.ts", "src/led-strip-gate.ts", "src/led-strip-runtime.ts", "src/led-strip-field.ts", "src/led-strip-editor.ts", "src/led-strip-card.ts", "src/glow-scene.ts", "src/space-render.ts", "src/space-card.ts", "src/resize.ts", "src/resize-labels.ts", "src/resize-controller.ts", "src/wall-record-preservation.ts", "src/rules.ts", "src/devices.ts", "src/device-inbox.ts",