fix(led): badge at the anchor, room_id wins, chain stays in its space, hidden loads nothing (#780)

r1 of the code review:
- M1: a strip keeps its marker's value badge, passive, at the half-length
  anchor on the card and on the static card (no icon core, pulse or slot).
- M2: one room resolver for the strip's Glow — an explicit valid room_id of
  the marker wins over the anchor room; a stale one falls back (stripRoom).
- M3: the chain remembers the space it is drawn in; a space switch finishes
  it there, never in the space shown next, and opens no picker over it.
- M4: visibility is decided before import(): a hidden marker or an
  HA-disabled device loads no LED chunk (ledVisible, also checks the stored
  marker so a just-hidden one does not slip through a stale device list).
- M6: the static card is a full light_pools × live_states browser matrix.

Unit tests for M2/M3, smokes for M1/M3/M4/M6, five registered mutants.

Issue: #780
User-Visible: yes
This commit is contained in:
Codex
2026-10-02 20:54:50 +03:00
parent ef3e7a5440
commit b40e8ff58d
20 changed files with 362 additions and 62 deletions
+32
View File
@@ -6,6 +6,7 @@
* the tool finishes, never loses the chain. A new segment stops at the first
* face of a thick wall, a fast vertex drag cannot jump it. The selection lives
* in the session only, its tray closes on Esc and on a clean background click.
* A space switch finishes an unfinished chain in its own space (r1 M3).
* Plan/Background have no LED tool, handles or targets.
*/
import { launch, check, finish } from './serve.mjs';
@@ -135,6 +136,37 @@ await clickAt(700, 550);
check('a clean background click drops the selection', await evaluate(() => window.__card._ledEditor.sel), null);
check('dropping the selection writes nothing', await evaluate(() => window.__ledSaves.length), savesBefore);
// r1 M3 (ТЗ §4): a space switch with an unfinished chain finishes it in the
// space it was drawn in — never in the space shown now — and opens no picker there.
await evaluate(() => window.__hpTest.setServerConfig((cfg) => {
if (!cfg.spaces.some((space) => space.id === 'led2')) {
cfg.spaces.push({ id: 'led2', title: 'LED 2', cell_cm: 5, view_box: [0, 0, 1, 0.7], rooms: [], wall_segments: [], partitions: [] });
}
}));
await page.waitForTimeout(300);
const beforeSwitch = (await strips()).length;
await page.click('[data-tool="led-strip"]');
await page.waitForTimeout(200);
await clickAt(200, 500);
await clickAt(350, 500);
check('an unfinished chain before the switch', await chain(), 2);
await evaluate(() => window.__hpTest.switchSpace('led2'));
await page.waitForTimeout(600);
const switched = await evaluate(() => {
const c = window.__card;
const of = (id) => c._serverCfg.spaces.find((space) => space.id === id)?.led_strips || [];
return { led: of('led').length, led2: of('led2').length, last: of('led').at(-1)?.points,
sel: c._ledEditor.sel, tool: c._ledEditor.tool,
picker: !!c.shadowRoot.querySelector('hp-dialog[data-kind="led-picker"]') };
});
check('the chain is stored in the space it was drawn in', switched.led, beforeSwitch + 1);
check('nothing is written into the space shown now', switched.led2, 0);
check('the stored chain keeps its points', JSON.stringify(switched.last), JSON.stringify([[0.2, 0.5], [0.35, 0.5]]));
check('no tool, selection or picker carried into the other space',
JSON.stringify([switched.tool, switched.sel, switched.picker]), JSON.stringify([false, null, false]));
await evaluate(() => window.__hpTest.switchSpace('led'));
await page.waitForTimeout(400);
// «Optimize plans» reports a strip that passes through the partition and changes nothing in it.
const optimizeNote = await evaluate(async () => {
const c = window.__card;
+112 -20
View File
@@ -5,8 +5,10 @@
* strip replaces the icon, paints the two-stroke stripe and — with Glow — the
* linear field; off/unavailable have no field, unavailable is dashed; without
* Glow the core takes the source colour. The whole length is one target: a
* click toggles once, a touch pan calls nothing. The static card is passive
* and computes no field with `light_pools: false`.
* click toggles once, a touch pan calls nothing. A hidden marker or an
* HA-disabled device loads no chunk (r1 M4); the value badge stays at the
* anchor, passive (r1 M1). The static card is passive in all four
* light_pools × live_states combinations (r1 M6).
*/
import { launch, check, finish } from './serve.mjs';
import { installHpTestOnPage } from './helpers/hp-test.mjs';
@@ -16,17 +18,23 @@ const ledRequests = [];
page.on('request', (request) => { if (/led-strip-(runtime|field|editor)-/.test(request.url())) ledRequests.push(request.url().replace(/.*\//, '')); });
const evaluate = (fn, arg) => page.evaluate(fn, arg);
const settle = () => page.waitForTimeout(500);
const setStrips = (strips, settings) => evaluate(async ([strips, settings]) => {
const setStrips = (strips, settings, extra = {}) => evaluate(async ([strips, settings, extra]) => {
await window.__hpTest.setServerConfig((cfg) => {
cfg.spaces[0].led_strips = strips;
cfg.spaces[0].settings = { ...(cfg.spaces[0].settings || {}), ...settings };
cfg.markers = [
...(cfg.markers || []).filter((m) => !['d_light1', 'd_lamp'].includes(m.id)),
{ id: 'd_light1', binding: 'device:d_light1', space: 'f1' },
{ id: 'd_lamp', binding: 'device:d_lamp', space: 'f1' },
{ id: 'd_light1', binding: 'device:d_light1', space: 'f1', ...(extra.d_light1 || {}) },
{ id: 'd_lamp', binding: 'device:d_lamp', space: 'f1', ...(extra.d_lamp || {}) },
];
});
}, [strips, settings]);
}, [strips, settings, extra]);
const registryDisabled = (by) => evaluate(async (by) => {
window.__setRegistryDisabled('device', 'd_light1', by);
window.__card.hass = window.__mkHass();
await new Promise((resolve) => setTimeout(resolve, 250));
await window.__card.updateComplete;
}, by);
const setState = (entity, state, attributes = {}) => evaluate(async ([entity, state, attributes]) => {
const c = window.__card; const st = c.hass.states[entity];
c.hass = { ...c.hass, states: { ...c.hass.states, [entity]: { ...st, state, attributes: { ...st.attributes, ...attributes } } } };
@@ -46,6 +54,21 @@ await setStrips([{ id: 'hidden', points: [[0.12, 0.40], [0.40, 0.40]], marker: '
await settle();
check('hidden shape and unbound strip: still no LED chunk', ledRequests.length, 0);
check('a hidden shape leaves the ordinary icon', await evaluate(() => !!window.__card.shadowRoot.querySelector('.dev[data-id="d_lamp"]')));
// r1 M4 (ТЗ §13.1): visibility is decided before import() — an active strip of
// a hidden marker or of an HA-disabled device loads no LED chunk at all.
const ceilingStrip = [{ id: 'ceiling', points: [[0.12, 0.30], [0.40, 0.30], [0.40, 0.45]], marker: 'd_light1' }];
await setStrips(ceilingStrip, { glow_enabled: true }, { d_light1: { hidden: true } });
await settle();
check('a hidden marker: its active strip loads no LED chunk', ledRequests.length, 0);
check('a hidden marker: no stripe, no badge, no icon', await evaluate(() => {
const root = window.__card.shadowRoot;
return !root.querySelector('[data-led-strip], [data-led-badge], .dev[data-id="d_light1"]');
}));
await registryDisabled('user');
await setStrips(ceilingStrip, { glow_enabled: true });
await settle();
check('an HA-disabled device: its active strip loads no LED chunk', ledRequests.length, 0);
await registryDisabled(null);
await setStrips([{ id: 'ceiling', points: [[0.12, 0.30], [0.40, 0.30], [0.40, 0.45]], marker: 'd_light1' },
{ id: 'loose', points: [[0.12, 0.45], [0.30, 0.45]], marker: null }], { glow_enabled: true });
@@ -60,6 +83,40 @@ check('no auto-slot reserved for the strip’s marker', await evaluate(() => !('
check('on with Glow: white core and a field', JSON.stringify([await stripe('d_light1'), await field() > 0]),
JSON.stringify([{ state: 'on', core: '#FFFFFF', dash: null }, true]));
// r1 M1 (ТЗ §5): the strip keeps the device's value badge, passive, at the
// half-length anchor — the icon core, pulse and slot stay suppressed.
const badgeAt = (root) => evaluate((root) => {
const host = root === 'card' ? window.__card : document.querySelector('houseplan-space-card');
const sr = host.shadowRoot;
const badge = sr.querySelector('[data-led-badge="d_light1"]');
if (!badge) return null;
const core = badge.querySelector('.device-core').getBoundingClientRect();
const value = badge.querySelector('.value-badge');
const style = getComputedStyle(badge);
return { text: value?.textContent?.trim() || '', x: core.x + core.width / 2, y: core.y + core.height / 2,
passive: style.pointerEvents === 'none' && badge.getAttribute('aria-hidden') === 'true',
coreHidden: getComputedStyle(badge.querySelector('.device-core')).visibility === 'hidden',
icon: !!badge.querySelector('ha-icon, .device-pulse, .activity-dot') };
}, root);
const midpoint = () => evaluate(() => {
const el = window.__card.shadowRoot.querySelector('.led-hit');
const p = el.getPointAtLength(el.getTotalLength() / 2), m = el.getScreenCTM();
return [p.x * m.a + m.e, p.y * m.d + m.f];
});
check('no configured badge: no badge element', await badgeAt('card'), null);
await setStrips(ceilingStrip, { glow_enabled: true }, { d_light1: { display: 'badge', value_badge: {
enabled: true, source: { kind: 'entity_state', entity_id: 'sensor.living_temp' }, position: 'right' } } });
await settle();
{
const badge = await badgeAt('card');
const [mx, my] = await midpoint();
check('the value badge stays with the strip', !!badge?.text);
check('the badge sits at the half-length anchor', !!badge && Math.hypot(badge.x - mx, badge.y - my) < 3,
true);
check('the badge is passive, without the icon core or pulse',
JSON.stringify(badge && [badge.passive, badge.coreHidden, badge.icon]), JSON.stringify([true, true, false]));
}
// One target over the whole length: a click toggles exactly once.
const calls = await evaluate(async () => {
const c = window.__card; const log = [];
@@ -114,24 +171,59 @@ const colored = await stripe('d_light1');
check('on without Glow: coloured core, no field', JSON.stringify([colored?.state, colored?.core !== '#FFFFFF', await field()]),
JSON.stringify(['on', true, 0]));
// The static card: passive; the field only with light_pools.
await setStrips(await evaluate(() => window.__card._serverCfg.spaces[0].led_strips), { glow_enabled: true });
const staticCards = await evaluate(async () => {
// The static card (ТЗ §8, AC14, r1 M6): the full light_pools × live_states
// matrix. Passive in all four: no hit path, no focus, a click calls nothing.
// live_states:false — a neutral stripe, no field; light_pools:false — no
// field, the core carries the source colour when the state is live.
await setStrips(await evaluate(() => window.__card._serverCfg.spaces[0].led_strips), { glow_enabled: true },
{ d_light1: { display: 'badge', value_badge: {
enabled: true, source: { kind: 'entity_state', entity_id: 'sensor.living_temp' }, position: 'right' } } });
const matrix = await evaluate(async () => {
await customElements.whenDefined('houseplan-space-card');
const host = document.createElement('div'); host.style.width = '700px'; document.body.appendChild(host);
const make = (pools) => { const el = document.createElement('houseplan-space-card'); el.setConfig({ type: 'custom:houseplan-space-card', space: 'f1', light_pools: pools }); el.hass = window.__card.hass; host.appendChild(el); return el; };
const plain = make(false), pools = make(true);
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
for (let i = 0; i < 40; i++) {
await wait(50);
if (plain.shadowRoot?.querySelector('[data-led-strip]') && pools.shadowRoot?.querySelector('[data-led-field]')) break;
const cards = {};
for (const pools of [false, true]) for (const live of [false, true]) {
const el = document.createElement('houseplan-space-card');
el.setConfig({ type: 'custom:houseplan-space-card', space: 'f1', light_pools: pools, live_states: live });
el.hass = window.__card.hass; host.appendChild(el);
cards[`pools=${pools},live=${live}`] = el;
}
const read = (el) => ({ stripe: !!el.shadowRoot?.querySelector('[data-led-strip]'),
field: !!el.shadowRoot?.querySelector('[data-led-field]'), hit: !!el.shadowRoot?.querySelector('.led-hit') });
return { plain: read(plain), pools: read(pools) };
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
for (let i = 0; i < 60; i++) {
await wait(50);
if (Object.values(cards).every((el) => el.shadowRoot?.querySelector('[data-led-strip]'))
&& cards['pools=true,live=true'].shadowRoot.querySelector('[data-led-field]')) break;
}
await wait(300);
const out = {};
for (const [key, el] of Object.entries(cards)) {
const sr = el.shadowRoot;
const g = sr.querySelector('[data-led-strip]');
const core = g?.querySelector('.led-core');
const box = core?.getBoundingClientRect();
if (box) {
const target = sr.elementFromPoint?.(box.x + box.width / 2, box.y + box.height / 2);
target?.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
}
out[key] = {
state: g?.dataset.state, white: core?.getAttribute('stroke') === '#FFFFFF',
field: !!sr.querySelector('[data-led-field]'),
passive: !sr.querySelector('.led-hit, [data-led-strip] [role], [data-led-strip] [tabindex]'),
badge: !!sr.querySelector('[data-led-badge="d_light1"] .value-badge'),
};
}
host.remove();
return out;
});
check('static card without light_pools: passive stripe, no field', JSON.stringify(staticCards.plain), JSON.stringify({ stripe: true, field: false, hit: false }));
check('static card with light_pools: the field', JSON.stringify(staticCards.pools), JSON.stringify({ stripe: true, field: true, hit: false }));
check('static live_states:false + light_pools:false — neutral, no field, passive', JSON.stringify(matrix['pools=false,live=false']),
JSON.stringify({ state: 'off', white: true, field: false, passive: true, badge: true }));
check('static live_states:true + light_pools:false — source colour in the core, no field, passive', JSON.stringify(matrix['pools=false,live=true']),
JSON.stringify({ state: 'on', white: false, field: false, passive: true, badge: true }));
check('static live_states:false + light_pools:true — neutral, no field, passive', JSON.stringify(matrix['pools=true,live=false']),
JSON.stringify({ state: 'off', white: true, field: false, passive: true, badge: true }));
check('static live_states:true + light_pools:true — white core and the field, passive', JSON.stringify(matrix['pools=true,live=true']),
JSON.stringify({ state: 'on', white: true, 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');
// The device dialog offers «Show as LED strip» before the tool is loaded:
// a static section (no dialog shift), the press loads the tool and starts
+4 -1
View File
@@ -704,7 +704,10 @@ and the device model stays the only owner of state and services. Four modules:
| `led-strip-editor.ts` (+ `i18n/led`) | lazy `led-editor` | the Devices tool, tray, picker, representation switch, LED history commands |
A View without a displayed active strip, and the Devices editor without the
tool or an editable strip, load none of them. Each chunk checks the entry
tool or an editable strip, load none of them. «Displayed» is decided before
`import()` (`ledVisible`): an active strip bound to a live device of the
space that is neither hidden nor HA-disabled — the stored marker is checked as
well as the built device list (r1 M4). Each chunk checks the entry
build fingerprint; a failed load is fail-dark for the strips only and retried
on the next explicit entry. Budgets: `LAZY_LED_GZIP_CEILING` and
`LAZY_LED_EDITOR_GZIP_CEILING` in `scripts/bundle-budget.mjs`.
+2 -1
View File
@@ -6,7 +6,8 @@
draws a strip on the plan point by point — it snaps to the grid and to wall
faces and stops at walls, windows and columns, passing doors and passages.
Bind it to a light (or any device) and the strip replaces the device's icon:
a two-stroke stripe that, when the light is on with Glow, casts a soft
a two-stroke stripe with the device's value badge at its middle that, when
the light is on with Glow, casts a soft
continuous band of its colour (50 cm by default) that does not pass through
walls or closed doors; without Glow the stripe itself takes the light's
colour. In the View the whole strip is one tap target, also in 2.5D and on
+2 -1
View File
@@ -12,7 +12,8 @@
«LED-лента» — лента рисуется на плане по точкам, притягивается к сетке и к
граням стен и упирается в стены, окна и колонны, проходя через двери и
проёмы. Привяжите её к светильнику (или любому устройству) — и лента заменит
значок устройства: полоса из двух штрихов, а при включённом свете с Glow —
значок устройства: полоса из двух штрихов с бейджем значения устройства
посередине, а при включённом свете с Glow —
мягкая непрерывная полоса света его цвета (по умолчанию 50 см), которая не
проходит сквозь стены и закрытые двери; без Glow цвет света принимает сама
полоса. В просмотре вся лента — одна цель нажатия, в том числе в 2.5D и в
+3 -1
View File
@@ -120,7 +120,9 @@ the value badge keep working; the individual `display`, `size`, `angle`,
icon». While shown as a strip the icon is not drawn, its saved position and
auto-grid slot are not used; the anchor — the point at half the polyline
length — replaces the position for the room, tooltip, label and badge (an
explicit valid `room_id` wins). A hidden shape (`active: false`) changes
explicit valid `room_id` wins — also for the strip's Glow room). The value
badge is drawn at the anchor as a passive satellite (`data-led-badge`) — no
icon core, pulse or auto-slot (r1 M1). A hidden shape (`active: false`) changes
nothing: the icon works normally. No pulses and no alarm red on the stripe.
`hidden` / HA-disabled suppress the stripe, its target and its light like an
icon; temporarily unavailable keeps it as a dashed grey stripe. In the device
+5 -1
View File
@@ -443,7 +443,11 @@ only the geometry differs:
Devices editor shows active strips (unbound ones as grey dashes);
Plan/Background show a passive translucent stripe. `houseplan-space-card`
draws the passive stripe always and the field only with `light_pools: true`
— with the option off no barrier, visibility or timer is created.
— with the option off no barrier, visibility or timer is created; with
`live_states: false` the stripe is neutral and has no field (all four
combinations are one browser matrix in `smoke_led_strip_glow`).
- **Room.** The Glow room of a strip is the explicit valid `room_id` of its
marker, otherwise the room of the half-length anchor (`stripRoom`).
- **Laziness.** The stripe/hit/2.5D code (`led-strip-runtime`) and the field
(`led-strip-field`) are separate lazy chunks; the initial graph holds only
the presence check and the loader (`led-strip-gate`). Caches are bounded per
+2 -2
View File
@@ -1051,14 +1051,14 @@ editor** — there is no need to switch to the Plan editor.
|---|---|
| **LED strip** (next to «Add») | Starts drawing. A clean click adds a point; the grid and wall faces attract it, a zero-thickness wall its axis; `Shift` gives 45° steps. A new segment stops at the first face of a thick wall, partition, column or window («The strip stopped at a wall»); doors, gates and passages let it through |
| `Ctrl/Cmd+Z` while drawing | Removes the chain's last point, not another history step |
| `Esc`, a double click on the last point, pressing **LED strip** again, leaving the editor | Finish the strip; a chain of fewer than two distinct points is not saved |
| `Esc`, a double click on the last point, pressing **LED strip** again, leaving the editor, switching to another space | Finish the strip in the space it was drawn in; a chain of fewer than two distinct points is not saved |
| A click on the first point (at least three vertices) | Closes the strip |
| After finishing | The device picker opens: lights first, devices bound to another strip are disabled with an explanation, **New device…** creates one and binds it in the same write, **Later** keeps an unbound strip (grey dashes, visible in the Devices editor only) |
| A click on a strip | Selects it and shows its vertices; they can be dragged, and walls stop them even on a fast drag. The tray offers **Device settings**, **Bind / Change device**, **Unbind**, **Show as icon**, **Delete strip**. `Esc` or a clean click on free space drops the selection |
| In the device dialog | **Show as LED strip** draws a shape for the same marker (or brings a saved one back at once); **Show as icon** returns the ordinary icon, the shape is kept hidden and comes back without redrawing. The usual unsaved-changes question comes first |
Binding creates no new Home Assistant device: the same marker, light
settings, tap actions and badge. The strip glows when the device does: with
settings, tap actions and badge — drawn at the middle of the strip's length. The strip glows when the device does: with
Glow on, a white core and a soft band of colour 50 cm wide by default (or the
device's own radius); without Glow the core takes the light's colour. Light
does not pass through walls or closed doors. In View the whole strip is one
+2 -2
View File
@@ -1147,14 +1147,14 @@ binding tombstone не вырезает её из живого устройст
|---|---|
| **LED-лента** (рядом с «Добавить») | Включает рисование. Чистый клик ставит точку; сетка и грани стен притягивают точку, у стены нулевой толщины — её ось; `Shift` даёт шаг 45°. Новый отрезок упирается в первую грань толстой стены, перегородки, колонны или окна («Лента упёрлась в стену»); двери, ворота и проходы пропускают |
| `Ctrl/Cmd+Z` во время рисования | Убирает последнюю точку цепочки, а не чужое действие истории |
| `Esc`, двойной клик по последней точке, повторное нажатие **LED-лента**, выход из редактора | Завершают ленту; цепочка меньше двух различных точек не сохраняется |
| `Esc`, двойной клик по последней точке, повторное нажатие **LED-лента**, выход из редактора, переход на другое пространство | Завершают ленту в том пространстве, где её рисовали; цепочка меньше двух различных точек не сохраняется |
| Клик по первой точке (не меньше трёх вершин) | Замыкает ленту |
| После завершения | Открывается выбор устройства: сначала светильники, занятые другой лентой недоступны с пояснением, **Новое устройство…** создаёт его и привязывает в той же записи, **Позже** оставляет непривязанную ленту (серый пунктир, видна только в редакторе устройств) |
| Клик по ленте | Выделяет её и показывает вершины; их можно перетаскивать, упор в стены действует и при быстром перетаскивании. Внизу — лоток: **Настройки устройства**, **Привязать / Сменить устройство**, **Отвязать**, **Показывать значком**, **Удалить ленту**. `Esc` или чистый клик по свободному месту снимают выделение |
| В диалоге устройства | **Показывать LED-лентой** — нарисовать форму для этого же маркера (или сразу вернуть сохранённую); **Показывать значком** — вернуть обычный значок, форма сохраняется скрыто и восстанавливается без перерисовки. Перед этим действует обычный вопрос о несохранённых изменениях |
Привязка не создаёт нового устройства в Home Assistant: тот же маркер, те же
настройки света, действия по нажатию и бейдж. Лента светится, когда светится
настройки света, действия по нажатию и бейдж — он стоит в середине длины ленты. Лента светится, когда светится
устройство: при включённом Glow — белое ядро и мягкая цветная полоса света
радиусом 50 см (или персональный радиус устройства), без Glow — ядро цвета
источника. Свет не проходит сквозь стены и закрытые двери. В просмотре лента
+3 -1
View File
@@ -256,7 +256,9 @@ layer you cannot see is a layer you cannot edit.
grid and to physical wall faces / zero-wall axes; a new segment stops at
the first face of masonry, a partition, a column or a window. `Ctrl/Cmd+Z`
removes the chain's own point first, `Esc` finishes. A finished strip opens
the device picker. A clicked strip is selected (session state only) and
the device picker. Switching the space finishes an unfinished chain in the
space it was drawn in (never in the one shown next) and opens no picker
there. A clicked strip is selected (session state only) and
shows vertex handles and the LED branch of the secondary tray. An ordinary
icon clicked while a strip is selected still opens its own dialog. The
device dialog switches the representation («Show as LED strip» / «Show as
@@ -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 | 30 | The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels produced by Chromium. |
| Paint, cascade and layer composition | 32 | 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 | 100 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. |
| **Total** | **223 / 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 | 101 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. |
| **Total** | **226 / 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
@@ -91,9 +91,11 @@ The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels p
- `iso-sun-card-drops-occluders`
- `iso-sun-flat-wedges-remain`
- `iso-theme-dark-wall-rule-returns`
- `led-badge-dropped`
- `led-core-coloured-under-glow`
- `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`
@@ -251,6 +253,7 @@ The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy
- `junction-limit-candidate-fail-open`
- `junction-limit-write-gate-removed`
- `led-auto-slot-reserved`
- `led-hidden-marker-loads-chunk`
- `locale-failure-toast-dropped`
- `marker-reject-keeps-optimistic-candidate`
- `marker-rollback-keeps-enqueue-time-revision`
+54 -2
View File
@@ -7387,7 +7387,7 @@ const MUTANT_DEFINITIONS = [
find: ' ${passageGlowTunnels}\n'
+ ' <g class="decorlayer" pointer-events="none">${decorImages}</g>\n'
+ ' <g class="hp-stairs-layer" pointer-events="none">${stairShapes}</g>\n'
+ ' ${glowPools}\n ${leds.size',
+ ' ${glowPools}\n ${ledVisible(',
replace: ' ${passageGlowTunnels}\n'
+ ' ${!space.bg && !disp.showNames ? svg`<g class="room-svg-labels" pointer-events="none">${space.rooms.map((room) => {\n'
+ ' const center = roomCenter(room);\n'
@@ -7397,7 +7397,7 @@ const MUTANT_DEFINITIONS = [
+ ' <g class="decorlayer" pointer-events="none">${decorImages}</g>\n'
+ ' <g class="hp-stairs-layer" pointer-events="none">${stairShapes}</g>\n'
+ ' ${glowPools}\n'
+ ' ${leds.size',
+ ' ${ledVisible(',
}],
},
{
@@ -14374,6 +14374,58 @@ const MUTANT_DEFINITIONS = [
replace: ' unbound_led_strips = unbind_strips(space, remap={\n old_id: old_id for old_id, new_id in marker_map.items()',
}],
},
{
id: 'led-badge-dropped',
guard: 'node demo/smoke_led_strip_glow.mjs',
because: "#780 r1 M1: a strip keeps the device value badge, passive, at the half-length anchor — measured against the rendered stripe in Chromium",
patches: [{
file: 'src/houseplan-card.ts',
find: " ${ledDevs.map((d) => this._ledBadge(d, view, isoOverlays?.devices.get(d.id)))}\n",
replace: "",
}],
},
{
id: 'led-room-id-ignored',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test test/led-strip-runtime.test.mjs',
because: '#780 r1 M2: an explicit valid room_id wins over the anchor room for the strip Glow',
patches: [{
file: 'src/led-strip-runtime.ts',
find: " return (roomId ? rooms.find((r) => r.id === roomId) : undefined)\n ?? (anchor",
replace: " return (anchor",
}],
},
{
id: 'led-chain-written-to-shown-space',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test test/led-strip-editor.test.mjs',
because: '#780 r1 M3: a space switch finishes an unfinished chain in the space it was drawn in, never the one shown now',
patches: [{
file: 'src/led-strip-editor.ts',
find: " chain.space, id, () => strip);",
replace: " this.host._spaceModel()?.id ?? chain.space, id, () => strip);",
}],
},
{
id: 'led-hidden-marker-loads-chunk',
guard: 'node demo/smoke_led_strip_glow.mjs',
because: '#780 r1 M4: a hidden marker or an HA-disabled device decides before import() — no LED chunk request in the browser network log',
patches: [{
file: 'src/led-strip-gate.ts',
find: " && (!d.hidden || showHidden) && !hidden(d.id));",
replace: ");",
}],
},
{
id: 'led-static-live-ignored',
guard: 'node demo/smoke_led_strip_glow.mjs',
because: '#780 r1 M6: the static card with live_states:false draws a neutral stripe without a field in both light_pools modes — the rendered SVG state of four real cards',
patches: [{
file: 'src/led-strip-runtime.ts',
find: " const views = input.live ? frame.views",
replace: " const views = true ? frame.views",
}],
},
{
id: 'led-old-writer-drops-strips',
guard: 'node scripts/backend-test-guard.mjs old_writer tests_backend/test_led_strips.py',
+16 -2
View File
@@ -6,7 +6,7 @@
* 2) LEGACY fallback — baked-in country-house data (src/data/*), coordinates in a 1489×1053 canvas.
* The icon layout is stored on the server (houseplan/layout/*), fallback — localStorage.
*/
import { ledAnchor, ledRuntime, ledStripsByMarker } from './led-strip-gate';
import { ledAnchor, ledRuntime, ledStripsByMarker, ledVisible } from './led-strip-gate';
import { ledButton, ledEditorFor, ledHistory, ledImportNote, ledSection, ledWallsNote } from './led-strip-card';
import { LitElement, html, svg, nothing, noChange, TemplateResult, PropertyValues, type PropertyDeclaration } from 'lit';
import { cache as litCache } from 'lit/directives/cache.js';
@@ -10509,7 +10509,8 @@ export class HouseplanCard extends LitElement {
/** #780: the lazy LED chunk when the space shows a strip (ТЗ §13.1). */
private _ledRt(space: SpaceModel) {
return ledStripsByMarker(space).size ? ledRuntime(space.id, () => this.requestUpdate()) : null;
return ledVisible(space, this._renderDevices, this._mode === 'devices' && this._showAll, this._serverCfg?.markers)
? ledRuntime(space.id, () => this.requestUpdate()) : null;
}
/** #780: the Devices-editor LED tool, its own lazy chunk (src/led-strip-card.ts). */
@@ -10725,6 +10726,8 @@ export class HouseplanCard extends LitElement {
const showGhosts = this._mode === 'devices' && this._showAll;
const leds = ledStripsByMarker(space);
const devs = this._renderDevices.filter((d) => d.space === space.id && (!d.hidden || showGhosts) && !leds.has(d.id));
// #780 r1 M1: a strip keeps its value badge at the anchor; no icon, pulse or slot.
const ledDevs = leds.size ? this._renderDevices.filter((d) => d.space === space.id && !d.hidden && leds.has(d.id)) : [];
const deviceSnapshot = this._renderDeviceSnapshot;
const disp = this._spaceDisplayForRender();
const roomFills = this._resolvedRoomFills(space, disp);
@@ -11208,6 +11211,7 @@ export class HouseplanCard extends LitElement {
${keyed(space.id, repeat(devs, (d) => d.id, (d) => this._renderDevice(
d, view, showLqi, isoOverlays?.devices.get(d.id),
)))}
${ledDevs.map((d) => this._ledBadge(d, view, isoOverlays?.devices.get(d.id)))}
${this._renderVacuums(this._renderVacuumDevices, view, space.id)}
${this._renderVacFit(view)}
${this._renderOpeningLocks(view, isoOverlays?.locks)}
@@ -11801,6 +11805,16 @@ export class HouseplanCard extends LitElement {
${pucks}`;
}
/** #780 r1 M1: the value badge of a strip, passive, at its anchor (`_pos`). */
private _ledBadge(d: DevItem, view: { x: number; y: number; w: number; h: number }, iso?: IsoOverlayPlacement) {
const presentation = this._devicePresentation(d, false);
const face = presentation.haDisabled ? nothing : renderDeviceShadowFace(presentation);
if (face === nothing) return nothing;
const pos = this._pos(d), point = iso?.visualScene ?? [pos.x, pos.y];
return html`<div class="dev led-badge ${deviceThemeClass(this._renderPlanHass)}" data-led-badge=${d.id} aria-hidden="true"
style="left:${((point[0] - view.x) / view.w) * 100}%;top:${((point[1] - view.y) / view.h) * 100}%">${face}</div>`;
}
private _renderDevice(
d: DevItem,
view: { x: number; y: number; w: number; h: number },
+16 -9
View File
@@ -161,7 +161,8 @@ function nearestOnSegment(p: Pt, a: Pt, b: Pt): Pt {
export class LedStripEditor {
/** The tool is armed: clean clicks add points. */
tool = false;
chain: { points: Pt[]; convert: string | null } | null = null;
/** The unfinished chain and the space it is drawn in (r1 M3: it is written there, never elsewhere). */
chain: { points: Pt[]; convert: string | null; space: string } | null = null;
hover: Pt | null = null;
stopped = false;
sel: string | null = null;
@@ -258,7 +259,7 @@ export class LedStripEditor {
this.tool = true;
this.sel = null;
this.picker = null;
this.chain = { points: [], convert };
this.chain = { points: [], convert, space: space.id };
this.hover = null;
this.stopped = false;
this.host.requestUpdate();
@@ -364,7 +365,7 @@ export class LedStripEditor {
private addPoint(e: PointerEvent): void {
if (this.busy) return;
const { bodies } = this.placement();
const chain = this.chain || (this.chain = { points: [], convert: null });
const chain = this.chain || (this.chain = { points: [], convert: null, space: this.host._spaceModel()?.id ?? '' });
const last = chain.points[chain.points.length - 1] || null;
let p = this.snapPoint(this.rawPoint(e), e.shiftKey, last);
if (!last) {
@@ -404,15 +405,17 @@ export class LedStripEditor {
/** Esc, a double click, a click on the first point, a tool or editor change. */
async finish(): Promise<void> {
const chain = this.chain;
const space = this.host._spaceModel();
this.chain = null;
this.hover = null;
if (this.tool && chain?.convert) this.tool = false;
if (!chain || !space) { this.host.requestUpdate(); return; }
// r1 M3: the chain belongs to the space it was drawn in — a space switch
// finishes it THERE (by id), never into the space shown now.
if (!chain?.space) { this.host.requestUpdate(); return; }
const stored = compactPoints(chain.points).map(scaleOut);
if (!validStripPoints(stored)) {
// Fewer than two distinct points: nothing is written, nothing converted.
if (this.tool) this.chain = { points: [], convert: null };
const here = this.host._spaceModel()?.id;
if (this.tool && here) this.chain = { points: [], convert: null, space: here };
this.host.requestUpdate();
return;
}
@@ -421,11 +424,15 @@ export class LedStripEditor {
? { id, points: stored, marker: chain.convert, active: true }
: { id, points: stored, marker: null };
const ok = await this.write(this.t(chain.convert ? 'led.history_view' : 'led.history_draw'),
space.id, id, () => strip);
chain.space, id, () => strip);
if (ok) {
this.tool = false;
this.sel = id;
if (!chain.convert) this.picker = id; // bind now, or "Later"
// Selection and the device picker are session state of the space shown;
// after a switch the strip waits unbound in its own space (ТЗ §4 п.5, п.8).
if (this.host._spaceModel()?.id === chain.space) {
this.sel = id;
if (!chain.convert) this.picker = id; // bind now, or "Later"
}
}
this.host.requestUpdate();
}
+23 -3
View File
@@ -4,9 +4,10 @@
* position, and one page-wide load of the lazy `led-strip-runtime` chunk.
* Geometry, light and hit-testing live in the chunk.
*
* A strip is represented when it is active and bound; the card additionally
* checks the marker is a live device of that space. `active: false` shapes,
* unbound strips and an empty array never ask for the chunk. A failed load is
* A strip is represented when it is active and bound; the chunk loads only
* when such a strip is drawn — a live, visible device of that space
* (`ledVisible`). `active: false` shapes, unbound strips, hidden or
* HA-disabled markers and an empty array never ask for the chunk. A failed load is
* fail-dark for the strips only and is retried on the next explicit entry
* (another space or the LED tool) — never in a loop; a chunk of another build
* is never installed.
@@ -48,6 +49,25 @@ export function ledStripsByMarker(space: SpaceModel | null | undefined): Map<str
return out;
}
/**
* Does the space draw a strip (r1 M4, ТЗ §13.1)? An active bound strip of a
* live device of this space that is not hidden or HA-disabled (`hidden`),
* unless hidden devices are shown as ghosts. Decided BEFORE the chunk loads.
*/
export function ledVisible(
space: SpaceModel | null | undefined,
devices: readonly { id: string; space: string; hidden?: boolean; marker?: { hidden?: boolean } | null }[],
showHidden: boolean,
markers?: readonly { id: string; hidden?: boolean }[],
): boolean {
const leds = ledStripsByMarker(space);
// The stored marker is checked too: the device list may still be the one
// built before this config (a just-hidden marker must not load the chunk).
const hidden = (id: string) => !showHidden && !!markers?.some((m) => m.id === id && m.hidden === true);
return leds.size > 0 && devices.some((d) => d.space === space?.id && leds.has(d.id)
&& (!d.hidden || showHidden) && !hidden(d.id));
}
/** The point at half the polyline length (ТЗ §5), in render units (`scale` = NORM_W). */
export function ledAnchor(points: readonly number[][], scale: number): { x: number; y: number } {
const step = (i: number) => Math.hypot(points[i][0] - points[i - 1][0], points[i][1] - points[i - 1][1]);
+14 -2
View File
@@ -347,6 +347,19 @@ export interface LedFrame {
polygons: readonly LightRoomPolygon[];
}
/**
* The room of a strip (ТЗ §5, r1 M2): an explicit valid `room_id` of its
* marker wins; otherwise the room containing the half-length anchor. A stale
* `room_id` (no such room in the space) falls back to the geometry.
*/
export function stripRoom(
rooms: readonly RoomCfg[], roomId: string | null | undefined, anchor: number[] | null,
inRoom: (point: number[], room: RoomCfg) => boolean,
): RoomCfg | undefined {
return (roomId ? rooms.find((r) => r.id === roomId) : undefined)
?? (anchor ? rooms.find((r) => inRoom(anchor, r)) : undefined);
}
export function ledFrame(input: LedFrameInput): LedFrame {
const byId = new Map(input.devices.map((device) => [device.id, device]));
const radius = (LED_DEFAULT_RADIUS_CM / input.cellCm) * input.gridPitch;
@@ -373,8 +386,7 @@ export function ledFrame(input: LedFrameInput): LedFrame {
const device = byId.get(strip.marker as string);
if (!device || device.space !== input.space.id) continue;
if (device.hidden && !input.showHidden) continue;
const anchor = stripAnchor(pts(strip.points));
const room = anchor ? input.space.rooms.find((r) => input.inRoom(anchor, r)) : undefined;
const room = stripRoom(input.space.rooms, device.marker?.room_id, stripAnchor(pts(strip.points)), input.inRoom);
views.push(ledStripView({
strip,
device,
+18 -10
View File
@@ -44,7 +44,7 @@ import {
type PresentationActivityRuntime, type ResolvedDevicePresentation,
} from './device-presentation';
import { presentationSnapshotKey } from './render-device-snapshot';
import { deviceFaceStyle, deviceThemeClass, renderDeviceFace } from './device-face';
import { deviceFaceStyle, deviceThemeClass, renderDeviceFace, renderDeviceShadowFace } from './device-face';
import { effectiveDeviceBaseSize } from './device-marker-geometry';
import { valueBadgeTitle } from './device-value-badge';
import { contentFingerprint } from './visual-continuity';
@@ -59,7 +59,7 @@ import {
type Layout, type ContentItem, type SpaceCardFit,
} from './space-geometry';
import { resolveZeroWalls } from './zero-walls';
import { ledRuntime, ledStripsByMarker } from './led-strip-gate';
import { ledAnchor, ledRuntime, ledStripsByMarker, ledVisible } from './led-strip-gate';
import { geometryOpenings } from './plan-geometry-preflight';
import { resolveDeviceAreaRelocations } from './device-area-relocation';
import { projectDecorImage } from './decor-assets';
@@ -602,12 +602,8 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
: null);
}
const planLightSources = resolvedLightSources(planHass, devs, null, o.virtualLights);
const markers = iconDevs.map((d) => {
const p = markerPos(d, o.layout, o.cfg, defPos, space, areaRelocationIds);
const left = ((p.x - vb[0]) / vb[2]) * 100;
const top = ((p.y - vb[1]) / vb[3]) * 100;
const showLqi = disp.showLqi ?? (o.showSignal !== false);
const presentation = o.presentations?.get(presentationSnapshotKey(d.id, showLqi))
const showLqi = disp.showLqi ?? (o.showSignal !== false);
const presentationOf = (d: DevItem) => o.presentations?.get(presentationSnapshotKey(d.id, showLqi))
|| resolveDevicePresentation(planHass, d, {
liveStates: o.liveStates !== false,
showTemperature: o.showTemperature !== false,
@@ -619,6 +615,18 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
registryHass,
reducedMotion: o.reducedMotion,
});
// #780 r1 M1: a strip keeps its value badge at the half-length anchor, passive.
const ledBadges = leds.size ? devs.filter((d) => leds.has(d.id)).map((d) => {
const face = renderDeviceShadowFace(presentationOf(d));
const a = ledAnchor(leds.get(d.id)!.points, NORM_W);
return face === nothing ? nothing : html`<div class="dev led-badge ${deviceThemeClass(planHass)}" data-led-badge=${d.id} aria-hidden="true"
style="left:${((a.x - vb[0]) / vb[2]) * 100}%;top:${((a.y - vb[1]) / vb[3]) * 100}%">${face}</div>`;
}) : nothing;
const markers = iconDevs.map((d) => {
const p = markerPos(d, o.layout, o.cfg, defPos, space, areaRelocationIds);
const left = ((p.x - vb[0]) / vb[2]) * 100;
const top = ((p.y - vb[1]) / vb[3]) * 100;
const presentation = presentationOf(d);
const st = [`left:${left}%`, `top:${top}%`, ...deviceFaceStyle(presentation)];
const a11yState = deviceA11yState(presentation);
const deviceAriaLabel = deviceAccessibleLabel([
@@ -993,7 +1001,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
<g class="decorlayer" pointer-events="none">${decorImages}</g>
<g class="hp-stairs-layer" pointer-events="none">${stairShapes}</g>
${glowPools}
${leds.size ? ledRuntime(space.id, () => o.moonHost?.requestUpdate())?.renderStaticLed({
${ledVisible(space, devs, false, o.cfg.markers) ? ledRuntime(space.id, () => o.moonHost?.requestUpdate())?.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),
@@ -1033,7 +1041,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
tighter the frame got. `iconCqw` keeps the resolved device base
proportional to the plan's base unit, as it was when the frame
was the stored view_box. */}
<div class="devlayer" style="--icon-size:${iconCqw(iconPct, space, vb[2]).toFixed(3)}cqw;--device-base-size:${iconCqw(deviceBasePct, space, vb[2]).toFixed(3)}cqw">${markers}${labels}</div>
<div class="devlayer" style="--icon-size:${iconCqw(iconPct, space, vb[2]).toFixed(3)}cqw;--device-base-size:${iconCqw(deviceBasePct, space, vb[2]).toFixed(3)}cqw">${markers}${ledBadges}${labels}</div>
</div>
`;
}
+2
View File
@@ -253,6 +253,8 @@ export const devicesStyles = css`
transform: translateX(-50%);
flex-direction: column-reverse;
}
.dev.led-badge { pointer-events: none; }
.dev.led-badge .device-core { visibility: hidden; }
.device-core {
position: relative;
z-index: 1;
+23
View File
@@ -270,3 +270,26 @@ test('ТЗ §4 п.9: the card’s LED history branch — stale clears, failure r
assert.deepEqual(calls.splice(0), ['history.redone']);
assert.equal(card._devicePositionBusy, false);
});
test('r1 M3: a space switch finishes the unfinished chain in its own space, never in the one shown now', async () => {
const { led, host, saves } = setup();
const shown = host._spaceModel();
led.layer(shown);
led.open();
click(led, 100, 100);
click(led, 300, 100);
click(led, 300, 300);
const other = { ...shown, id: 'other' };
host._spaceModel = () => other;
host._space = 'other';
led.layer(other); // the card renders the new space: the session resets
await settle();
await settle();
assert.equal(saves.length, 1, 'one write');
assert.equal(stripsOf(host, 's').length, 1, 'the chain is stored in the space it was drawn in');
assert.deepEqual(stripsOf(host, 'other'), [], 'nothing is written into the space shown now');
assert.equal(stripsOf(host, 's')[0].points.length, 3);
assert.equal(led.tool, false);
assert.equal(led.sel, null, 'no selection carried into the other space');
assert.equal(led.picker, null, 'no device picker over the other space');
});
+23 -1
View File
@@ -3,7 +3,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ledAnchor, ledStripsByMarker } from '../test-build/led-strip-gate.js';
import { faceContext, ledFrame, ledStripView } from '../test-build/led-strip-runtime.js';
import { faceContext, ledFrame, ledStripView, stripRoom } from '../test-build/led-strip-runtime.js';
import { LedFieldCache, buildFieldGeometry, falloffAt } from '../test-build/led-strip-field.js';
import { GLOW_FALLOFF } from '../test-build/glow-scene.js';
import { stripAnchor } from '../test-build/led-strip-geometry.js';
@@ -136,3 +136,25 @@ test('AC9: the frame gives every strip the 50 cm default, not the shared radius;
assert.equal(frame.views.length, 1, 'the unbound strip is not a View strip');
assert.ok(Math.abs(frame.views[0].radius - (50 / 5) * (1000 / 240)) < 1e-9, `radius ${frame.views[0].radius}`);
});
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;
const frameWith = (roomId) => ledFrame({
space: { id: 's', vb: [0, 0, 1000, 1000], rooms, led_strips: [
// Anchor (half length) at x = 250: geometrically inside A.
{ id: 'a', points: [[0.1, 0.1], [0.4, 0.1]], marker: 'm1' },
] },
devices: [{ id: 'm1', name: 'Lamp', primary: 'light.led', entities: ['light.led'], space: 's',
marker: { id: 'm1', binding: 'device:m1', ...(roomId === undefined ? {} : { room_id: roomId }) } }],
hass: { states: { 'light.led': { state: 'on', attributes: {} } } },
defaultColor: '#ffd27b', paletteAlpha: 0.7, cellCm: 5, gridPitch: 1000 / 240, iconPct: 3.4,
scene: null, polygons: [], glowFor: (room) => room.id === 'B', inRoom: inA, showHidden: false,
});
assert.equal(frameWith(undefined).views[0].glow, false, 'no room_id: the anchor room A decides (Glow off)');
assert.equal(frameWith('B').views[0].glow, true, 'explicit room_id B wins over the geometric A');
assert.equal(frameWith('Z').views[0].glow, false, 'a stale room_id falls back to the geometry');
assert.equal(stripRoom(rooms, 'B', [250, 100], inA)?.id, 'B');
assert.equal(stripRoom(rooms, null, [250, 100], inA)?.id, 'A');
assert.equal(stripRoom(rooms, null, null, inA), undefined);
});