Files
houseplan-card/docs/specs/262-readd-child-entity-after-device-delete.md
T
2026-08-23 16:41:15 +03:00

444 lines
27 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 #262 — повторное добавление entity после удаления родительского устройства
- Issue: [#262](https://github.com/Matysh/houseplan-card/issues/262)
- Ветка: `issue/262-readd-child-entity`
- Статус: реализовано, ожидает код-ревью
- Ревью ТЗ: [`docs/reviews/SPEC-REVIEW-262-r1.md`](../reviews/SPEC-REVIEW-262-r1.md),
зелёный вердикт
- Приоритет: `P2`
- Тип: `bug`
- Трек: обычный
- Связанные задачи: [#161](https://github.com/Matysh/houseplan-card/issues/161),
[#226](https://github.com/Matysh/houseplan-card/issues/226),
[#263](https://github.com/Matysh/houseplan-card/issues/263),
[#104](https://github.com/Matysh/houseplan-card/issues/104)
## 1. Сценарий и персона
**Персона:** администратор дома из `docs/SCOPE.md`, который поддерживает план в
актуальном состоянии (J6).
**Поверхность:** desktop-first редактор устройств, диалог **Добавить** → выбор
HA-привязки → включённый флаг **Показывать сущности**.
**Сценарий:**
1. на плане размещено HA-устройство `device:D` с несколькими дочерними
сущностями;
2. пользователь удаляет устройство с плана, и House Plan сохраняет минимальный
`device:D` tombstone;
3. позже пользователь хочет вернуть не всё устройство, а только одну его
сущность `entity:X`;
4. пользователь открывает **Добавить**, включает **Показывать сущности**, находит
X, сохраняет маркер и размещает его как обычную отдельную entity.
Это не сценарий администрирования реестра HA: устройство и X остаются активными
в Home Assistant. Меняется только состав объектов House Plan.
## 2. Что человек увидит до и после
**До:** удалённое устройство можно вернуть целиком, но ни одну его дочернюю
сущность нельзя найти даже при включённом **Показывать сущности**.
**После:** в том же списке доступны активные дочерние сущности удалённого
устройства; сохранение выбранной сущности возвращает только её, а удалённое
родительское устройство и остальные его сущности сами на план не возвращаются.
Новых кнопок, предупреждений, toast-сообщений и настроек не появляется.
## 3. Подтверждённое воспроизведение
Текущий браузерный smoke `demo/smoke_binding_picker.mjs`, добавленный в #263,
проверяет настоящий `_bindingCandidates()` собранной карточки и фиксирует:
- `device:D` после удаления снова предлагается — работает;
- обычная entity другого живого устройства появляется после включения
**Показывать сущности** — работает;
- `entity:X` удалённого `device:D` отсутствует —
`knownDefect262ChildEntityBlocked: true`.
Автор отчёта подтвердил именно этот третий сценарий. Повторное добавление всего
устройства является обходом, но меняет желаемый состав маркера и потому не
закрывает проблему.
## 4. Причина
### 4.1 Picker разрешает только точный tombstone binding
`removedPlanBindings()` хранит отдельно удалённые device ids и entity ids.
`isRemovedPlanEntity(hass, X, removed)` считает X удалённой, когда удалена либо
сама `entity:X`, либо её родитель `device:D`.
`_bindingCandidates()` допускает исключение для точной строки из набора
`removedBindings`:
- tombstone `device:D` разрешает снова показать `device:D`;
- tombstone `entity:X` разрешает снова показать `entity:X`;
- tombstone `device:D` **не** является точной строкой `entity:X`, поэтому X
отбрасывается общим `isRemovedPlanEntity()`.
### 4.2 Исправления только списка недостаточно
Если принудительно записать `entity:X` поверх текущего конфига:
- `_saveMarker()` удалит только tombstone с тем же exact binding; родительский
`device:D` tombstone останется;
- `buildDevices()` снова вызовет `isRemovedPlanEntity()` и отбросит уже живой
explicit marker X;
- plan/render availability, live text, room-source picker и агрегаты используют
ту же модель удаления и также могут считать X недоступной.
Следовательно, контракт должен охватывать и вход в picker, и runtime-приоритет
живого exact entity-marker над родительским tombstone.
## 5. Scope
### Входит
1. Показ активных допустимых дочерних entity удалённого `device:D` в Add при
включённом **Показывать сущности**.
2. Сохранение выбранной `entity:X` как обычного живого explicit marker.
3. Сохранение `device:D` tombstone после такого действия: родительское устройство
не должно автоматически воскреснуть.
4. Exact live `entity:X` имеет приоритет над tombstone самой X и/или её parent D
во всех обычных marker-level runtime-потребителях.
5. Все не выбранные siblings устройства D и device-level binding D остаются
удалёнными с плана.
6. Согласованность с ownership-контрактом #226 и существующими правилами exact
re-add из #161.
7. Unit, browser smoke, mutation guard, документация и changelog.
### Не входит
1. Автоматическое включение **Показывать сущности** или изменение его текста.
2. Снятие лимита 200 строк и изменение поиска/сортировки picker.
3. Показ HA-disabled, orphaned, registry-hidden или недоказанных entity: для них
продолжают действовать текущие registry-фильтры.
4. Автоматическое восстановление всех дочерних entity при выборе одной.
5. Изменение независимого контракта opening contact/lock из #104: точные ссылки
архитектурных объектов по-прежнему игнорируют marker tombstones.
6. Очистка tombstones, layout или файлов через Optimize.
7. Новая миграция формата конфига или изменение `PLAN_MODEL_VERSION`.
## 6. Контракт поведения
### 6.1 Матрица Add picker
Пусть активная registry-backed `entity:X` принадлежит активному `device:D`.
| Конфиг House Plan | `device:D` в Add | `entity:X` при выключенном флаге | `entity:X` при включённом флаге |
|---|---:|---:|---:|
| ничего не размещено/не удалено | по текущим правилам | нет | да |
| живой `device:D` marker | нет | нет | да, если exact X не занята |
| `device:D` tombstone | да | нет | **да** |
| `entity:X` tombstone, parent жив | по текущим правилам | нет | да |
| живой `entity:X` marker | по текущим правилам #226 | нет | нет |
Флаг **Показывать сущности** остаётся выключенным для нового маркера. Исправление
не маскирует эту существующую UX-ступень.
### 6.2 Транзакция повторного добавления
При сохранении X из состояния с `device:D` tombstone итоговый persisted набор
содержит:
- прежний минимальный `device:D` tombstone;
- ровно один живой marker с binding `entity:X`;
- ни одного `entity:X` tombstone;
- без изменений tombstones/markers остальных binding.
Новый X получает обычную свежую позицию по действующему правилу Add. Позиция,
метаданные и файлы удалённого D не восстанавливаются и не наследуются X.
Если конфиг содержит старый/конкурентный exact `entity:X` tombstone, сохранение X
заменяет его как обычный exact re-add. Parent tombstone при этом сохраняется.
### 6.3 Runtime-приоритет
Для marker-level семантики действует порядок:
1. живой explicit `entity:X` разрешает X;
2. exact `entity:X` tombstone подавляет X, если живого exact marker нет;
3. `device:D` tombstone подавляет X, если живого exact marker нет;
4. обычная registry availability решает, активна ли разрешённая X в HA.
Исключение относится только к exact живой entity-binding. Нельзя трактовать
наличие **одной** восстановленной entity как снятие parent tombstone со всех
siblings.
Разрешённая X должна вести себя как обычный entity-marker во всех потребителях:
- строиться и рендериться во View, kiosk, editors и static card;
- получать live state, value badge, activity, Glow и разрешённые действия по
существующим правилам домена;
- участвовать в room aggregates/source pickers там, где обычная explicit X
участвовала бы без tombstone;
- быть доступной live text и marker controls, если эти потребители ссылаются на
X по существующим правилам.
### 6.4 Что остаётся удалённым
После восстановления X:
- `device:D` не строится автоматически, потому что его tombstone остаётся
точным claim родительского binding;
- `device:D` остаётся доступным в Add для осознанного восстановления целиком;
- любая sibling `entity:Y`, не имеющая собственного живого explicit marker,
остаётся подавленной во всех marker-level runtime-потребителях;
- device-level source `device:D` остаётся удалённым;
- автоматические агрегаты не получают данные Y через ослабленный общий guard.
### 6.5 Дальнейшие действия
1. Если пользователь снова удаляет X, рядом с parent tombstone появляется exact
`entity:X` tombstone; X снова доступна для exact re-add через picker.
2. Если пользователь затем явно добавляет `device:D`, exact device tombstone
заменяется живым device-marker. Живой X сохраняется рядом: два явных marker
считаются осознанной конфигурацией по #226.
3. Если пользователь добавляет несколько дочерних entity D, каждая становится
отдельным explicit marker; parent tombstone остаётся один.
## 7. UX, touch и accessibility
- UI и последовательность действий не меняются.
- Desktop-first контракт Device editor сохраняется.
- Touch-поведение редактора не расширяется и не ухудшается: новых pointer
targets/gestures нет.
- Тексты и accessibility tree не меняются.
- View/kiosk получают только ожидаемое возвращение живого marker после save;
редакторские элементы в них не появляются.
## 8. Данные, compatibility и миграция
- Используются существующие records `marker.removed` и live `entity:*` marker.
- Новых полей и версии модели нет; read/write migration не нужна.
- Комбинация `device:D removed:true` + live `entity:X` уже schema-valid и должна
round-trip без нормализации, удаляющей один из records.
- Старый frontend, открытый одновременно после записи нового сочетания, может
временно продолжать скрывать X по старой runtime-семантике. Он не должен
разрушительно удалить X или parent tombstone; после обновления/reload новый
frontend показывает X.
- Delete/re-add остаются optimistic config-транзакциями с действующими rev
guards; задача не вводит отдельную запись или частичный commit.
- Layout identity X определяется текущим `markerIdForBinding()`; parent D layout
не переиспользуется.
## 9. i18n, security и performance
### i18n
Новых ключей и текстов нет. Документация EN/RU обновляется симметрично.
### Security
Исправление не меняет resolver действий и lock invariant. Разрешённая X получает
ровно те же safe/secure ограничения, что обычный explicit entity-marker.
### Performance
- Не добавляется проход по HA registry на render/update.
- Допустима линейная подготовка небольшого набора exact live entity bindings
вместе с уже существующим проходом по `markers[]`.
- Проверка одного eid должна оставаться O(1) по подготовленным Set.
- Перестроение `_devices` и render snapshots не должно создавать новый объект
состояния на каждый вызов availability сверх текущей модели.
## 10. Acceptance criteria и доказательства
### AC1. Дочерняя entity доступна в Add
После удаления `device:D` активная дочерняя `entity:X` отсутствует при
выключенном **Показывать сущности** и присутствует при включённом. Сам `device:D`
также остаётся доступным для re-add; размещённые bindings не дублируются.
**Доказательство:** браузерный `demo/smoke_binding_picker.mjs` вызывает настоящий
picker собранного бандла. Проверка known defect перевёрнута в положительную и
проверяет checkbox boundary.
### AC2. Сохранение возвращает только выбранную entity
После save X persisted config содержит live `entity:X` и прежний `device:D`
tombstone; X видна на плане с новой обычной позицией, parent auto-marker не
появляется. Повторный delete/re-add X идемпотентен.
**Доказательство:** browser smoke проходит delete D → Add X → Save → rebuild →
Delete X → Add X → Save и проверяет config records, layout и DOM/device list.
Targeted unit покрывает чистую семантику tombstone/live-binding.
### AC3. Исключение exact и не протекает на siblings
При `device:D` tombstone + live `entity:X`:
- X доступна plan/render availability и обычным state/action/aggregate
потребителям;
- sibling Y без live marker остаётся удалённой;
- `device:D` source остаётся удалённым;
- parent auto-marker отсутствует.
**Доказательство:** unit-матрица для `isRemovedPlanEntity`, `isRemovedPlanSource`,
`buildDevices`, room aggregate и explicit controls/live source; browser smoke
проверяет видимый X и отсутствие D/Y.
### AC4. Существующие lifecycle-контракты не меняются
- exact re-add `device:D` из #161 продолжает заменять device tombstone;
- exact re-add `entity:X` продолжает заменять entity tombstone;
- entity tombstone не вычитает X из живого auto-parent;
- два живых explicit `device:D` + `entity:X` сосуществуют по #226;
- exact opening contact/lock из #104 не зависит от marker tombstones.
**Доказательство:** существующие unit/smoke #104/#161/#226 плюс новые regression
assertions в picker smoke.
### AC5. Данные и UX совместимы
Нет новых config/i18n полей, model bump или миграции. Checkbox, поиск, лимит 200,
disabled/hidden registry-фильтры, touch contract и lock safety не меняются.
**Доказательство:** schema/config round-trip unit, diff review, typecheck и docs
check; новых i18n keys нет.
### AC6. Гейты реализации зелёные
- `npm run typecheck`;
- `npm test`;
- `npm run build` и byte-identical три bundle-копии;
- targeted browser smokes, выбранные `smoke-select`;
- named mutation guards §11;
- `node scripts/check-docs.mjs`.
Полные golden, smoke и performance остаются предрелизными гейтами. Golden не
требуется для этого невизуального изменения, если diff не меняет визуальные
baseline-сценарии.
## 11. План автотестов
### Unit
1. `removedPlanBindings`/availability matrix:
- parent tombstone без live child → X/Y suppressed;
- parent tombstone + live X → X allowed, Y suppressed;
- exact X tombstone без live X → X suppressed;
- legacy duplicate exact tombstone + live X → live exact marker wins;
- `device:D` source remains suppressed in every case with parent tombstone.
2. `buildDevices`:
- only X is built from parent tombstone + live X;
- D does not auto-build;
- adding live explicit D later yields D + X as #226 requires.
3. Room/source consumers include X but not Y.
4. Delete/re-add record helper preserves unrelated tombstones and produces one
exact live binding.
### Browser smoke
Расширить `demo/smoke_binding_picker.mjs` полноценным lifecycle вместо одной
проверки текущего дефекта:
1. удалить реальное устройство стенда;
2. открыть Add с checkbox off/on и проверить X;
3. сохранить X через `_saveMarker()`;
4. проверить records, `_devices`, DOM и свежую layout position;
5. проверить, что D по-прежнему предлагается, а X уже не дублируется;
6. удалить и повторно добавить X;
7. вернуть D и подтвердить осознанное сосуществование D + X.
`scripts/smoke-links.mjs` должен выбирать smoke при изменении helpers tombstone
и нового exact-live override. Если имя `_bindingCandidates` остаётся прямым
совпадением, дублировать его зарегистрированной связью не требуется.
### Mutation guards
| id | Мутант | Обязанный поймать тест |
|---|---|---|
| `device-tombstone-blocks-child-picker` | вернуть общий запрет X в picker при parent tombstone | AC1 browser smoke |
| `live-child-still-suppressed-by-parent-tombstone` | удалить exact live override из runtime | AC2/AC3 unit + smoke |
| `child-readd-clears-parent-tombstone` | при save X удалить `device:D` tombstone | AC2 smoke/config assertion |
| `parent-tombstone-restores-all-siblings` | разрешить все entity D вместо только живых explicit bindings | AC3 unit |
Перед добавлением мутанты запускаются и должны быть реально пойманы. Непроверенный
mutation id в репозиторий не добавляется.
## 12. Затронутые файлы и артефакты
Ожидаемый продуктовый diff:
- `src/devices.ts` — представление tombstones и exact live override;
- `src/houseplan-card.ts` — Add candidates и, если нужно, атомарная замена exact
entity tombstone без parent tombstone;
- `test/devices.test.mjs` и/или узкий policy unit;
- `demo/smoke_binding_picker.mjs`;
- `scripts/smoke-links.mjs`, `scripts/mutation-gate.mjs`;
- `docs/FILTERING.md`, `docs/ARCHITECTURE.md`, `docs/TESTING.md`;
- `docs/USER-GUIDE.md`, `docs/USER-GUIDE.ru.md`;
- `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`;
- три синхронные bundle-копии после build.
Если реализация требует новых config fields, i18n keys или backend-файлов, это
выход за утверждённый scope и повод вернуть ТЗ на уточнение до продолжения кода.
## 13. Release artifacts
- EN/RU changelog в том же `User-Visible: yes` implementation commit;
- EN/RU user guide и канонические filtering/architecture/testing docs;
- bundle parity;
- handoff-комментарий с commit SHA, точными targeted gates и остаточными рисками;
- независимый `CODE-REVIEW-262-rN.md` до автоматического merge в `dev`.
Публичные screenshots не меняются: новых визуальных состояний и UI нет.
## 14. Риски и меры снижения
### R1. Воскреснет весь parent device
Если save X удалит `device:D` tombstone, auto-discovery построит D из остаточных
siblings. Защита: persisted-record и DOM assertions AC2/AC3.
### R2. Исключение протечёт на siblings и агрегаты
Если parent tombstone перестанет подавлять device целиком, удалённые Y снова
начнут влиять на room climate/LQI/Glow или controls. Защита: exact X/Y unit
матрица и mutation `parent-tombstone-restores-all-siblings`.
### R3. X появится в picker, но останется мёртвой после Save
Изолированная UI-правка сделает выбор возможным, однако `buildDevices` и
availability снова отбросят marker. Защита: smoke обязан пройти до DOM/live
state после `_saveMarker()`, а не завершаться на списке candidates.
### R4. Сломается #226
Нельзя считать entity tombstone живым ownership или запретить явное
сосуществование D + X. Защита: существующая unit/smoke-матрица #226 входит в
обязательную регрессию AC4.
### R5. Старый frontend увидит новое сочетание иначе
Старый runtime может временно скрыть X рядом с parent tombstone. Поскольку
records schema-valid и не удаляются старой версией, это обратимая визуальная
деградация до reload новой сборки, а не потеря данных.
## 15. Откат
Откат — один implementation commit #262 вместе с unit/smoke/mutation,
документацией, changelog и bundle-копиями.
Новых persisted fields нет, поэтому data rollback не требуется. После отката
конфиг `device:D removed:true` + live `entity:X` остаётся валидным и сохранным,
но старая версия снова временно не покажет X, пока пользователь не вернёт D
целиком или не установит исправленную сборку.
## 16. Принятые предположения — поменять свободно на ревью ТЗ
1. Parent `device:D` tombstone сохраняется после re-add X. Это следует из
пользовательского действия «вернуть одну entity», а также предотвращает
автоматическое появление нежелательного остаточного parent marker.
2. Live exact `entity:X` сильнее exact/parent tombstone только для X. Техническая
форма (дополнительный Set в prepared policy, отдельный helper или иной O(1)
resolver) не является продуктовым решением.
3. Registry-hidden/disabled entity не получает нового специального whitelist:
issue исправляет только plan tombstone, а не HA availability.
4. Fresh position X определяется существующим Add-контрактом; отдельной миграции
координат D → X нет.
5. Полноценное добавление D после X оставляет оба explicit marker по уже
утверждённому #226; скрытое автоматическое объединение было бы новым UX.