mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-05 22:29:05 +00:00
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
«LED-лента» — лента рисуется на плане по точкам, притягивается к сетке и к
|
||||
граням стен и упирается в стены, окна и колонны, проходя через двери и
|
||||
проёмы. Привяжите её к светильнику (или любому устройству) — и лента заменит
|
||||
значок устройства: полоса из двух штрихов, а при включённом свете с Glow —
|
||||
значок устройства: полоса из двух штрихов с бейджем значения устройства
|
||||
посередине, а при включённом свете с Glow —
|
||||
мягкая непрерывная полоса света его цвета (по умолчанию 50 см), которая не
|
||||
проходит сквозь стены и закрытые двери; без Glow цвет света принимает сама
|
||||
полоса. В просмотре вся лента — одна цель нажатия, в том числе в 2.5D и в
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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]);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user