mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 14:39:22 +00:00
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:
@@ -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 } },
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 =',
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user