mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 19:28:46 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52c0a6e8b8 | ||
|
|
f151e70a27 | ||
|
|
d7c654d4c3 | ||
|
|
6162da8be9 | ||
|
|
ecf9473359 | ||
|
|
5feabed02b |
File diff suppressed because one or more lines are too long
@@ -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));
|
||||
+136
-136
File diff suppressed because one or more lines are too long
Vendored
+136
-136
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
- Текстовые маркеры устройств снова получают капсульную внешнюю обводку вместо
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -573,6 +573,16 @@ House Plan читает реестры устройств, сущностей и
|
||||
|
||||
Одну и ту же привязку нельзя одновременно использовать в двух маркерах.
|
||||
|
||||
Если отдельная HA-сущность принадлежит устройству, после её размещения этот
|
||||
канал принадлежит точному entity-marker. Автоматический родительский маркер,
|
||||
если он ещё нужен, строится только из оставшихся активных, видимых в HA и не
|
||||
размещённых отдельно сущностей; при пустом остатке он исчезает. Одни только
|
||||
скрытые в HA siblings не удерживают auto-marker на плане. Чтобы одновременно
|
||||
показать точную сущность и полное устройство, явно разместите и `entity:X`, и
|
||||
`device:D` — два явных маркера считаются осознанной конфигурацией. После
|
||||
удаления entity-marker сущность снова доступна автоматическому родителю:
|
||||
binding tombstone не вырезает её из живого устройства HA.
|
||||
|
||||
### Редактор устройств
|
||||
|
||||
| Действие | Результат |
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
@@ -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 действует со второго
|
||||
цикла).
|
||||
@@ -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 закрыт по существу, новых находок дельта не создала. ТЗ готово к статусу
|
||||
«Готово к разработке» с точки зрения ревью ТЗ (формальный перевод статуса —
|
||||
не предмет этого документа).
|
||||
@@ -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. Дополнительных настроек, предупреждений и переводов не требуется.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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: [],
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user