Compare commits

...
Author SHA1 Message Date
claude[bot] 52c0a6e8b8 docs: review document for #226
Validate / docs (push) Failing after 29s
Validate / provenance (push) Successful in 46s
Validate / changes (push) Successful in 57s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / process-gate (push) Failing after 1m3s
Validate / reuse (push) Successful in 45s
Validate / backend (push) Skipped
Validate / frontend (push) Successful in 9m12s
Validate / golden (push) Failing after 10m53s
Validate / performance_smoke (push) Failing after 14m7s
Validate / smoke (push) Failing after 33m16s
Issue: #226
User-Visible: no
2026-08-20 18:47:59 +00:00
Sergey Matyunin f151e70a27 fix: deduplicate entity markers from parent devices
Issue: #226
User-Visible: yes
2026-08-20 21:35:23 +03:00
claude[bot] d7c654d4c3 docs: review document for #226
Issue: #226
User-Visible: no
2026-08-20 18:25:56 +00:00
Sergey Matyunin 6162da8be9 docs: define hidden-only device residual
Issue: #226
User-Visible: no
2026-08-20 21:21:44 +03:00
claude[bot] ecf9473359 docs: review document for #226
Issue: #226
User-Visible: no
2026-08-20 18:20:37 +00:00
Sergey Matyunin 5feabed02b docs: specify entity parent deduplication
Issue: #226
User-Visible: no
2026-08-20 21:11:57 +03:00
19 changed files with 1895 additions and 423 deletions
File diff suppressed because one or more lines are too long
+120
View File
@@ -0,0 +1,120 @@
// #226: an entity marker owns its HA channel, while an automatic parent may
// render only the visible unclaimed residual. Proves full/touch-kiosk DOM,
// exact tap target, editor preview and the static space card from one fixture.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 390, height: 760 }, 1);
const out = await page.evaluate(async () => {
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const paint = async () => {
card.requestUpdate();
await card.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
};
const marker = {
id: 'switch-as-x-light',
binding: 'entity:light.ceiling',
space: 'f1',
area: 'living_room',
tap_action: 'toggle',
};
card._serverCfg = {
...card._serverCfg,
markers: [
...(card._serverCfg.markers || []).filter((item) =>
item.binding !== 'device:d_light1' && item.binding !== 'entity:light.ceiling'),
marker,
],
};
card._layout = {
...card._layout,
[marker.id]: { s: 'f1', x: 0.22, y: 0.22 },
};
card._config = { ...card._config, kiosk: true };
card._cfgEpoch++;
card._regSignature = '';
card._visibleDeviceSnapshot = null;
card._candidateDeviceSnapshot = null;
card._maybeRebuildDevices();
card._setMode('view');
await paint();
const exact = card._devices.find((item) => item.id === marker.id);
const configBeforeAction = JSON.stringify(card._serverCfg);
const calls = [];
const originalCallService = card.hass.callService.bind(card.hass);
card.hass = {
...card.hass,
callService: async (domain, service, data) => {
calls.push({ domain, service, data });
return originalCallService(domain, service, data);
},
};
root().querySelector(`.dev[data-id="${marker.id}"]`)?.click();
await wait(180);
await paint();
const planFacts = {
exactOnlyInProjection: !!exact
&& !card._devices.some((item) => item.id === 'd_light1')
&& card._devices.filter((item) => item.entities.includes('light.ceiling')).length === 1,
exactOnlyInTouchKioskDom: innerWidth === 390
&& !!root().querySelector(`.dev[data-id="${marker.id}"]`)
&& !root().querySelector('.dev[data-id="d_light1"]'),
exactToggleTarget: calls.length === 1
&& calls[0].domain === 'light'
&& calls[0].service === 'turn_off'
&& calls[0].data?.entity_id === 'light.ceiling',
actionDoesNotRewriteConfig: JSON.stringify(card._serverCfg) === configBeforeAction,
};
card._setMode('devices');
card._openMarkerDialog(card._devices.find((item) => item.id === marker.id));
await paint();
const preview = root().querySelector('hp-device-preview');
await preview?.updateComplete;
const previewFacts = {
editorKeepsExactBinding: card._markerDialog?.binding === 'entity:light.ceiling',
editorPreviewHasOneFace: preview?.renderRoot?.querySelectorAll('.dev').length === 1,
};
card._markerDialog = null;
card._setMode('view');
await paint();
await customElements.whenDefined('houseplan-space-card');
const staticCard = document.createElement('houseplan-space-card');
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
const baseCallWS = card.hass.callWS.bind(card.hass);
staticCard.hass = {
...card.hass,
callWS: async (message) => {
if (message.type === 'houseplan/config/get') {
return { config: card._serverCfg, rev: 226, can_write: false };
}
if (message.type === 'houseplan/layout/get') {
return { layout: card._layout, rev: 226 };
}
return baseCallWS(message);
},
};
document.body.appendChild(staticCard);
const started = Date.now();
while (!staticCard.renderRoot?.querySelector(`.dev[data-id="${marker.id}"]`)
&& Date.now() - started < 6000) {
await wait(60);
}
await staticCard.updateComplete;
const staticFacts = {
staticCardHasExactEntity: !!staticCard.renderRoot?.querySelector(
`.dev[data-id="${marker.id}"]`,
),
staticCardHasNoParent: !staticCard.renderRoot?.querySelector('.dev[data-id="d_light1"]'),
};
return { ...planFacts, ...previewFacts, ...staticFacts };
});
await finish(browser, checkAll(out));
File diff suppressed because one or more lines are too long
+136 -136
View File
File diff suppressed because one or more lines are too long
+6
View File
@@ -2,6 +2,12 @@
## Unreleased
- Placing an individual Home Assistant entity no longer leaves a duplicate
automatic marker of its complete parent device. The automatic marker now
contains only the remaining active visible entities and disappears when
none remain; two explicitly placed entity/device markers still coexist
([#226](https://github.com/Matysh/houseplan-card/issues/226)).
## v1.66.0 — 2026-08-20
- Text device markers once again use a capsule-shaped outer outline instead of
+7
View File
@@ -8,6 +8,13 @@
## Не выпущено
- Размещение отдельной сущности Home Assistant больше не оставляет рядом
дублирующий автоматический маркер всего родительского устройства. В
auto-marker теперь входят только оставшиеся активные видимые сущности, а при
пустом остатке он исчезает; две явно размещённые привязки entity/device
по-прежнему могут сосуществовать
([#226](https://github.com/Matysh/houseplan-card/issues/226)).
## v1.66.0 — 2026-08-20
- Текстовые маркеры устройств снова получают капсульную внешнюю обводку вместо
+10
View File
@@ -26,6 +26,12 @@ as the SEEDER of initial hidden flags.
automatic discovery cannot immediately recreate the deleted device. The
same binding remains available in Add; saving it again replaces the
tombstone and starts with a fresh position.
- A live `entity:X` marker owns X inside its automatic parent `device:D`. A
residual auto-device contains only active, HA-visible siblings not owned by
other entity markers and disappears when that set is empty. A user-hidden
live marker still owns X; an entity tombstone does not. An explicit
`device:D` remains complete and may intentionally coexist with explicit
entity markers.
- No marker — never evaluated by the seeder yet, or a plain physical device.
- `bindingStatus: ha_disabled` is runtime-only. It is derived from Home
Assistant's device/entity registries and is never written into a marker or
@@ -51,6 +57,10 @@ are never revisited. It fires on:
3. a new device appearing in a bound area — non-physical ones are hidden
silently (no red dot); physical ones keep the red-dot flow.
Entity-marker ownership uses the same residual projection here as in the
renderer. The seeder never turns an automatic parent with an empty or
HA-hidden-only residual into a persistent hidden `device:D` stub.
Until a config is seeded (`filter_seeded` absent), `buildDevices` applies the
LEGACY runtime filter, so a read-only client on an old config sees exactly
the old behaviour until an editing client materialises it.
+17
View File
@@ -676,6 +676,23 @@ separately promised workflows:
## Devices on the plan ★
- [ ] Auto devices appear only in rooms bound to their area [manual]
- [ ] **Entity/parent ownership (#226):** placing `entity:X` removes X from its
automatic parent. A visible unclaimed sibling keeps one residual parent;
an empty or HA-hidden-only residual removes it. State, primary/action,
`allEntities`, light/Glow and LQI cannot see X twice
[auto: unit `devices.test.mjs`; browser `smoke_entity_parent_dedup.mjs`].
- [ ] Two explicit markers `entity:X` + `device:D` coexist and the device stays
complete. A user-hidden live entity marker still owns X, while an entity
tombstone returns X to the parent; disabled entity ownership follows the
known full-registry relation [auto: unit `devices.test.mjs`].
- [ ] The #94 curtain boundary is deliberate: untouched hidden `cover.*` stays
cover-first, but after placing the only visible auxiliary switch the
hidden-only automatic remainder disappears. An explicit `device:D`
restores the complete curtain beside that entity marker
[auto: unit `devices.test.mjs`].
- [ ] Renderer and seeder cannot drift back to exact-binding-only ownership
[mutation: `entity-marker-kept-in-parent-device`,
`entity-marker-parent-seeded`].
- [ ] Filtering hides bridges/groups/scenes/excluded integrations; 👁 "show all" reveals [manual]
- [ ] Duplicate "name|area" numbered ("Lamp", "Lamp 2") [manual]
- [ ] Light groups fold their single lamps; `group_lights=false` unfolds [manual]
+10
View File
@@ -338,6 +338,16 @@ Newly discovered devices get a red dot until first opened in Device.
The same binding cannot be used by two markers.
When an exact HA entity belongs to a device, placing that entity gives its
channel to the entity marker. The automatic parent marker, if needed, contains
only the remaining active, HA-visible and unplaced entities; it disappears
when that residual is empty. HA-hidden siblings alone do not keep an automatic
parent on the plan. To show both the exact entity and the complete device,
place `entity:X` and `device:D` explicitly — two explicit markers are treated
as an intentional configuration. Deleting an entity marker returns the entity
to automatic parent discovery; its binding tombstone does not remove registry
data from the live HA device.
### Device editor
- drag a marker to save its server-side position;
+10
View File
@@ -573,6 +573,16 @@ House Plan читает реестры устройств, сущностей и
Одну и ту же привязку нельзя одновременно использовать в двух маркерах.
Если отдельная HA-сущность принадлежит устройству, после её размещения этот
канал принадлежит точному entity-marker. Автоматический родительский маркер,
если он ещё нужен, строится только из оставшихся активных, видимых в HA и не
размещённых отдельно сущностей; при пустом остатке он исчезает. Одни только
скрытые в HA siblings не удерживают auto-marker на плане. Чтобы одновременно
показать точную сущность и полное устройство, явно разместите и `entity:X`, и
`device:D` — два явных маркера считаются осознанной конфигурацией. После
удаления entity-marker сущность снова доступна автоматическому родителю:
binding tombstone не вырезает её из живого устройства HA.
### Редактор устройств
| Действие | Результат |
+11 -11
View File
@@ -1,7 +1,7 @@
{
"version": 1,
"fixture": "synthetic-only",
"sourceFingerprint": "d2baf22d884daf235af39c2010d42f5fdefc4004ae30a45a6a4ce33115c52c8f",
"sourceFingerprint": "264380083a9a18bfc2305a6577368068d6839fabe82fe1a1f440a62867b4e589",
"captureScriptSha256": "34f2219790d46efd8250e7a1bd829cb8fc0b0547e1260635fefa52407551b41b",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -13,7 +13,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "d2baf22d884daf235af39c2010d42f5fdefc4004ae30a45a6a4ce33115c52c8f",
"sourceSha256": "264380083a9a18bfc2305a6577368068d6839fabe82fe1a1f440a62867b4e589",
"imageSha256": "2885f96e348b15ab7c696e56e99bddcd9bb2ee94218a883e5a2155f45f5042aa"
},
"view-touch": {
@@ -24,7 +24,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "d2baf22d884daf235af39c2010d42f5fdefc4004ae30a45a6a4ce33115c52c8f",
"sourceSha256": "264380083a9a18bfc2305a6577368068d6839fabe82fe1a1f440a62867b4e589",
"imageSha256": "f62d8af3617c00a5e99511bd765980d2e27badf25047a1be34046b64195abe6c"
},
"space-create": {
@@ -35,7 +35,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "d2baf22d884daf235af39c2010d42f5fdefc4004ae30a45a6a4ce33115c52c8f",
"sourceSha256": "264380083a9a18bfc2305a6577368068d6839fabe82fe1a1f440a62867b4e589",
"imageSha256": "c53db2e5c642a5549c13f3c93a5b359fed69bdb2621bf71a243a877ffcb95e6b"
},
"room-contour-close": {
@@ -46,7 +46,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "d2baf22d884daf235af39c2010d42f5fdefc4004ae30a45a6a4ce33115c52c8f",
"sourceSha256": "264380083a9a18bfc2305a6577368068d6839fabe82fe1a1f440a62867b4e589",
"imageSha256": "2f13ea645af306eee9cc7c52699c928377b72f412b08c1a3db6beb848ace1b51"
},
"plan-context-tray": {
@@ -57,7 +57,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "d2baf22d884daf235af39c2010d42f5fdefc4004ae30a45a6a4ce33115c52c8f",
"sourceSha256": "264380083a9a18bfc2305a6577368068d6839fabe82fe1a1f440a62867b4e589",
"imageSha256": "a6c526fede11bc3503fd2384bd4a3f6afa008f481c5c47578f06cae6c81c6f9b"
},
"device-editor": {
@@ -68,7 +68,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "d2baf22d884daf235af39c2010d42f5fdefc4004ae30a45a6a4ce33115c52c8f",
"sourceSha256": "264380083a9a18bfc2305a6577368068d6839fabe82fe1a1f440a62867b4e589",
"imageSha256": "9585b59add4d35b5a6f028ce5b720ef1b77a3f8b19436d192cd1a0e6fc637264"
},
"device-display-preview": {
@@ -79,7 +79,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "d2baf22d884daf235af39c2010d42f5fdefc4004ae30a45a6a4ce33115c52c8f",
"sourceSha256": "264380083a9a18bfc2305a6577368068d6839fabe82fe1a1f440a62867b4e589",
"imageSha256": "cfc317da4628d079a116ff71311fbf06b1eb3b181928a5ea7ba888ab936a5e8b"
},
"background-editor": {
@@ -90,7 +90,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "d2baf22d884daf235af39c2010d42f5fdefc4004ae30a45a6a4ce33115c52c8f",
"sourceSha256": "264380083a9a18bfc2305a6577368068d6839fabe82fe1a1f440a62867b4e589",
"imageSha256": "d7cfe70551d9260169df8efd832e32ed7df99c7f60b55d1fd4a8322bd4c47175"
},
"room-card": {
@@ -101,7 +101,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "d2baf22d884daf235af39c2010d42f5fdefc4004ae30a45a6a4ce33115c52c8f",
"sourceSha256": "264380083a9a18bfc2305a6577368068d6839fabe82fe1a1f440a62867b4e589",
"imageSha256": "029a3e69ec647a8a370d99e6bb7f9225833c526739076022f6b52ba54bff30ea"
},
"device-info": {
@@ -112,7 +112,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "d2baf22d884daf235af39c2010d42f5fdefc4004ae30a45a6a4ce33115c52c8f",
"sourceSha256": "264380083a9a18bfc2305a6577368068d6839fabe82fe1a1f440a62867b4e589",
"imageSha256": "a06cbf83f09e2f67b3566d7c0b10e973db060c3d20786f26f74ada6a21937c3e"
}
}
+198
View File
@@ -0,0 +1,198 @@
# Код-ревью #226 — r1
- Issue: [#226](https://github.com/Matysh/houseplan-card/issues/226)
- ТЗ: [`docs/specs/226-entity-parent-dedup.md`](../specs/226-entity-parent-dedup.md)
(зелёное ревью r2: [`SPEC-REVIEW-226-r2.md`](SPEC-REVIEW-226-r2.md))
- Ветка: `issue/226-entity-parent-dedup`, коммит реализации `f151e70`
- Материал: `git diff origin/dev...HEAD`, `git log --oneline origin/dev..HEAD`
- Вердикт: **зелёный** · цикл r1/4 · High: 0 · Medium: 0
## Скоуп разбора
Первый цикл код-ревью — разбор полный. Диапазон коммитов от `dev`: два
docs-коммита спец-ревью (уже приняты на этапе spec), один implementation
commit `f151e70`. Продуктовый код меняется только в `src/devices.ts`
(`buildDevices`, `seedHiddenBindings` + два новых internal helper).
Сопутствующие изменения: `test/devices.test.mjs`, `demo/smoke_entity_parent_dedup.mjs`,
`scripts/mutation-gate.mjs`, `docs/FILTERING.md`, `docs/USER-GUIDE{.ru,}.md`,
`docs/TESTING.md`, оба changelog, `docs/specs/README.md`, три копии bundle.
## Как проверялось
### Прочитано построчно
- `src/devices.ts` diff целиком: `entityMarkerOwnership()`,
`residualAutoDeviceEntities()`, их встраивание в `buildDevices()` (авто-цикл
устройств, строки ~1092–1144) и в `seedHiddenBindings()` (~1004–1030), а
также неизменённые ветки — явный `device:D` (~1180–1210) и явный `entity:X`
(~1211–1245), чтобы подтвердить заявленную асимметрию.
- `src/ha-binding-status.ts`: `activeRegistryHass`, `fullRegistryHass`,
`isRegistryEntryEnabled`, `HaBindingStatus` — чтобы проверить, что
реконструкция `itemBindingStatus` для частичного остатка (`{kind:'active',
enabledEntityIds: entIds, allEntityIds: entIds}`) не роняет поля, которых нет
в варианте `active`, и что `hass.entities[eid].hidden` — тот же нормализованный
frontend-флаг, что уже используется в `visibleFirst()` (`devices.ts:147`) —
не новое допущение этого PR.
- Потребители `allEntities`/`entities` вне `devices.ts` (`device-presentation.ts:247`,
`device-toggle.ts:384,723`) — подтверждено, что частичный остаток
сознательно не протекает через `allEntities` (§6 ТЗ), а не через
недосмотр: если единственный cover в остатке скрыт HA, `allEntities`
корректно не содержит его, и это ровно граница #94, которую ТЗ объявляет
ожидаемой.
- `test/devices.test.mjs` и `test/mutation-gate.test.mjs` diff целиком —
сопоставлено с матрицей §12 ТЗ (таблица ниже).
- `docs/FILTERING.md`, `docs/USER-GUIDE.md`, `docs/USER-GUIDE.ru.md`,
`docs/TESTING.md`, оба `CHANGELOG` — сверено с §16 ТЗ и с формулировками,
принятыми на ревью ТЗ (§3.3/§8), терминология не изобретена.
### Выполнено (не только прочитано)
```
npx tsc --noEmit → passed, без вывода
npm test → 971 passed / 0 failed / 0 skipped
npm run build → passed
sha256sum dist/… custom_components/…/frontend/… demo/srv/assets/…
→ одинаковый хеш на всех трёх копиях,
совпадает с хешем из хендоффа автора
(795513ec6d15…)
node scripts/mutation-gate.mjs --id=entity-marker-kept-in-parent-device
→ чистый прогон ok, мутант "покраснел, как обязан"
node scripts/mutation-gate.mjs --id=entity-marker-parent-seeded
→ чистый прогон ok, мутант "покраснел, как обязан"
node demo/smoke_entity_parent_dedup.mjs → OK, все 8 planFacts/previewFacts/staticFacts true
node demo/smoke_cover_not_primary.mjs → OK, 36/36 фактов true (регресс #94 не пойман)
node demo/smoke_cover_tap.mjs → OK, 31/31 фактов true
node scripts/check-docs.mjs --external → passed (7 файлов, 10 внешних ссылок)
npm run inventory → 971 unit / 144 pure backend / 114 HA-harness / 156 smokes
(сверено с записью автора)
```
Дисциплина «тест должен уметь падать» применена к обоим мутационным guard: у
каждого проверен и чистый прогон (ok), и то, что патч именно ломает целевой
тест, а не проходит мимо.
## Находки
Нет находок ни High, ни Medium. Один Low, снят с записью ниже.
**Low — тест-матрица §12.7 не покрывает первую половину кейса напрямую.**
ТЗ (кейс 7, AC4) требует: «явная HA-hidden entity работает как exact marker».
Тест `hidden-only curtain residual disappears…` доказывает только вторую
половину (hidden sibling не удерживает остаток). Прямого юнита «маркер
`entity:X`, где сама `X` имеет `reg.hidden: true`, и это не глушит explicit-ветку»
нет.
Снимаю без правки: ветка `kind === 'entity'` в `buildDevices` (строки
~1211–1245) не читает `hidden`/`reg.hidden` ни в этом коммите, ни до него —
diff её не касается вовсе. Поведение «explicit entity marker показывается
независимо от HA-hidden» не новое и не зависит от `entityMarkerOwnership`;
риск регрессии от этого PR отсутствует, потому что PR не добавляет туда ни
одной строки. Это пробел в тест-документации ТЗ (заявлена AC-покрытием кейса,
которого нет буквально), не дефект кода. Не блокирует — верно чтением, а не
исполнением, что здесь и достаточно.
## Что проверено и корректно
- **AC1 (нет полного дубля).** `residualAutoDeviceEntities` вычитает
`placedEntityIds` из `entsBy[dev.id]`; при пустом остатке
`if (residual.partial && !residual.entityIds.length) continue;` — устройство
не попадает в вывод ни в `buildDevices`, ни в `seedHiddenBindings`. Юнит-кейсы
1/3 зелёные, мутант `entity-marker-kept-in-parent-device` подтверждён
падающим при откате правила.
- **AC2 (частичный остаток).** Тест «partial auto parent contains only visible
unclaimed siblings» проверяет не только `entities`, но и `allEntities`,
`bindingStatus`, `primary` и `resolvedLightSources` — заявленная claimed
light не протекает повторно. `itemBindingStatus` — валидный `active`-вариант
`HaBindingStatus` (проверено типом в `ha-binding-status.ts:14`), typecheck
зелёный.
- **AC3 (явная асимметрия).** Тест «explicit device and child entity markers
coexist intentionally»: `device:D` сохраняет полный состав через
неизменённую ветку (~1187, `bindingStatus.enabledEntityIds`), не пересекается
с `residualAutoDeviceEntities`. Пре-существующий тест «entity tombstone does
not strip that entity from a live parent device» (строка 451, не в дельте)
прогнан в общем `npm test` и зелёный — `entityMarkerOwnership` корректно
пропускает `marker.removed === true`.
- **AC4 (hidden-контракты + #94).** `residualAutoDeviceEntities` возвращает
`{partial:false, entityIds:[...entityIds]}`, когда `ownership.byDevice` не
содержит записи для устройства — нетронутый auto/`device:D` не фильтрует
hidden вообще, что и требует §8 ТЗ. Подтверждено смоками
`smoke_cover_not_primary.mjs`/`smoke_cover_tap.mjs` (регресс #94 не
воспроизведён) и юнитом «hidden-only curtain residual disappears but
explicit device stays cover-first» — untouched-ветка даёт `primary:
'cover.curtain'`, split-ветка убирает auto-marker, explicit `device:D`
восстанавливает штору. Marker-hidden (case 5) и HA-disabled (case 9)
отдельно покрыты юнитами и дают верный `ghost`/`hidden` статус.
- **AC5 (standalone/группы).** Ветка light-groups (`groups`, `claimed.has('entity:'+g.eid)`)
не тронута диффом; helper без `device_id` пропускается в
`entityMarkerOwnership` через `if (!deviceId) continue;`.
- **AC6 (seeder parity).** `seedHiddenBindings` использует те же
`entityMarkerOwnership`/`residualAutoDeviceEntities`, не дублирующую
реализацию — единственный источник правила, как требует §6 ТЗ. Тест
«entity ownership uses the same visible residual as buildDevices» и мутант
`entity-marker-parent-seeded` подтверждают.
- **AC7 (все renderers).** `demo/smoke_entity_parent_dedup.mjs` проверяет
полный View/kiosk-DOM (390×760, touch-профиль), клик по exact-marker
(`light.ceiling`, точный `turn_off`, конфиг не перезаписывается), Device
editor preview (`hp-device-preview` — ровно одна `.dev`-морда) и
`houseplan-space-card` (статическая карта видит только exact-сущность).
- **AC8 (динамический registry).** Юнит «registry hidden sibling changes
rebuild the residual without config writes» — два снапшота (`hidden`/`visible`)
дают разный список без переписывания маркера (`assert.deepEqual(marker, …)`
на неизменный объект).
- **AC9 (совместимость).** Diff не трогает `ServerConfig`, backend,
i18n-ключи, wire protocol. `npx tsc --noEmit` зелёный, `npm run build`
зелёный.
- **AC10 (release-артефакты).** Оба changelog в том же коммите `f151e70`
(`Issue: #226`, `User-Visible: yes`), `docs/FILTERING.md`/`USER-GUIDE{.ru,}.md`/`TESTING.md`
описывают ownership тем же языком, что принят на ревью ТЗ.
`docs/images/screenshots.json`: `sourceFingerprint` обновился (ожидаемо —
`src/**` изменился), все 10 `imageSha256` идентичны dev — визуальной дельты
в захваченных сценариях нет. Три копии bundle синхронны по SHA-256.
- **Производительность (§17 ТЗ).** `entityMarkerOwnership` — один проход по
`markers` (`O(markers)`), `residualAutoDeviceEntities` — `O(1)` lookup на
сущность через `Set`/`Map`, никакого вложенного поиска markers внутри
device/entity циклов. Проверено чтением, не исполнением (нет отдельного
perf-теста в AC этой задачи).
- **Мутационные guards.** Оба id из §14 ТЗ присутствуют в `scripts/mutation-gate.mjs`
и подтверждены индивидуальным прогоном (см. выше). `test/mutation-gate.test.mjs`
не менялся, но перебирает `MUTANTS` обобщённо — новые id уже под его
структурными проверками (`patch anchors exactly once`, `guard file exists`,
`explains itself`).
- **Трейлеры.** `f151e70`: `Issue: #226`, `User-Visible: yes` — терминальные,
оба changelog в том же коммите.
## Чего не проверял и почему
- **`npm run golden:verify` не запускался.** Golden-фикстуры (`demo/golden/matrix.mjs`,
`harness.mjs`) — про геометрию стен/комнат/glow, ни один сценарий не строит
устройство с несколькими сущностями и явным entity-marker на части из них
(проверено grep по фикстурам). Единственный канал, где эта задача могла
задеть видимый пиксель, — количество/состав device-маркеров на скриншотах
документации, а там все 10 `imageSha256` не изменились. Риск, который
`golden:verify` мог бы поймать сверх уже пройденных смоков, оцениваю как
пренебрежимо малый для объёма этой правки; полный golden — гейт предбета
(PROCESS.md §8), не код-ревью.
- **`npm run golden:capture`/принятие baseline** не запускалось — не требуется:
геометрия marker не меняется (ТЗ §15), новых сценариев нет.
- **`python -m pytest tests_backend`** не запускался — диф не касается
`custom_components/houseplan/**/*.py` ни одним файлом (только сгенерированный
frontend-бандл под `custom_components/houseplan/frontend/`, класс D).
- **Полный набор из 156 браузерных смоков** не прогонялся. Прогнаны три:
новый `smoke_entity_parent_dedup.mjs` (назван в AC7/матрице §12.15) и два,
которые делят с диффом общий resolver #94 (`smoke_cover_not_primary.mjs`,
`smoke_cover_tap.mjs`). Остальные смоки покрывают геометрию/openings/sun/
touch-жесты — поверхности, которых этот diff не трогает.
- **Performance-профиль на синтетическом большом registry** не запускался:
не назван в AC этой задачи, а линейность подтверждена чтением кода (раздел
выше).
- **Ручное тестирование в браузере** не проводилось — по процессу код-ревью
этого цикла работает по диффу и автоматическим доказательствам, не по живому
UI.
## Итог
Все 10 AC доказаны автотестом или прочитанным кодом с явной пометкой способа
проверки; оба мутационных guard подтверждены индивидуальным прогоном, включая
проверку, что они действительно способны покраснеть. Единственная находка —
Low, документационный пробел тест-матрицы без функционального риска, снят с
запиской выше. Готово к статусу «Принято»/`S8-merged`.
+218
View File
@@ -0,0 +1,218 @@
# SPEC-REVIEW — issue #226, цикл r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/226
- **ТЗ:** `docs/specs/226-entity-parent-dedup.md`, коммит `5feabed` (ветка
`issue/226-entity-parent-dedup`)
- **Трек:** обычный полный (не `small`), лимит циклов ревью ТЗ — 4
- **Ревьюер:** Claude (роль «Ревьюер ТЗ», отдельная сессия от автора)
- **Вердикт:** жёлтый · цикл r1/4 · High: 0 · Medium: 1 → в задаче
## Скоуп проверки
Issue #226 — баг: явно размещённый `entity:X` не вычитается из авто-обнаруженного
родительского `device:D`, поэтому один физический прибор задваивается на плане
(классический вход — HA-хелпер «Switch as X»). ТЗ вводит частичное ownership
между entity-markers и родительским устройством плюс отдельные правила для
`hidden_by`, tombstone, marker.hidden и явной пары `device:D + entity:X`.
Это первый цикл — раздел «объём по дельте» (§2.10 PROCESS.md) не применяется,
разбор выполнен полностью:
1. соответствие ТЗ обязательным разделам §7.1 PROCESS.md;
2. однозначность и доказуемость AC1…AC10;
3. отсутствие догадок, выданных за факт без пометки «предположение»;
4. соответствие ТЗ реальному коду `src/devices.ts`, `src/ha-binding-status.ts`
и канону (`docs/SCOPE.md`, `docs/FILTERING.md`, `docs/TOUCH-SUPPORT.md`);
5. что вопросы, заданные и не заданные владельцу, действительно продуктовые
либо действительно технические.
## Как проверялось
- Прочитаны `docs/SCOPE.md` (J1/J3/J4/J6 — единый правдивый объект, однозначное
действие, предсказуемая настройка, план остаётся верным при появлении
HA-сущностей), `AGENTS.md`, `PROCESS.md` целиком (§2.4, §2.10, §7.1, §7.2, §12).
- Прочитано тело issue #226 и все пять комментариев: аналитика, пакет вопросов
Q1-Q3, решения владельца по каждому вопросу, занятие, публикация ТЗ. Метка на
момент ревью — `S4-spec-review`.
- Прочитан `docs/specs/226-entity-parent-dedup.md` целиком (19 разделов).
- Прочитан текущий код `src/devices.ts` построчно в проблемной области:
`entitiesByDevice()` (:42-51, не фильтрует `hidden`, только
`isRegistryEntryEnabled`), `claimed`-цикл (:1032, ровно тот баг, что описан в
issue — `claimed.add(m.binding)` кладёт точный `entity:X`), авто-устройства
(:1032-1042, `claimed.has('device:' + dev.id)` — не видит entity-привязку),
`seedHiddenBindings()` (:956-990), `primaryEntity`/`resolvedDeviceStateEntities`/
`visibleFirst` (:99-177, уже читают `reg.hidden` как существующий паттерн —
ТЗ его не изобретает, а переиспользует).
- Прочитан `src/ha-binding-status.ts`: `isRegistryEntryEnabled` (`disabled_by ==
null`, не трогает hidden), `activeRegistryHass`/`fullRegistryHass` (проекции по
`disabled_by`, hidden не фильтруют), `resolveHaBindingStatus` (`ha_disabled` —
тоже по `disabled_by`, не по `hidden_by`) — подтверждает заявление ТЗ §5, что
`hidden_by` сейчас не участвует ни в одной из этих проекций.
- Прочитан `docs/FILTERING.md` (строки 190-217) — контракт #94 (Aqara Roller
shade E1, `cover.*` скрыт интеграцией, видимый `switch.*_reverse_direction`,
cover-first resolver) подтверждён дословно тем же текстом, что цитирует ТЗ §8.
- Прочитан ранее реализованный `docs/superpowers/specs/2026-08-08-ha-disabled-devices-design.md`
(инвариант 5: «`hidden_by` сущности HA не равен `disabled_by`: скрытая в
интерфейсе HA сущность остаётся рабочей для House Plan») — ТЗ #226 этому канону
не противоречит: не превращает `hidden_by` в глобальный фильтр, использует его
только как узкий критерий «не поддерживает остаток» (§3.2-3.3 ТЗ).
- Прочитан `docs/TOUCH-SUPPORT.md` (строки 9-40) — формулировка ТЗ §17 «View и
kiosk release-blocking» совпадает с каноном дословно.
- Проверено docs/CONFIG-COMPATIBILITY.md на предмет требований к чисто
runtime-правкам без изменения схемы — заявление ТЗ §9 «схема, backend,
storage version и wire protocol не меняются» противоречий не имеет.
- Гейты кода в этом цикле не запускались: предмет ревью — ТЗ, реализации ещё
нет (стадия `S4-spec-review`, изменён только класс C — `docs/specs/**`).
## Находки
### Medium — в скоупе задачи
**M1. Комбинация «явный marker на видимую сущность + единственный оставшийся
сиблинг скрыт HA и функционально важен (сценарий #94)» не имеет ни AC, ни теста,
хотя решённые в ТЗ правила делают её исход неочевидным и потенциально
регрессионным именно для дважды уже чинившегося контракта штор.**
Разбор по коду и ТЗ:
- §3.3 ТЗ: «Hidden sibling не удерживает остаток» — HA-hidden сущность не
считается основанием для остаточного auto-marker.
- §6 шаг 5: «Если `visibleResidual(D)` пуст, auto-marker не добавляется. Наличие
только hidden siblings не считается остатком.»
- §8: «Только **остаточный** auto-marker, возникший после явного entity-marker,
применяет правило «hidden siblings не удерживают остаток»» — то есть защита
#94 (тест-кейс 8) прямо ограничена **нетронутым** устройством.
Возьмём буквально сценарий #94: устройство `D` = штора, `cover.curtain` скрыт
интеграцией, `switch.reverse_direction` виден. Пользователь размещает
`entity:switch.reverse_direction` (ровно тот класс действия, который #226
описывает как «выбирает то, что видит» — здесь это доступный видимый service
switch). По правилам ТЗ: `switch.reverse_direction` уходит под точный
entity-marker; единственный оставшийся сиблинг `cover.curtain` — скрыт HA и по
§3.3/§6.5 не формирует остаток → `visibleResidual(D)` пуст → auto-marker `D`
целиком исчезает с плана.
До фикса #226 при том же действии пользователя `D` продолжал бы строиться как
полный auto-device (баг #226 создаёт **дубль**: и entity-marker, и полноценный
auto-device с cover-first резолвером). После фикса, по буквальному прочтению
принятого правила, дубль устраняется ценой **потери самого объекта**: шторы
целиком нет на плане, хотя до задачи #226 функциональное представление шторы
(пусть и задвоенное) на плане было. Это худший, а не нейтральный исход именно
для контракта, который #94 чинил дважды (`docs/FILTERING.md` — «Why the cover is
FIRST and not third», «шторы никогда не жёлтые» уже дважды пробивалось
соседними правилами).
Формально это не противоречит букве принятого владельцем default по Q2 (owner:
«При расчёте остатка после отдельного entity-marker не считать скрытые siblings
основанием для второго auto-marker»), и потому не новый неотвеченный
продуктовый вопрос по существу правила. Но конкретное следствие — «устройство
может исчезнуть с плана целиком, если пользователь разместит маркер на видимую
вспомогательную сущность шторы» — нигде в ТЗ не названо явно и не имеет
собственного теста в матрице §12 (кейс 7 испытывает HA-hidden **саму** `X`,
кейс 8 испытывает **нетронутое** устройство; комбинации «видимая размещённая
сущность + единственный оставшийся сиблинг скрыт и функционально первичен» нет
ни в одном из 14 кейсов).
**Почему это Medium, а не Low.** Дефект не в правиле как таковом (оно — решение
владельца), а в отсутствии доказательства и явной фиксации именно этого
пограничного исхода в зоне, которая уже дважды была источником регрессии (#94
и последующий DEV-1DA1-01/DEV-2C947-01, оба упомянуты в `docs/FILTERING.md`).
Без явного теста разработчик может реализовать правило иначе (например, счесть,
что «функционально протected» сиблинг вроде cover-first-резолвера обязан
удерживать остаток), и оба прочтения пройдут существующую матрицу тестов ТЗ
одинаково зелёным — расхождение проявится только на живом Aqara-конфиге
пользователя.
**Фикс, ожидаемый в этом же цикле:** добавить в §12 (матрица тестов) явный
пятнадцатый кейс — размещённая видимая `entity:switch.reverse_direction`,
единственный оставшийся сиблинг `cover.curtain` скрыт HA — и явно зафиксировать
в §8/§3.3 ожидаемый результат (auto-marker `D` исчезает — если это осознанно
принимается; либо cover-first защита #94 переживает и это состояние, если нет).
Достаточно одного предложения в ТЗ плюс одной строки в матрице; выбор из двух
исходов — техническое решение автора по формулировке правила, разногласие
с ревьюером решается вердиктом, а не владельцем (`PROCESS.md` §7.1).
### Low
Не найдено находок, которые стоило бы фиксировать отдельно и не чинить.
## Что проверено и корректно
- Обязательные разделы §7.1 присутствуют по существу: сценарий и персона (§1,
включает и постановку проблемы), что человек увидит до/после (§2, без
терминов реализации), скоуп/не-скоуп (§4), контракт поведения (§3/§6-8),
модель данных и совместимость (§9), i18n (§10 — не требуется, обоснованно:
нет новых строк UI), критерии приёмки AC1…AC10 с доказательством (§13), план
автотестов (§12/§14), риски (§18), откат (§18), release-артефакты (§16).
Отдельного заголовка «Проблема» нет — текст интегрирован в §1 без потери
содержания, не блокирующее замечание.
- Все три продуктовых вопроса (Q1 частичное ownership, Q2 `hidden_by` без
регрессии #94, Q3 асимметрия `device:D`/`entity:X`) заданы владельцу пачкой
с default и получили явные ответы **до** написания ТЗ — процесс §7.1
соблюдён, ТЗ переносит принятые решения, а не изобретает их.
- Причина дефекта, описанная в §3 ТЗ и в самом issue, точно соответствует коду:
`claimed.add(m.binding)` (:1032 `src/devices.ts`) кладёт ровно
`entity:light.liustra`, цикл авто-устройств (:1039) проверяет только
`claimed.has('device:' + dev.id)` — связь entity→device в `claimed` не
участвует. Смежная находка про `hidden_by`/`entitiesByDevice()` тоже
подтверждена чтением: функция (:42-51) фильтрует только `disabled_by`.
- Все продуктовые решения §3 (1-6) прослежены до конкретных AC и тест-кейсов
без пропусков: partial ownership → AC1/AC2; `hidden_by` не глобальный фильтр
→ AC4/кейс8; hidden sibling не держит остаток → AC4/кейс7; явная асимметрия
`device:D`/`entity:X` → AC3/кейс4; tombstone не владеет → AC3/кейс6; marker
hidden сохраняет ownership → AC4/кейс5.
- Алгоритм (§6) явно требует линейную сложность `O(markers + entities +
devices)` и запрещает вложенный поиск — соответствует требованию
производительности §17 и не противоречит текущей структуре `buildDevices()`
(уже строит `claimed`/`entsBy` за один проход).
- Защита #94 сформулирована через прямую цитату существующего канона
(`docs/FILTERING.md:190-217`) и получила отдельный обязательный регрессионный
тест (кейс 8) для **нетронутого** устройства — совпадает с текстом канона
дословно, не пересказ на слово.
- Раздел «Принятые предположения» (§19) — реальные технические допущения
(семантика «видимой» entity как отсутствие `reg.hidden`, отсутствие новых
настроек/переводов), не подмена продуктового решения: все пять пунктов
либо прямо повторяют ответы владельца, либо являются нейтральными
техническими деталями (инертность layout key уже была решена в самом issue
автором аналитики).
- Не-скоуп (§4) корректно исключает автослияние/удаление двух явных markers,
смену выбора primary у полного device-marker, глобальное исключение
`hidden_by`, миграцию и новый config — то есть ТЗ не расширяет задачу дальше
системного правила ownership, заявленного в issue.
- Touch/kiosk (§17) корректно унаследован как release-blocking для View и
kiosk по действующему `docs/TOUCH-SUPPORT.md`, без нового touch-контракта.
- Compatibility (§9) корректен: изменение чисто runtime-проекции без записи
конфига не требует миграции, схема/backend/wire protocol не меняются —
подтверждено отсутствием противоречий в `docs/CONFIG-COMPATIBILITY.md`.
- Track (обычный, не `small`) выбран верно и обоснован в issue самим автором
аналитики: несколько потребителей (`buildDevices`, seeder, light/Glow, LQI,
два редактора), конфликт с уже принятым поведением штор — критерий «одна
поверхность» для лёгкого трека не выполняется.
- Mutation-gate (§14 ТЗ) сузил предложенный в issue список с трёх до двух id
(`entity-marker-kept-in-parent-device`, `entity-marker-parent-seeded`) без
отдельного мутанта на `hidden_by`-регрессию — но это техническое решение
автора о стратегии тестов (`PROCESS.md` §7.1: «стратегия тестов… агенты
решают сами»), не продуктовая догадка; AC4 всё равно доказывается unit-кейсами
5/7/8/9 без мутационного гейта на каждый.
## Чего не проверял
- Код `buildDevices()`/`seedHiddenBindings()` реализации ещё не существует
(стадия `S4-spec-review`, изменён только класс C) — `npx tsc --noEmit`,
`npm test`, `npm run build` не запускались: собирать пока нечего.
- Golden/browser smoke/performance/mutation-gate не запускались по той же
причине — предмет код-ревью (§2.7), не ревью ТЗ (§2.4).
- Не проверялась построчно вся `docs/USER-GUIDE.ru.md`/`docs/USER-GUIDE.md` на
предмет других мест, описывающих текущее (дублирующее) поведение auto-device —
целевой grep по «auto-device», «родительск», «Switch as X» ограничен разделом
§16 ТЗ, где эти файлы названы release-артефактом; полнота их будущей правки —
предмет код-ревью, не ревью ТЗ.
- Не проверялась историческая полнота предыдущих SPEC/CODE-REVIEW документов
по #170 (связанная находка) на предмет собственных незакрытых находок — вне
предмета этого ревью.
## Раздел «Унаследовано» — не применяется
Это первый цикл ревью ТЗ (r1); раздел «Унаследовано из r<N-1>» и таблица
закрытия предыдущего раунда не ведутся (§2.10 PROCESS.md действует со второго
цикла).
+154
View File
@@ -0,0 +1,154 @@
# SPEC-REVIEW — issue #226, цикл r2
- **Issue:** https://github.com/Matysh/houseplan-card/issues/226
- **ТЗ:** `docs/specs/226-entity-parent-dedup.md`, коммит `6162da8` (ветка
`issue/226-entity-parent-dedup`)
- **Предыдущий раунд:** r1, вердикт жёлтый, получен на коммите `5feabed`
(`docs/reviews/SPEC-REVIEW-226-r1.md`, назван явно в шапке документа — SHA
не пришлось искать отдельной находкой).
- **Трек:** обычный полный (не `small`), лимит циклов ревью ТЗ — 4
- **Ревьюер:** Claude (роль «Ревьюер ТЗ», отдельная сессия от автора)
- **Вердикт:** зелёный · цикл r2/4 · High: 0 · Medium: 0
## Скоуп проверки
Это второй цикл — по §2.10 PROCESS.md разбор ведётся по дельте, а не заново.
Дельта объявлена: `git diff 5feabed..6162da8`. Затронут ровно один файл
предмета ревью — `docs/specs/226-entity-parent-dedup.md`, плюс сам r1-документ
(не часть ТЗ). Дельта в ТЗ:
- §3.3 — одно уточняющее предложение о следствии правила «hidden sibling не
удерживает остаток»;
- §8 — одно уточняющее предложение о том же следствии применительно к
сценарию #94, плюс явное «это ожидаемое следствие Q2, а не обход
cover-first»;
- §12 — новый тест-кейс 14 (граница #94: видимая `entity:switch.reverse_direction`
+ hidden-only `cover.curtain`), старый кейс 14 (Switch as X browser fixture)
сдвинут на 15;
- AC4 — добавлена ссылка на новый кейс 14;
- AC7 — обновлена ссылка со старого номера 14 на новый номер 15.
Дельта локальна: не меняет контракт поведения (текст фиксирует то же решение
Q2, которое уже было принято владельцем в r1), не задевает новую подсистему,
не связана с ребейзом (`origin/dev` здесь не участвует, история линейная —
`3af0484 → a20dd54 → 5feabed → ecf9473 → 6162da8`). Полный разбор всего ТЗ не
требуется; проверке подлежит закрытие находки M1 и любые AC, чьё доказательство
эта дельта задевает — то есть только AC4 и AC7 (по номеру ссылки на тест-кейс,
без изменения самого доказательства AC7).
## Как проверялось
- `git diff 5feabed..6162da8` — построчно, весь diff (см. выше состав).
- Перечитаны актуальные редакции §3.3, §8, §12 (кейсы 12-15), AC4, AC7 в
`docs/specs/226-entity-parent-dedup.md` на коммите `6162da8` целиком, не
только diff-хунки — чтобы увидеть новый текст в контексте всего раздела, а не
выдернутым из соседних решений.
- `git log --oneline -5` — подтверждён линейный путь от `5feabed` (SHA r1) до
текущего HEAD `6162da8`, без ребейза и посторонних коммитов.
- Прочитаны все комментарии issue #226 после публикации r1: комментарий
вердикта r1 (жёлтый, M1) и ответный комментарий автора «Исправил
SPEC-REVIEW r1, M1 в `6162da8`» с перечислением четырёх правок — сверено
построчно с фактическим diff, расхождений нет.
- `grep` по всему файлу ТЗ на предмет устаревших ссылок на старую нумерацию
тест-кейсов после сдвига 14→15 — не найдено (AC4/AC7/§14 мутационного гейта
ссылаются на кейсы по номеру и/или по существу корректно, других мест,
ссылающихся на «кейс 14» в старом смысле, нет).
- Независимо (не по заявлению автора) перезапущены оба гейта документации,
которые автор указал в хендоффе: `git diff --check 5feabed..6162da8` → exit
0, чисто; `node scripts/check-docs.mjs --external` → `Documentation checks
passed (7 files, 10 external links)`, exit 0.
- Код гейты (`npx tsc --noEmit`, `npm test`, `npm run build`) не запускались:
стадия по-прежнему `S4-spec-review` (метки issue: `bug`, `P1`,
`S4-spec-review`), продуктовый код не существует — их предмет отсутствует, а
не отложен.
- Остальные разделы ТЗ (§1-2, §4-7 кроме дельты, §9-11, §13 кроме AC4/AC7,
§15-19) не перечитывались построчно повторно — их проверка унаследована из
r1 (см. раздел ниже), т.к. дельта их текст не меняет.
## Находки
Новых находок нет. Единственная находка r1 (M1) закрыта дельтой этого раунда
— см. таблицу ниже.
## Закрытие раунда r1
| Находка r1 | Чем закрыта | Где это видно |
|---|---|---|
| **M1** — комбинация «явный marker на видимую вспомогательную сущность + единственный оставшийся сиблинг скрыт HA и функционально первичен (сценарий #94)» не имела ни AC, ни теста, исход был неочевиден | (а) §3.3 получил явное предложение, фиксирующее исход: «если пользователь вынес видимую вспомогательную entity, а у родителя остался только HA-hidden функциональный sibling, auto-marker родителя исчезает»; (б) §8 получил зеркальное явное предложение применительно к #94 с явной пометкой «это ожидаемое следствие Q2, а не обход cover-first»; (в) §12 получил новый тест-кейс 14 с точным сценарием (видимый `switch.reverse_direction` + hidden-only `cover.curtain` → только entity-marker; явный `device:D` восстанавливает полную штору); (г) AC4 теперь явно ссылается на кейс 14 | `docs/specs/226-entity-parent-dedup.md:53-57` (§3.3), `:162-166` (§8), `:238-241` (§12, кейс 14), `:258-260` (AC4) на коммите `6162da8`; `git diff 5feabed..6162da8` показывает все четыре правки одним связным diff-хунком по каждому разделу |
Автор выбрал именно ту из двух альтернатив, что была предложена в r1 как
«если это осознанно принимается»: auto-marker исчезает, а не «cover-first
защита переживает это состояние». Это техническое решение автора о
формулировке правила (`PROCESS.md` §7.1 — решается вердиктом ревьюера, не
владельцем), и выбор согласуется с уже принятым default Q2 и с текстом,
который сам владелец одобрил в r1. Ревьюер согласен: решение сформулировано
явно, не как недосказанность, снабжено собственным тестом и не противоречит
ничему из принятых владельцем решений Q1-Q3.
## Что проверено и корректно (дельта этого раунда)
- Новый текст §3.3/§8 не вводит догадку, выданную за факт: оба предложения
прямо помечены как «следствие принято осознанно» / «ожидаемое следствие
Q2», то есть явно относятся к уже принятому владельцем решению, а не
изобретают новое продуктовое правило без него.
- Новый тест-кейс 14 однозначен и доказуем: два состояния (только
entity-marker после размещения; полная штора после добавления явного
`device:D`) с конкретными сущностями (`switch.reverse_direction`,
`cover.curtain`), не оставляет свободы трактовки.
- Ссылки AC4→кейс14 и AC7→кейс15 (после сдвига) корректны, других мест с
устаревшей нумерацией не осталось (проверено `grep`).
- Оба гейта документации, заявленные автором в хендоффе, воспроизведены
независимо и оба зелёные.
## Чего не проверял
- Полный текст ТЗ вне дельты (§1-2, §4-7 кроме тронутых предложений, §9-11,
§13 кроме AC4/AC7-ссылок, §15-19) — не перечитывался заново; принят
унаследованным из r1 (см. ниже), поскольку дельта этого текста не касается.
- Код `buildDevices()`/`seedHiddenBindings()` реализации по-прежнему не
существует — `npx tsc --noEmit`, `npm test`, `npm run build`, golden,
browser smoke, performance, mutation-gate не запускались: предмет код-ревью
(§2.7), не ревью ТЗ (§2.4), стадия не менялась.
- `docs/USER-GUIDE.ru.md`/`docs/USER-GUIDE.md`/`docs/FILTERING.md` как
release-артефакты будущей реализации — не проверялись повторно, дельта их
не трогает (это по-прежнему только план в §16 ТЗ).
## Унаследовано из r1
Документ: `docs/reviews/SPEC-REVIEW-226-r1.md`, SHA `5feabed`.
Принято без повторной проверки в этом раунде, так как дельта `5feabed..6162da8`
этого текста и вывода не касается:
- Соответствие ТЗ обязательным разделам §7.1 PROCESS.md (сценарий/персона,
что человек увидит, скоуп/не-скоуп, контракт поведения, модель данных и
совместимость, i18n, AC1…AC10 с доказательством, план автотестов, риски,
откат, release-артефакты) — все присутствуют по существу, отдельного
дефекта не найдено.
- Три продуктовых вопроса владельцу (Q1 partial ownership, Q2 `hidden_by`/#94,
Q3 асимметрия `device:D`/`entity:X`) заданы пачкой с default и получили явные
ответы до написания ТЗ — процесс §7.1 соблюдён.
- Причина дефекта в §3 ТЗ соответствует коду `src/devices.ts`
(`claimed.add(m.binding)` :1032, цикл авто-устройств :1039) и
`src/ha-binding-status.ts` (`hidden_by` не участвует ни в одной активной
проекции) — подтверждено построчным чтением в r1.
- Защита #94 сформулирована дословной цитатой канона `docs/FILTERING.md:190-217`
и получила отдельный регрессионный тест (кейс 8, нетронутое устройство).
- AC1…AC3, AC5, AC6, AC8, AC9, AC10 прослежены до конкретных тест-кейсов без
пропусков — вывод r1 остаётся в силе, дельта этих AC не касается.
- Алгоритм §6 (линейная сложность, запрет вложенного поиска), compatibility
§9 (нет миграции/схемы/wire protocol), touch/kiosk §17 (release-blocking по
`docs/TOUCH-SUPPORT.md`), track (обычный, не `small`) — выводы r1 остаются в
силе без повторной проверки.
- Раздел «Принятые предположения» §19 — признан техническими допущениями, не
подменой продуктового решения; дельта этого раздела не трогает.
- Mutation-gate §14 (два id вместо трёх из issue) — признан техническим
решением автора о стратегии тестов, не продуктовой догадкой; дельта этого
раздела не трогает.
## Вывод
M1 закрыт по существу, новых находок дельта не создала. ТЗ готово к статусу
«Готово к разработке» с точки зрения ревью ТЗ (формальный перевод статуса —
не предмет этого документа).
+364
View File
@@ -0,0 +1,364 @@
# Issue #226 — Entity-marker не дублируется родительским HA-устройством
- Дата: 2026-08-20
- Тип: bug · приоритет P1 · ценность 8/10 · сложность/риск 5/10
- Issue: [#226](https://github.com/Matysh/houseplan-card/issues/226)
- Ветка: `issue/226-entity-parent-dedup`
- Статус ТЗ: на ревью
Канонические документы: `docs/SCOPE.md`, `docs/FILTERING.md`,
`docs/CONFIG-COMPATIBILITY.md`, `docs/TOUCH-SUPPORT.md`,
`docs/USER-GUIDE.ru.md`, `docs/USER-GUIDE.md`.
## 1. Сценарий и персона
Администратор включает в редакторе устройств показ отдельных сущностей и
размещает `entity:X`, принадлежащую HA-устройству `D`. Например, интеграция
Switch as X создаёт `light.room` поверх физического реле `D`.
Сейчас House Plan показывает и явно размещённую сущность, и автоматически
обнаруженное родительское устройство. Две иконки относятся к одному физическому
объекту, могут по-разному выглядеть и реагировать на клик, а свет дважды входит
в визуальное представление. Это нарушает J1, J3, J4 и J6.
## 2. Что человек увидит до и после
**До исправления:** после размещения `entity:X` рядом остаётся auto-marker
устройства `D`. Если у устройства несколько сущностей, auto-marker продолжает
использовать и уже вынесенную `X`, и остальные сущности.
**После исправления:** явно размещённая `entity:X` принадлежит только своему
marker и вычитается из автоматического состава `D`:
- если у `D` после вычитания остаются активные видимые сущности, House Plan
показывает ровно один остаточный auto-marker, построенный только из них;
- если остаток пуст, auto-marker `D` не показывается;
- несколько явно размещённых сущностей одного устройства остаются отдельными
markers; auto-marker получает только незанятый остаток;
- явно сохранённые `device:D` и `entity:X` не подавляют друг друга: это
осознанная конфигурация пользователя, поэтому на плане остаются оба markers.
## 3. Зафиксированные продуктовые решения
1. **Частичное владение.** Entity-marker забирает из auto-device только свою
сущность. Наличие одной entity не подавляет весь родительский marker, пока
существует видимый активный остаток.
2. **`hidden_by` не является глобальным фильтром.** Нетронутый auto-device и
явно сохранённый `device:D` сохраняют действующий функциональный resolver,
включая скрытый интеграцией `cover.*` из #94. Это защищает шторы от выбора
служебного switch как основного состояния и действия.
3. **Hidden sibling не удерживает остаток.** Сущность с HA `hidden_by` (в
нормализованном frontend registry — `reg.hidden`) не считается основанием
для остаточного auto-marker. При этом явно сохранённый `entity:X` разрешён и
отображается по действующим правилам даже при `hidden_by`. Следствие принято
осознанно: если пользователь вынес видимую вспомогательную entity, а у
родителя остался только HA-hidden функциональный sibling, auto-marker
родителя исчезает. Чтобы сохранить полное устройство рядом с отдельной
entity, пользователь явно размещает `device:D` — тогда действует решение 4.
4. **Явная конфигурация сильнее автоматической.** Сохранённый `device:D` не
удаляет явно сохранённые entity-markers того же устройства. В этом случае
состав `device:D` остаётся полным, как сейчас.
5. **Tombstone не владеет сущностью.** `entity:X` с `removed:true` подавляет
только отдельный plan binding. Она не вычитается из живого родительского
устройства и не подавляет его auto-marker.
6. **Скрытие marker — сохранённое владение.** Живой entity-marker с
`marker.hidden:true` продолжает занимать `X`: скрытие не должно возвращать
эту сущность внутрь видимого auto-device. В Device editor он остаётся ghost
по действующему контракту.
## 4. Границы задачи
### Входит
- единая модель ownership между `entity:X` и родительским `device:D`;
- остаточный состав auto-device во всех потребителях `buildDevices()`;
- согласованное поведение `seedHiddenBindings()`, чтобы seeder не создавал
hidden stub для родителя, у которого после вычитания нет пригодного остатка;
- регрессии state/icon/action, света/Glow, LQI и редакторского preview;
- unit, browser smoke и mutation guards;
- документация RU/EN и оба changelog.
### Не входит
- автоматическое слияние или удаление двух **явно** сохранённых markers;
- изменение выбора primary entity у обычного полного device-marker;
- глобальное исключение HA `hidden_by` из функционального resolver;
- изменение семантики `disabled_by`, tombstones, light groups или ручного
скрытия;
- очистка сохранённых layout-позиций, новый config field, backend API,
миграция или настройка в UI.
## 5. Термины и множества
Для одной проекции `buildDevices()` вводятся:
- `placedEntityIds` — `ref` всех живых (`removed !== true`) markers с валидной
привязкой `entity:<ref>`, включая `marker.hidden:true`;
- `placedDeviceIds` — `ref` всех markers `device:<ref>` по действующему
exact-binding контракту, включая tombstone;
- `eligibleDeviceEntities(D)` — активные registry entities устройства из
текущей `activeRegistryHass()`;
- `visibleResidual(D)` — `eligibleDeviceEntities(D)` без `placedEntityIds` и
без HA-hidden сущностей (`reg.hidden === true`).
Связь `entity → device` читается из полного авторитетного/cached registry
snapshot, а не выводится из имени entity или текущего state. Если registry не
даёт `device_id`, сущность считается самостоятельной и не влияет на устройство.
После следующего авторитетного snapshot проекция пересчитывается без записи
конфига.
## 6. Алгоритм построения
1. Один раз до циклов построить ownership по живым entity-markers. Нельзя
делать вложенный поиск всех markers для каждого устройства: бюджет остаётся
`O(markers + entities + devices)`.
2. Для каждого auto-discovered `D` сначала сохранить действующие проверки
Area, service entry, exact `device:D`, binding status и legacy filtering.
3. Если существует явно сохранённый `device:D`, auto-marker по-прежнему не
строится; явные entity-markers обрабатываются независимо на шаге 3 текущего
`buildDevices()`.
4. Для действительно автоматического `D` передать во все вычисления marker
только `visibleResidual(D)`: domain/icon/primary/state/temp/humidity,
`entities`, light/Glow и action не должны видеть вынесенную `X`.
5. Если `visibleResidual(D)` пуст, auto-marker не добавляется. Наличие только
hidden siblings не считается остатком.
6. `allEntities` остаточного auto-marker должно описывать тот же остаточный
binding, а не возвращать занятую `X` через side-channel доступности,
презентации или диалога. Полный список сохраняется только у явного
`device:D`.
7. Явные entity-markers строятся существующим exact resolver без изменений;
entity без `device_id` (helper/group/template) остаётся самостоятельной.
8. `seedHiddenBindings()` использует ту же ownership-функцию и остаточный
критерий. Он не материализует `device:D` stub, если после вычитания
размещённых entity и HA-hidden siblings у `D` ничего не осталось.
Ownership/helper должен быть общим для `buildDevices()` и seeder либо иметь
contract test, доказывающий идентичную семантику. Дублирующиеся реализации
правила запрещены.
## 7. Состояния, действия и агрегаты
- Entity-marker получает icon/state/value/action только от своей точной `X`.
- Остаточный auto-marker получает их только от `visibleResidual(D)`.
- Вынесенная light/switch не может второй раз попасть в room light count,
light fill или Glow через auto-device. Остальные сущности остатка продолжают
работать.
- LQI и availability остаточного marker вычисляются по остаточному составу.
Явный полный `device:D` сохраняет текущую device-wide семантику.
- Hidden plan-marker не рисуется и не даёт видимый свет по `docs/FILTERING.md`,
но продолжает владеть entity, поэтому родитель не возвращает её на план.
- `removed:true` остаётся binding-scoped: после удаления отдельного marker
сущность снова доступна полному auto-device.
## 8. `hidden_by` и защита #94
Изменять `activeRegistryHass()`, `entitiesByDevice()` как глобальный HA-hidden
фильтр или `resolvedDeviceStateEntities()` для всех устройств запрещено.
Обязательная регрессия: у нетронутой шторы с hidden integration `cover.*` и
видимым служебным `switch.*` auto/device-marker сохраняет cover-first
functional state/icon/toggle из #94. Только **остаточный auto-marker**, возникший
после явного entity-marker, применяет правило «hidden siblings не удерживают
остаток». Поэтому при явном marker на видимый `switch.reverse_direction` и
единственном остатке в виде hidden `cover.curtain` автоматическая штора
исчезает; это ожидаемое следствие Q2, а не обход cover-first. Явно сохранённый
`device:D` по-прежнему показывает полную штору и может сосуществовать с этим
entity-marker.
## 9. Lifecycle и совместимость
- Схема `ServerConfig`, backend validation, storage version и wire protocol не
меняются.
- Существующие планы исправляются проекцией при следующем render/reload;
конфиг не переписывается.
- Лишний auto-marker не имеет собственного marker record. Его старый layout key
остаётся инертным и не очищается: удаление могло бы потерять выбранную
пользователем позицию при последующем возвращении устройства.
- Старый frontend продолжит показывать старый дубль; downgrade не повреждает
данные. Новый frontend восстанавливает исправленную проекцию без миграции.
- Ограниченный или временно неавторитетный registry не даёт права угадывать
parent по entity id. Используется последний доступный authoritative cached
relation; без неё поведение безопасно возвращается к exact binding и
самовосстанавливается после registry refresh.
## 10. Поверхности
Источник поведения — общий `buildDevices()`, поэтому контракт обязателен для:
- полного View и kiosk;
- Device editor и его unsaved preview через `deviceFromMarkerDraft()`;
- `houseplan-space-card`;
- room light/fill/Glow, LQI и climate/value consumers набора устройств;
- desktop mouse и touch tap. Геометрия hit-area и жесты не меняются.
i18n-ключи, backend и отдельная mobile-компоновка не требуются.
## 11. Изменяемые файлы и модули
Ожидаемый минимум:
- `src/devices.ts` — ownership, residual projection, `buildDevices()` и seeder;
- `test/devices.test.mjs` — матрица unit-контрактов;
- `demo/smoke_device_entity_parent_dedup.mjs` и package/CI registration, если
существующий smoke нельзя расширить без смешения скоупа;
- `scripts/mutation-gate.mjs` и `test/mutation-gate.test.mjs` — guards;
- `docs/FILTERING.md`, `docs/USER-GUIDE.md`, `docs/USER-GUIDE.ru.md`,
`docs/TESTING.md`;
- `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`;
- generated bundles — только штатным `npm run build` в implementation commit.
Список может сузиться по реализации, но новый product/config модуль требует
возврата ТЗ на ревью.
## 12. Матрица обязательных тестов
1. Единственная entity `X` устройства `D`, остатка нет → только marker `X`.
2. `X` размещена, у `D` есть видимая `Y` → marker `X` плюс один auto-marker
`D`, причём `D.entities/allEntities/primary` не содержат `X`.
3. Размещены `X` и `Y`, остатка нет → два entity-markers, auto `D` отсутствует.
4. Явные `entity:X` и `device:D` → оба явных markers; `device:D` сохраняет
полный состав, третьего auto-marker нет.
5. `entity:X` с `marker.hidden:true` → auto `D` не получает `X`; ghost доступен
только по действующему editor contract.
6. Tombstone `entity:X, removed:true` → auto `D` существует и по-прежнему
содержит `X`.
7. Явная HA-hidden `entity:X` работает как exact marker; hidden sibling `Y` не
создаёт пустой/бесполезный остаточный auto-marker.
8. Нетронутая штора #94 с hidden `cover.*` сохраняет cover-first icon/state/
action у полного auto/device marker.
9. HA-disabled entity-marker с известным `device_id` не позволяет занятой
сущности вернуться в активный остаток родителя; ghost/lifecycle остаётся
прежним.
10. Helper/group/template без `device_id` → одна exact entity-строка, другие
устройства не затронуты.
11. Auto light group и exact group marker сохраняют текущую дедупликацию.
12. Seeder не создаёт parent stub при пустом остатке и остаётся идемпотентным.
13. Registry refresh, добавляющий/удаляющий sibling или меняющий hidden status,
перестраивает один остаточный marker без config write.
14. Граница #94: размещён видимый `entity:switch.reverse_direction`, а
единственный sibling `cover.curtain` имеет HA-hidden status → остаётся
только entity-marker, auto-marker шторы отсутствует; добавление явного
`device:D` возвращает полную cover-first штору рядом с entity-marker.
15. Switch as X browser fixture: отдельная лампа и остаток (если он есть)
дают ожидаемое число DOM markers; click entity-marker вызывает точную
entity action, а light/Glow считают `X` один раз.
## 13. Acceptance criteria
1. **AC1 — нет полного дубля.** Размещённая `entity:X` исключается из состава
auto-device `D`; при пустом остатке `D` отсутствует. **Доказательство:** unit
cases 1/3 и mutation guard основного residual predicate.
2. **AC2 — частичный остаток.** При наличии `Y` остаётся ровно один auto-marker,
все его state/icon/action/availability поля построены без `X`.
**Доказательство:** unit case 2 с проверкой результата и primary/action.
3. **AC3 — явная асимметрия.** Entity tombstone не вычитает `X`, а явные
`device:D + entity:X` сосуществуют. **Доказательство:** unit cases 4/6.
4. **AC4 — hidden-контракты.** Marker hidden, HA hidden и HA disabled следуют
решениям §§3, 7 и 8; штора #94 не регрессирует. **Доказательство:** unit cases
5/7/8/9/14, включая явную проверку hidden-only остатка и восстановления
полного cover-first marker через сохранённый `device:D`.
5. **AC5 — standalone и групповые bindings.** Entity без parent и light group
не меняют поведение. **Доказательство:** unit cases 10/11 и существующие
device/group tests.
6. **AC6 — seeder parity.** Seeder использует ту же ownership semantics и не
создаёт новый скрытый parent stub для пустого остатка. **Доказательство:**
unit case 12 и mutation guard seeder predicate.
7. **AC7 — все renderers и действия.** Full View, kiosk/touch, Device preview и
static card получают одну проекцию; Switch as X рисуется и действует без
двойного light/Glow contribution. **Доказательство:** browser smoke case 15,
shared projection unit и code review.
8. **AC8 — динамический registry.** Изменение sibling/hidden metadata
пересчитывает остаток без записи конфига и без исключения/ошибки.
**Доказательство:** registry mutation unit/smoke case 13.
9. **AC9 — совместимость.** Нет schema/backend/i18n migration, layout не
очищается, unknown config siblings не затрагиваются. **Доказательство:** diff
review, config round-trip regressions, typecheck и build.
10. **AC10 — release artifacts.** Оба changelog и RU/EN user/filter/testing docs
описывают ownership; generated bundles идентичны. **Доказательство:** docs
check, bundle hash check и review diff.
## 14. Mutation guards
Минимум два мутанта в `scripts/mutation-gate.mjs`:
| id | Поломка | Guard |
|---|---|---|
| `entity-marker-kept-in-parent-device` | не вычитать `placedEntityIds` из residual `D` | AC1/AC2 unit |
| `entity-marker-parent-seeded` | вернуть seeder к exact `device:D` claimed без residual ownership | AC6 unit |
Unit отдельно обязан падать, если tombstone ошибочно начать считать живым
ownership, или если явный `device:D` начать обрезать по entity-markers.
## 15. Проверки реализации и ревью
Implementation loop:
```text
npm run typecheck
npm test
npm run build
```
Перед бетой по действующему процессу:
- targeted Switch as X browser smoke на desktop и touch/kiosk viewport;
- `npm run golden:verify` для проверки отсутствия непредусмотренной визуальной
дельты; новый golden не обязателен, потому что геометрия marker не меняется;
- performance gate: синтетический большой registry не должен получить
`markers × devices` обход;
- штатные smoke/performance/security и проверка SHA-256 трёх bundles.
Автор не принимает новые golden baselines самостоятельно.
## 16. Release-артефакты
- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` в том же пользовательском
implementation commit (`User-Visible: yes`);
- `docs/USER-GUIDE.md` и `docs/USER-GUIDE.ru.md`: выбор Entity и судьба
родительского auto-marker;
- `docs/FILTERING.md`: разница live entity-marker и binding tombstone, а также
ограниченный `hidden_by` residual contract;
- `docs/TESTING.md`: автоматические доказательства и mutation ids;
- generated `dist`, demo и integration bundles после build, с одинаковым hash;
- screenshots manifest/PNG меняются только если штатный capture действительно
затронут. Само исправление не требует нового эталонного изображения.
## 17. Производительность, безопасность и touch
- Временная и пространственная сложность ownership — линейная; запрещён поиск
markers внутри device/entity loops.
- Новых HA service calls, прав, внешних URL, HTML или пользовательского ввода
нет; security surface не меняется.
- Touch: View и kiosk release-blocking. Количество markers и точный tap target
должны совпадать с desktop; drag/editor остаётся best effort по текущему
`docs/TOUCH-SUPPORT.md`.
## 18. Откат и риски
Откат — один implementation commit #226 вместе с тестами, документацией,
changelog и generated bundles. Данные не мигрируют, поэтому отдельного rollback
данных нет; старые инертные layout keys сохраняются.
Риски:
1. Частичный auto-device может случайно получить `X` через `allEntities`, primary
или агрегацию, хотя `entities` уже обрезан.
2. Глобальный hidden filter способен повторно сломать шторы #94.
3. Seeder может материализовать скрытый explicit device и превратить
автоматический дубль в постоянную конфигурацию.
4. Неправильная трактовка tombstone может удалить полезную entity из parent.
5. Вложенный поиск ownership ухудшит cold render на больших registry.
Каждый риск закрыт соответствующим AC и тестом выше.
## 19. Принятые предположения
1. «Видимая entity» в остатке означает HA registry entity без `reg.hidden`, а не
видимость plan-marker.
2. Живой hidden plan-marker остаётся пользовательским ownership; `removed:true`
— нет.
3. При явной паре `device:D + entity:X` возможен осознанный повтор состояния и
света; автоматическая дедупликация явной конфигурации вне скоупа.
4. Инертный layout key не является пользовательски видимым объектом и не
требует очистки.
5. Дополнительных настроек, предупреждений и переводов не требуется.
+1
View File
@@ -60,6 +60,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#218](https://github.com/Matysh/houseplan-card/issues/218) Floating-point шум комнаты не гасит Glow пространства | [218-glow-floor-geometry.md](218-glow-floor-geometry.md) |
| [#219](https://github.com/Matysh/houseplan-card/issues/219) Единая палитра замков и glyph на оранжевых подложках | [219-lock-orange-palette.md](219-lock-orange-palette.md) |
| [#205](https://github.com/Matysh/houseplan-card/issues/205) Продолжение следа после короткой остановки пылесоса | [205-vacuum-trail-resume-grace.md](205-vacuum-trail-resume-grace.md) |
| [#226](https://github.com/Matysh/houseplan-card/issues/226) Entity-marker не дублируется родительским HA-устройством | [226-entity-parent-dedup.md](226-entity-parent-dedup.md) |
## P2
+30
View File
@@ -418,6 +418,36 @@ export const MUTANTS = [
replace: " if (false && input.type === 'passage') {",
}],
},
{
id: 'entity-marker-kept-in-parent-device',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="entity marker owns its entity|partial auto parent" '
+ 'test/devices.test.mjs',
because: 'an explicitly placed entity must be removed from the residual automatic parent '
+ 'so state, action and light projection cannot render the same HA channel twice',
patches: [{
file: 'src/devices.ts',
find: ' !owned.has(entityId) && !hass?.entities?.[entityId]?.hidden),',
replace: ' !hass?.entities?.[entityId]?.hidden),',
}],
},
{
id: 'entity-marker-parent-seeded',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="seedHiddenBindings: entity ownership" '
+ 'test/devices.test.mjs',
because: 'the first-run seeder must share residual ownership with buildDevices or it can '
+ 'materialise the removed automatic duplicate as a permanent hidden device marker',
patches: [{
file: 'src/devices.ts',
find: ' const entsBy = entitiesByDevice(h);\n'
+ ' const ownership = entityMarkerOwnership(markers, fullHass);\n'
+ ' const marked = new Set(markers.map((m) => m.binding));',
replace: ' const entsBy = entitiesByDevice(h);\n'
+ ' const ownership = entityMarkerOwnership([], fullHass);\n'
+ ' const marked = new Set(markers.map((m) => m.binding));',
}],
},
{
id: 'manual-room-device-area-fallback',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+61 -4
View File
@@ -58,6 +58,53 @@ function allEntitiesByDevice(hass: any): Record<string, string[]> {
return map;
}
interface EntityMarkerOwnership {
byDevice: ReadonlyMap<string, ReadonlySet<string>>;
}
/**
* Live entity markers own their exact entity inside an auto-discovered parent.
* Tombstones deliberately do not: removing a standalone binding must return
* that entity to the still-live HA device (docs/FILTERING.md).
*/
function entityMarkerOwnership(markers: readonly Marker[], fullHass: any): EntityMarkerOwnership {
const mutableByDevice = new Map<string, Set<string>>();
for (const marker of markers) {
if (marker?.removed === true) continue;
const binding = marker?.binding || '';
if (!binding.startsWith('entity:')) continue;
const entityId = binding.slice('entity:'.length);
if (!entityId) continue;
const deviceId = fullHass?.entities?.[entityId]?.device_id;
if (!deviceId) continue;
const owned = mutableByDevice.get(deviceId) || new Set<string>();
owned.add(entityId);
mutableByDevice.set(deviceId, owned);
}
return { byDevice: mutableByDevice };
}
/**
* An untouched automatic device keeps its complete functional resolver,
* including integration-hidden cover entities (#94). Once one of its entities
* is placed explicitly, the residual auto marker contains only active,
* HA-visible, unclaimed siblings.
*/
function residualAutoDeviceEntities(
hass: any,
deviceId: string,
entityIds: readonly string[],
ownership: EntityMarkerOwnership,
): { partial: boolean; entityIds: string[] } {
const owned = ownership.byDevice.get(deviceId);
if (!owned?.size) return { partial: false, entityIds: [...entityIds] };
return {
partial: true,
entityIds: entityIds.filter((entityId) =>
!owned.has(entityId) && !hass?.entities?.[entityId]?.hidden),
};
}
export function domainOfDevice(hass: any, dev: any, entIds: string[]): string {
if (dev.identifiers?.[0]?.[0]) return dev.identifiers[0][0];
for (const eid of entIds) {
@@ -957,6 +1004,7 @@ export function seedHiddenBindings(ctx: Omit<BuildCtx, 'showAll' | 'loc'>): stri
const baseHass = ctx.hass;
const registry = ctx.registry || haRegistrySnapshot(baseHass);
const h = activeRegistryHass(baseHass, registry);
const fullHass = fullRegistryHass(baseHass, registry);
const { areaToSpace, markers, settings, excluded, iconRules } = ctx;
const groupLights = settings.group_lights !== false;
const removed = removedPlanBindings(markers);
@@ -964,6 +1012,7 @@ export function seedHiddenBindings(ctx: Omit<BuildCtx, 'showAll' | 'loc'>): stri
.filter((g) => !isRemovedPlanEntity(h, g.eid, removed));
const groupedAreas = new Set(groups.map((g) => g.area));
const entsBy = entitiesByDevice(h);
const ownership = entityMarkerOwnership(markers, fullHass);
const marked = new Set(markers.map((m) => m.binding));
const out: string[] = [];
for (const dev of Object.values<any>(h.devices)) {
@@ -974,7 +1023,9 @@ export function seedHiddenBindings(ctx: Omit<BuildCtx, 'showAll' | 'loc'>): stri
if (resolveHaBindingStatus(baseHass, 'device:' + dev.id, registry).kind !== 'active') continue;
// An entity tombstone suppresses that standalone binding, not the same
// entity as data belonging to a still-live parent device.
const entIds = entsBy[dev.id] || [];
const residual = residualAutoDeviceEntities(h, dev.id, entsBy[dev.id] || [], ownership);
if (residual.partial && !residual.entityIds.length) continue;
const entIds = residual.entityIds;
const dom = domainOfDevice(h, dev, entIds);
let nonPhysical =
excluded.has(dom)
@@ -1027,6 +1078,7 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
const groupedAreas = new Set(groups.map((g) => g.area));
const entsBy = entitiesByDevice(h);
const allEntsBy = allEntitiesByDevice(fullHass);
const ownership = entityMarkerOwnership(markers, fullHass);
const claimed = new Set<string>();
for (const m of markers) {
const [kind, ref] = m.binding.split(':');
@@ -1046,7 +1098,12 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
if (bindingStatus.kind !== 'active') continue;
const marker = markerFor('device', dev.id);
if (marker && marker.hidden && !settings.filter_seeded) continue; // legacy: dropped entirely
const entIds = entsBy[dev.id] || [];
const residual = residualAutoDeviceEntities(h, dev.id, entsBy[dev.id] || [], ownership);
if (residual.partial && !residual.entityIds.length) continue;
const entIds = residual.entityIds;
const itemBindingStatus = residual.partial
? { kind: 'active' as const, enabledEntityIds: entIds, allEntityIds: entIds }
: bindingStatus;
const dom = domainOfDevice(h, dev, entIds);
// LEGACY runtime filter: only while the config is not yet materialised
// (docs/FILTERING.md). A seeded config hides by explicit marker flags.
@@ -1074,8 +1131,8 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
space: areaToSpace[area],
icon,
entities: entIds,
allEntities: bindingStatus.allEntityIds,
bindingStatus,
allEntities: itemBindingStatus.allEntityIds,
bindingStatus: itemBindingStatus,
bindingKind: 'device',
bindingRef: dev.id,
pdfs: [],
+270
View File
@@ -119,6 +119,223 @@ test('buildDevices: claimed device is replaced by its marker (metadata applied)'
assert.equal(it.bindingKind, 'device');
});
test('buildDevices: entity marker owns its entity and removes an empty auto parent', () => {
const h = mkHass({
devices: { combo: dev('combo', 'Combo sensor', 'Sensor', 'living') },
entities: {
'sensor.combo_temp': {
entity_id: 'sensor.combo_temp', device_id: 'combo', platform: 'demo',
},
},
states: {
'sensor.combo_temp': {
state: '22', attributes: { device_class: 'temperature', friendly_name: 'Combo temperature' },
},
},
});
const result = buildDevices(baseCtx(h, {
markers: [{ id: 'temp-marker', binding: 'entity:sensor.combo_temp' }],
}));
assert.deepEqual(result.map((item) => item.id), ['temp-marker']);
assert.equal(result[0].bindingKind, 'entity');
});
test('buildDevices: partial auto parent contains only visible unclaimed siblings', () => {
const h = mkHass({
devices: { combo: dev('combo', 'Combo relay', 'Relay', 'living') },
entities: {
'light.combo_main': { entity_id: 'light.combo_main', device_id: 'combo', platform: 'demo' },
'switch.combo_aux': { entity_id: 'switch.combo_aux', device_id: 'combo', platform: 'demo' },
},
states: {
'light.combo_main': { state: 'on', attributes: { friendly_name: 'Main light' } },
'switch.combo_aux': { state: 'off', attributes: { friendly_name: 'Aux relay' } },
},
});
const result = buildDevices(baseCtx(h, {
settings: { group_lights: false },
markers: [{ id: 'main-marker', binding: 'entity:light.combo_main' }],
}));
const exact = result.find((item) => item.id === 'main-marker');
const parent = result.find((item) => item.id === 'combo');
assert.ok(exact);
assert.ok(parent);
assert.deepEqual(parent.entities, ['switch.combo_aux']);
assert.deepEqual(parent.allEntities, ['switch.combo_aux']);
assert.deepEqual(parent.bindingStatus, {
kind: 'active', enabledEntityIds: ['switch.combo_aux'], allEntityIds: ['switch.combo_aux'],
});
assert.equal(parent.primary, 'switch.combo_aux');
assert.deepEqual(
resolvedLightSources(h, result).map((source) => source.eid).sort(),
['light.combo_main'],
'the claimed light contributes once and never leaks through the parent',
);
});
test('buildDevices: several entity markers split one device without an auto duplicate', () => {
const h = mkHass({
devices: { climate_box: dev('climate_box', 'Climate box', 'Sensor', 'living') },
entities: {
'sensor.box_temp': { entity_id: 'sensor.box_temp', device_id: 'climate_box', platform: 'demo' },
'sensor.box_hum': { entity_id: 'sensor.box_hum', device_id: 'climate_box', platform: 'demo' },
},
states: {
'sensor.box_temp': { state: '21', attributes: { device_class: 'temperature' } },
'sensor.box_hum': { state: '50', attributes: { device_class: 'humidity' } },
},
});
const result = buildDevices(baseCtx(h, { markers: [
{ id: 'temp', binding: 'entity:sensor.box_temp' },
{ id: 'hum', binding: 'entity:sensor.box_hum' },
] }));
assert.deepEqual(result.map((item) => item.id), ['temp', 'hum']);
assert.ok(!result.some((item) => item.bindingRef === 'climate_box'));
});
test('buildDevices: explicit device and child entity markers coexist intentionally', () => {
const h = mkHass({
devices: { combo: dev('combo', 'Combo relay', 'Relay', 'living') },
entities: {
'switch.combo': { entity_id: 'switch.combo', device_id: 'combo', platform: 'demo' },
'sensor.combo_power': { entity_id: 'sensor.combo_power', device_id: 'combo', platform: 'demo' },
},
states: {
'switch.combo': { state: 'on', attributes: {} },
'sensor.combo_power': { state: '20', attributes: { device_class: 'power' } },
},
});
const result = buildDevices(baseCtx(h, { markers: [
{ id: 'device-marker', binding: 'device:combo' },
{ id: 'power-marker', binding: 'entity:sensor.combo_power' },
] }));
const explicitDevice = result.find((item) => item.id === 'device-marker');
const exact = result.find((item) => item.id === 'power-marker');
assert.equal(result.length, 2);
assert.ok(explicitDevice && exact);
assert.deepEqual(explicitDevice.entities.sort(), ['sensor.combo_power', 'switch.combo']);
assert.deepEqual(explicitDevice.allEntities.sort(), ['sensor.combo_power', 'switch.combo']);
});
test('buildDevices: hidden entity marker still owns its entity inside the parent', () => {
const h = mkHass({
devices: { combo: dev('combo', 'Combo relay', 'Relay', 'living') },
entities: {
'switch.combo': { entity_id: 'switch.combo', device_id: 'combo', platform: 'demo' },
},
states: { 'switch.combo': { state: 'on', attributes: {} } },
});
const result = buildDevices(baseCtx(h, {
settings: { filter_seeded: true },
markers: [{ id: 'hidden-switch', binding: 'entity:switch.combo', hidden: true }],
}));
assert.equal(result.length, 1);
assert.equal(result[0].id, 'hidden-switch');
assert.equal(result[0].hidden, true);
assert.ok(!result.some((item) => item.id === 'combo'));
});
test('buildDevices: hidden-only curtain residual disappears but explicit device stays cover-first', () => {
const h = mkHass({
devices: { curtain: dev('curtain', 'Living curtain', 'Roller shade E1', 'living') },
entities: {
'cover.curtain': {
entity_id: 'cover.curtain', device_id: 'curtain', platform: 'demo', hidden: true,
},
'switch.curtain_reverse_direction': {
entity_id: 'switch.curtain_reverse_direction', device_id: 'curtain', platform: 'demo',
},
},
states: {
'cover.curtain': { state: 'opening', attributes: { device_class: 'curtain' } },
'switch.curtain_reverse_direction': { state: 'off', attributes: {} },
},
});
const untouched = buildDevices(baseCtx(h));
assert.equal(untouched.length, 1);
assert.equal(untouched[0].primary, 'cover.curtain', 'untouched #94 resolver remains cover-first');
const marker = { id: 'reverse', binding: 'entity:switch.curtain_reverse_direction' };
const split = buildDevices(baseCtx(h, { markers: [marker] }));
assert.deepEqual(split.map((item) => item.id), ['reverse']);
const explicit = buildDevices(baseCtx(h, { markers: [
marker, { id: 'curtain-device', binding: 'device:curtain' },
] }));
assert.equal(explicit.length, 2);
assert.equal(explicit.find((item) => item.id === 'curtain-device').primary, 'cover.curtain');
});
test('buildDevices: disabled entity marker owns its known parent relation', () => {
const devices = { combo: dev('combo', 'Combo relay', 'Relay', 'living') };
const entities = {
'switch.combo_disabled': {
entity_id: 'switch.combo_disabled', device_id: 'combo', platform: 'demo', disabled_by: 'user',
},
'switch.combo_live': {
entity_id: 'switch.combo_live', device_id: 'combo', platform: 'demo', disabled_by: null,
},
};
const h = mkHass({
devices,
entities,
states: {
'switch.combo_disabled': { state: 'on', attributes: {} },
'switch.combo_live': { state: 'off', attributes: {} },
},
});
const registry = {
revision: 1, authoritative: true, access: 'full', devices, entities, lastSuccess: 1,
};
const result = buildDevices(baseCtx(h, {
registry,
settings: { filter_seeded: true },
markers: [{ id: 'disabled-marker', binding: 'entity:switch.combo_disabled' }],
}));
const parent = result.find((item) => item.id === 'combo');
const ghost = result.find((item) => item.id === 'disabled-marker');
assert.deepEqual(parent.entities, ['switch.combo_live']);
assert.deepEqual(parent.allEntities, ['switch.combo_live']);
assert.equal(ghost.bindingStatus.kind, 'ha_disabled');
assert.equal(ghost.hidden, true);
});
test('buildDevices: registry hidden sibling changes rebuild the residual without config writes', () => {
const devices = { combo: dev('combo', 'Combo relay', 'Relay', 'living') };
const visibleEntities = {
'switch.combo_claimed': {
entity_id: 'switch.combo_claimed', device_id: 'combo', platform: 'demo', disabled_by: null,
},
'switch.combo_sibling': {
entity_id: 'switch.combo_sibling', device_id: 'combo', platform: 'demo', disabled_by: null,
},
};
const h = mkHass({
devices,
entities: visibleEntities,
states: {
'switch.combo_claimed': { state: 'on', attributes: {} },
'switch.combo_sibling': { state: 'off', attributes: {} },
},
});
const marker = { id: 'claimed', binding: 'entity:switch.combo_claimed' };
const snapshot = (revision, hidden) => ({
revision, authoritative: true, access: 'full', devices,
entities: {
...visibleEntities,
'switch.combo_sibling': { ...visibleEntities['switch.combo_sibling'], hidden },
},
lastSuccess: revision,
});
const hidden = buildDevices(baseCtx(h, { registry: snapshot(1, true), markers: [marker] }));
const visible = buildDevices(baseCtx(h, { registry: snapshot(2, false), markers: [marker] }));
assert.deepEqual(hidden.map((item) => item.id), ['claimed']);
assert.deepEqual(visible.map((item) => item.id), ['combo', 'claimed']);
assert.deepEqual(visible.find((item) => item.id === 'combo').entities, ['switch.combo_sibling']);
assert.deepEqual(marker, { id: 'claimed', binding: 'entity:switch.combo_claimed' });
});
test('buildDevices: manual room without area overrides registry area for device binding', () => {
const h = mkHass({ devices: { washer: dev('washer', 'Washer', 'WM-1', 'living') } });
const [item] = buildDevices(baseCtx(h, {
@@ -1572,6 +1789,59 @@ test('seedHiddenBindings: non-physical devices in bound areas, unmarked only', (
assert.deepEqual(seedHiddenBindings(marked), [], 'marked and deleted devices are never revisited');
});
test('seedHiddenBindings: entity ownership uses the same visible residual as buildDevices', () => {
const devices = { bridge: dev('bridge', 'Z2M Bridge', 'Bridge', 'living') };
const states = {
'sensor.bridge_claimed': { state: 'ok', attributes: {} },
'sensor.bridge_sibling': { state: 'ok', attributes: {} },
};
const makeCtx = (entities, markers) => ({
hass: mkHass({ devices, entities, states }),
areaToSpace: { living: 'f1' }, markers, settings: {},
excluded: new Set(['hacs']), firstSpaceId: 'f1',
});
const claimed = {
entity_id: 'sensor.bridge_claimed', device_id: 'bridge', platform: 'demo',
};
const marker = { id: 'claimed', binding: 'entity:sensor.bridge_claimed' };
assert.deepEqual(
seedHiddenBindings(makeCtx({ 'sensor.bridge_claimed': claimed }, [marker])),
[],
'an empty residual must not become a hidden parent stub',
);
const visibleSibling = {
entity_id: 'sensor.bridge_sibling', device_id: 'bridge', platform: 'demo',
};
assert.deepEqual(
seedHiddenBindings(makeCtx({
'sensor.bridge_claimed': claimed,
'sensor.bridge_sibling': visibleSibling,
}, [marker])),
['device:bridge'],
'a visible unclaimed sibling keeps one residual parent',
);
assert.deepEqual(
seedHiddenBindings(makeCtx({
'sensor.bridge_claimed': claimed,
'sensor.bridge_sibling': { ...visibleSibling, hidden: true },
}, [marker])),
[],
'a hidden-only sibling does not keep the residual parent',
);
assert.deepEqual(
seedHiddenBindings(makeCtx(
{ 'sensor.bridge_claimed': claimed },
[{ ...marker, removed: true, hidden: true }],
)),
['device:bridge'],
'an entity tombstone does not own the entity inside its live parent',
);
});
test('seeded config: hidden is a flag, not an absence', () => {
const h = mkHass({ devices: {
lamp: dev('lamp', 'Lamp', 'Bulb', 'living'),