Merge reviewed infra fixes #794 and #795 for owner-authorized integration

Preserve both independently reviewed code commits without rewriting history.
The owner explicitly approved manual integration after the review model failed.
The combined candidate must pass exact-SHA Validate before the dev push.

Issue: #794
Issue: #795
User-Visible: no
This commit is contained in:
Matysh
2026-10-05 11:28:49 +03:00
6 changed files with 163 additions and 8 deletions
+4 -1
View File
@@ -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 } },
+5
View File
@@ -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
@@ -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`
+4 -3
View File
@@ -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 =',
+140
View File
@@ -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');
});
+1 -1
View File
@@ -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",