Files
houseplan-card/docs/specs/226-entity-parent-dedup.md
T
2026-08-20 18:48:01 +00:00

365 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. Дополнительных настроек, предупреждений и переводов не требуется.