diff --git a/docs/testing-notes/mutation-browser-guards.md b/docs/testing-notes/mutation-browser-guards.md index 47850ee6..79454956 100644 --- a/docs/testing-notes/mutation-browser-guards.md +++ b/docs/testing-notes/mutation-browser-guards.md @@ -7,18 +7,25 @@ must be added deliberately under one reason below, and its mutant `because` must the concrete browser-only invariant. Converted witnesses are not listed here: their registry guard names an explicit `node --test` -suite plus `test/mutation-browser-offload.test.mjs`, and every converted mutant is run once -to prove that the Node witness actually kills it. +suite. The original #659 conversions also use `test/mutation-browser-offload.test.mjs`; +new behavioral witnesses execute the consumer directly where possible. Catching the mutant +is verified by the nightly run under PROCESS §2.7, not by a development-time mutation run. + +`led-unbound-in-view` moved to `test/led-strip-runtime.test.mjs` in #791: its guard +requires a non-null marker absent from runtime devices, whereas the browser's +`marker: null` fixture exits before the mutated guard. The Node witness checks the +exact surviving strip/owner and its live light; the browser smoke still covers +unbound-strip visibility and cold loading. | 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. | | 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 | 38 | 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 | 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** | **237 / 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`. | +| **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`. | ## Measured effect @@ -102,7 +109,6 @@ The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels p - `led-icon-not-suppressed` - `led-source-stays-round-at-anchor` - `led-static-live-ignored` -- `led-unbound-in-view` - `stage3-w4-device-target-loses-44px-floor` - `stage3-w5-runtime-nudge-writes-storage` - `stage3-w6-no-borders-keeps-raised-plates` diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 387c5d76..fe4de263 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -14506,8 +14506,10 @@ const MUTANT_DEFINITIONS = [ }, { id: 'led-unbound-in-view', - guard: 'node demo/smoke_led_strip_glow.mjs', - because: "#780 ТЗ §5: an unbound strip has no View representation, light or target", + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test test/led-strip-runtime.test.mjs', + because: '#780 ТЗ §5 / #791: ledFrame skips a non-null marker absent from runtime devices ' + + 'without losing a valid neighbouring strip; the executable frame boundary needs no browser', patches: [{ file: 'src/led-strip-runtime.ts', find: " if (!device || device.space !== input.space.id) continue;", diff --git a/test/led-strip-runtime.test.mjs b/test/led-strip-runtime.test.mjs index a323ef6b..ceac333d 100644 --- a/test/led-strip-runtime.test.mjs +++ b/test/led-strip-runtime.test.mjs @@ -444,6 +444,35 @@ test('#784/AC9: the frame gives every strip the 30 cm default, not the shared ra assert.ok(Math.abs(frame.views[0].radius - (30 / 5) * (1000 / 240)) < 1e-9, `radius ${frame.views[0].radius}`); }); +test('#791: the frame skips missing runtime owners without dropping a valid neighbouring strip', () => { + const lamp = device({ entities: ['light.led'], marker: { id: 'm1', binding: 'device:m1' } }); + const points = [[0.1, 0.1], [0.4, 0.1]]; + const strips = [ + // A non-null reference reaches the runtime-owner guard; marker:null alone + // is rejected earlier and never exercised the escaped nightly mutant. + { id: 'missing-owner', points, marker: 'absent-from-runtime' }, + { id: 'unbound', points, marker: null }, + { id: 'other-space', points, marker: 'm2' }, + { id: 'inactive', points, marker: 'm1', active: false }, + { id: 'valid', points, marker: 'm1' }, + ]; + const before = structuredClone(strips); + const frame = ledFrame({ + space: { id: 's', vb: [0, 0, 1000, 1000], rooms: [{ id: 'room' }], led_strips: strips }, + devices: [lamp, device({ id: 'm2', space: 'other', primary: 'light.other', entities: ['light.other'], + marker: { id: 'm2', binding: 'device:m2' } })], + hass: { states: { 'light.led': { state: 'on', attributes: { rgb_color: [128, 213, 255] } } } }, + defaultColor: '#ffd27b', paletteAlpha: 0.7, cellCm: 5, gridPitch: 1000 / 240, iconPct: 3.4, + scene: null, polygons: [], glowFor: () => true, inRoom: () => true, showHidden: false, + }); + assert.deepEqual(frame.views.map(view => [view.strip.id, view.device.id]), [['valid', 'm1']], + 'only the same-space bound strip may reach stripe, light and hit rendering'); + assert.equal(frame.views[0].state, 'on'); + assert.equal(frame.views[0].glow, true); + assert.equal(frame.views[0].appearance?.c, '#80d5ff', 'the valid neighbour retains its live light'); + assert.deepEqual(strips, before, 'runtime filtering must not rewrite stored strips'); +}); + test('AC2/r1 M2: an explicit valid room_id wins over the anchor room; a stale one falls back', () => { const rooms = [{ id: 'A' }, { id: 'B' }]; const inA = (point, room) => room.id === 'A' && point[0] < 500;