Files
houseplan-card/legacy/specs/226-entity-parent-dedup.md
T
Claudeandclaude[bot] df46fd1c3e docs(hygiene): ТЗ выпущенных задач без живых ссылок — в legacy/specs (#682)
Волна 5 эпика #674, перенос ТЗ (класс C). Из 240 файлов `docs/specs/` в
`legacy/specs/` уехали 219: на них не ссылается ни один живой файл (код,
тесты, скрипты, workflow, документы вне архива и ревью). Остались 21 ТЗ —
на которые ссылаются код, ADR, ISOMETRIC, SUN, RADAR, LIGHT (`docs/specs/067`),
DECOR-EDITOR, support-relay, и те, на которые ссылаются они сами; README
каталога объясняет, где искать остальное. Открытых issue с файлом ТЗ среди
перенесённых нет. Относительные ссылки перенесённых файлов переписаны
(`../X` → `../../docs/X`, соседние оставшиеся ТЗ → `../../docs/specs/…`) —
все 26 резолвятся. Попутно: битая ссылка в
`089-isometric-view-stage1.md:8` на удалённый `089-isometric-view.md` —
теперь команда `git show` по истории. Строка в `legacy/README.md`.

Issue: #682
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-27 22:10:46 +00:00

26 KiB
Raw Blame History

Issue #226 — Entity-marker не дублируется родительским HA-устройством

  • Дата: 2026-08-20
  • Тип: bug · приоритет P1 · ценность 8/10 · сложность/риск 5/10
  • Issue: #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:

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. Дополнительных настроек, предупреждений и переводов не требуется.