# Issue #170 — HA-устройство не привязывается к комнате без HA-зоны - Дата: 2026-08-18 - Тип: bug · приоритет P1 · ценность 8/10 · сложность/риск 4/10 - Issue: [#170](https://github.com/Matysh/houseplan-card/issues/170) - Ветка: `issue/170-room-without-area` Канонические документы: `docs/SCOPE.md`, `docs/ARCHITECTURE.md`, `docs/CONFIG-COMPATIBILITY.md`, `docs/TOUCH-SUPPORT.md`, `docs/USER-GUIDE.ru.md`, `docs/USER-GUIDE.md`. ## 1. Сценарий и персона Администратор House Plan создаёт внутри пространства комнату без HA Area — например, гардеробную или кладовую. В редакторе устройств он назначает этой комнате уже существующее HA-устройство или отдельную HA-сущность и сохраняет маркер. Это штатный сценарий J4/J6: модель плана может быть точнее модели Areas в Home Assistant, а её сопровождение не должно требовать создания искусственных зон. Корректная пространственная принадлежность также нужна J1: устройство должно отображаться там, куда его явно поместил пользователь. ## 2. Что человек увидит до и после **До исправления:** выбор комнаты без HA-зоны сохраняется, но устройство после перестроения данных остаётся в пространстве своей HA Area либо исчезает из ожидаемого пространства. При повторном открытии редактора выбранная комната не восстанавливается корректно. **После исправления:** явный выбор комнаты без HA-зоны имеет приоритет над HA Area устройства. Маркер сразу отображается в выбранном пространстве, а редактор при следующем открытии показывает ту же комнату. Уже сохранённые затронутые маркеры восстанавливаются автоматически, без повторного сохранения. Внешний вид поля «Комната», маркера и плана не меняется. ## 3. Проблема и подтверждённая причина Сохранение уже записывает корректный контракт: - `marker.space` — пространство выбранной комнаты; - `marker.room_id` — id выбранной комнаты; - `marker.area: null` — у комнаты нет HA Area. Ошибка возникает при чтении в общем `buildDevices()`. В ветках `device:*` и `entity:*` effective area вычисляется через `marker.area || registryArea`. Значимый `null` ошибочно принимается за отсутствие ручного выбора, после чего HA Area снова определяет и `DevItem.area`, и `DevItem.space`. Подтверждённое воспроизведение на `origin/dev`: маркер сохранился как `{space: "garden", area: null, room_id: ""}`, но runtime-проекция стала `{space: "f1", area: "living_room"}`. Повторное открытие редактора построило несуществующую пару `f1#@`. Имеющийся `smoke_subarea` не заметил ошибку, поскольку проверяет только `binding: virtual`. ## 4. Scope - исправить общую runtime-проекцию ручной комнаты без HA Area; - одинаково поддержать `device:*` и `entity:*`, включая entity с Area у родительского HA-устройства; - корректно отразить назначение в полном плане, `houseplan-space-card` и preview редактора через существующий общий `buildDevices()`; - восстановить точное выбранное значение при повторном открытии редактора; - сохранить действующие правила позиции маркера; - добавить unit- и browser-smoke-регрессии; - описать пользовательское исправление в обоих changelog. ## 5. Non-scope - новый UI, новый способ создания комнат или изменение терминологии; - автоматическое создание/изменение HA Floors и Areas; - изменение схемы `markers[]`, версия конфигурации или миграция хранилища; - новая очистка, автопереназначение или persisted rewrite удалённого/ несуществующего `room_id`; безопасный placeholder селектора из §6.3 остаётся обязательным fail-safe; - изменение автоматического размещения устройств без ручной комнаты; - изменение поведения виртуальных маркеров и комнат с HA Area; - новые room aggregates, источники температуры/влажности, LQI или правила Glow; - изменение визуала, touch-, accessibility- или performance-контракта. ## 6. Контракт поведения ### 6.1. Ручная комната без HA Area Для явного HA-маркера `room_id` является признаком точного назначения в комнату. Если `room_id` — непустая строка и сохранённый `marker.area` равен `null`, runtime обязан получить: - `DevItem.space` из сохранённого `marker.space`; - `DevItem.area === ""` как существующее runtime-представление отсутствующей HA Area; - неизменённый `marker.room_id`. Registry Area устройства, сущности или её родительского устройства не должна переопределять эти значения. Правило одинаково для `device:*` и `entity:*`. ### 6.2. Остальные способы размещения Если точного назначения в комнату без HA Area нет, сохраняется текущая семантика: 1. явная Area маркера определяет Area и пространство; 2. для старого/metadata-only HA-маркера без явной комнаты применяется Area из entity/device registry; 3. если Area не разрешается, применяется существующий fallback пространства; 4. `virtual` продолжает использовать свою текущую ветку; 5. удалённые, hidden, disabled, orphaned и unverified binding сохраняют действующие правила. Исправление не должно превращать любое `area: null` в ручную комнату: точное правило активируется только при непустом `room_id`. ### 6.3. Редактор, preview и позиция - Unsaved preview использует тот же production resolver и показывает маркер в выбранном пространстве без наследования registry Area. - После сохранения и повторного открытия поле «Комната» восстанавливает точное значение `#@`. - Если во время реконструкции редактора всё же получена несовпадающая пара `#@` (такой комнаты нет в указанном пространстве), диалог не падает, selector остаётся на существующем placeholder и не переписывает persisted marker без явного Save. - При смене комнаты внутри того же пространства существующая позиция маркера не меняется. - При переносе в другое пространство действует существующее центрирование по целевой комнате. Новый отдельный resolver для полного или статического плана не допускается. ### 6.4. Пространственные потребители Полный и статический планы получают одинаковые `space`, `area` и `room_id` из `buildDevices()`. Пустая effective area не должна возвращать устройство в агрегаты исходной HA Area. Существующий room-id-aware light/Glow resolver может использовать точную комнату; остальные агрегаты продолжают действовать по своим текущим Area/source-контрактам. ## 7. Данные, миграция и совместимость Persisted-формат не меняется. Каноническая запись комнаты без HA Area остаётся `space: `, `area: null`, `room_id: `; runtime `area: ""` в `DevItem` не записывается обратно в конфигурацию. Миграция не нужна: корректные значения уже находятся в существующих конфигурациях и должны начать читаться правильно сразу после обновления. Неизвестные sibling-поля маркера сохраняются обычным путём. Старые frontend после downgrade снова могут проявить исходный дефект, но данные при этом не требуют новой downgrade-конверсии. Никаких изменений backend validation, WebSocket API, export/import envelope или `docs/CONFIG-COMPATIBILITY.md` не требуется. ## 8. i18n, accessibility и touch Новых строк и переводов нет. Существующие label, focus order, keyboard и touch targets редактора не меняются. `docs/TOUCH-SUPPORT.md` остаётся без правок. ## 9. Acceptance criteria и доказательства | AC | Критерий | Обязательное доказательство | |---|---|---| | AC1 | `device:*` с registry Area и сохранёнными `space`, `area:null`, `room_id` строится в сохранённом пространстве с `DevItem.area === ""`. | Targeted unit `buildDevices`. | | AC2 | То же правило работает для `entity:*`, даже если Area приходит от entity registry или родительского device. | Targeted unit с обоими registry-path. | | AC3 | Старый HA-маркер без `room_id`, ручная комната с HA Area, автоустройство и `virtual` сохраняют текущее размещение. | Негативные/regression unit cases. | | AC4 | Уже сохранённый маркер комнаты без HA Area корректно читается без resave или migration. | Unit-fixture существующей persisted-записи. | | AC5 | Сохранение реального HA-binding в комнату без Area переносит runtime-маркер в целевое пространство, а повторное открытие редактора восстанавливает точный выбор. Несовпадающая пара `space#@room_id` не вызывает ошибку и оставляет selector на placeholder без неявной записи. | Расширенный `demo/smoke_subarea.mjs`: happy path и negative mismatched-pair case. | | AC6 | Перенос между пространствами использует существующее центрирование, а выбор другой комнаты в том же пространстве не двигает расставленный маркер. | Browser-smoke с обеими ветками позиции. | | AC7 | Полный и статический планы используют единую исправленную проекцию без второго resolver. | Unit общего `buildDevices` и code review shared call sites. | | AC8 | Изменение проходит рабочие implementation-gates. | `npm run typecheck`, `npm test`, `npm run build`; targeted browser smoke до code review. | | AC9 | Пять регрессий §10.3 зарегистрированы в штатном mutation runner и каждый чистый guard доказан чувствительным к своей поломке. | `node scripts/mutation-gate.mjs --check` и пять отдельных `--id=...`; в evidence сохранены id, guard и ожидаемый non-zero. | ## 10. План автотестов ### 10.1. Unit В `test/devices.test.mjs` добавить минимальные fixtures для: - `device:*`: registry Area конфликтует с ручной area-less комнатой; - `entity:*`: конфликт с Area самой entity и с Area parent device; - сохранённой до исправления записи без миграции; - маркера без `room_id`, который обязан продолжить registry fallback; - комнаты с HA Area, автоустройства и virtual marker. Проверять одновременно `space`, `area` и сохранение `marker.room_id`, чтобы частичное исправление не прошло незамеченным. ### 10.2. Browser smoke Расширить `demo/smoke_subarea.mjs`, не удаляя существующий virtual-сценарий: 1. открыть реальный HA device/entity, исходно принадлежащий Area другого пространства; 2. выбрать комнату без Area и сохранить; 3. проверить persisted `space/area/room_id` и runtime `space/area`; 4. повторно открыть редактор и проверить точное значение выбора; 5. доказать центрирование при межпространственном переносе; 6. доказать сохранение позиции при смене комнаты внутри пространства; 7. передать редактору несовпадающую пару `space#@room_id`, проверить отсутствие `pageerror`/необработанного console error, placeholder селектора и отсутствие неявного изменения persisted marker. Golden/screenshot не нужен: пиксельный контракт не меняется. Полный smoke, golden и performance остаются общими пред-бета gates по runbook. ### 10.3. Executable mutation gate Реализация обязана зарегистрировать в `scripts/mutation-gate.mjs` пять реальных entries. Каждый entry содержит стабильный `id`, объяснение `because`, один или несколько патчей `file/find/replace` с уникальным якорем и указанный guard. Runner применяет патч в отдельном worktree, собирает bundle, получает ожидаемый non-zero от guard и оставляет рабочее дерево чистым. | Mutant id | Обязательный патч | Guard | |---|---|---| | `manual-room-device-area-fallback` | В `src/devices.ts` заменить room-aware resolution device-ветки двумя прежними строками `const area = m.area \|\| dev?.area_id \|\| '';` и `const space = (area && areaToSpace[area]) \|\| m.space \|\| firstSpaceId;`, снова дав registry Area приоритет над ручной area-less комнатой. | `npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test --test-name-pattern="manual room without area.*device" test/devices.test.mjs` — AC1 обязан увидеть registry `area/space` вместо сохранённых значений. | | `manual-room-entity-branch-skipped` | В entity-ветке вернуть прежний `m.area \|\| reg?.area_id \|\| parentDeviceArea` resolution, оставив device-ветку исправленной. | `npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test --test-name-pattern="manual room without area.*entity" test/devices.test.mjs` — AC2 обязан поймать обе ветки registry Area. | | `area-null-without-room-id-hijacked` | В предикате area-less manual room убрать требование непустого `room_id`, то есть считать любое `area:null` ручной комнатой. | `npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test --test-name-pattern="area null without room_id" test/devices.test.mjs` — negative AC3 обязан сохранить registry fallback старого marker. | | `reopened-room-from-registry-space` | В `src/houseplan-card.ts` при сборке room value заменить effective `d.space + '#@' + d.marker.room_id` на текущее/source пространство редактора, вновь создавая несовпадающую пару. | `node demo/smoke_subarea.mjs` — AC5 обязан отличить точный reopened selection и одновременно доказать безопасный placeholder для invalid pair. | | `same-space-room-change-recenters` | В ветке сохранения позиции исключить `roomChanged` из same-space preserve path, например добавить `&& !roomChanged` к проверке `prevPos.s === targetSpace`, чтобы изменение комнаты снова ушло в центрирование. | `node demo/smoke_subarea.mjs` — AC6 обязан увидеть изменение ранее закреплённых координат. | Имена targeted unit tests в guard должны совпасть с фактически добавленными именами. Если реализация меняет форму исходного кода, допустимо скорректировать `find/replace`, но семантика каждого мутанта и его guard неизменны; якорь обязан встречаться ровно один раз и проходить `--check`. Обязательные команды перед передачей кода на review: ```text node scripts/mutation-gate.mjs --check node scripts/mutation-gate.mjs --id=manual-room-device-area-fallback node scripts/mutation-gate.mjs --id=manual-room-entity-branch-skipped node scripts/mutation-gate.mjs --id=area-null-without-room-id-hijacked node scripts/mutation-gate.mjs --id=reopened-room-from-registry-space node scripts/mutation-gate.mjs --id=same-space-room-change-recenters ``` Перед каждым mutant-run его чистый guard должен быть зелёным. Evidence каждого запуска обязано содержать id, точную guard-команду и ожидаемый non-zero. Ручное редактирование без runner, а также только `--list` или `--check`, AC9 не выполняет. ## 11. Риски и меры | Риск | Мера | |---|---| | Сломан registry fallback старых явных маркеров без ручной комнаты. | Отдельный negative unit без `room_id`. | | Исправлена device-, но не entity-ветка. | Симметричные AC1/AC2 и mutation gate. | | Пространство исправлено, но registry Area продолжает влиять на room aggregates. | В AC1/AC2 обязательно проверять и `area === ""`. | | Полный и статический планы расходятся. | Исправлять общий `buildDevices`, не добавлять renderer-specific resolver. | | Исправление случайно меняет позицию существующего маркера. | Две browser-проверки same-space/cross-space. | | Начинается неоговорённая очистка stale `room_id`. | Stale/deleted room явно оставлен в non-scope. | Performance-риск пренебрежимо мал: добавляется только константная проверка полей на явный marker. Новых DOM-узлов, таймеров, подписок, сетевых запросов и persisted writes нет. Security/privacy boundary не меняется. ## 12. Rollback Исправление можно откатить одним frontend-коммитом: persisted-схема и backend не меняются. Откат возвращает исходный баг чтения, но не требует восстановления данных. Перед откатом оставить новые тесты как описание известного контракта либо явно откатить их вместе с поведением; частичный откат resolver без тестов не допускается. ## 13. Release-артефакты - пользовательские записи в `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` в том же implementation-коммите (`User-Visible: yes`); - `docs/USER-GUIDE.ru.md` и `docs/USER-GUIDE.md` уже описывают требуемое поведение, поэтому смысловая правка не нужна; при реализации проверить, что формулировки не устарели; - обновлённые unit и `demo/smoke_subarea.mjs`; - синхронные build-артефакты по действующему D-контракту; - screenshots/golden не требуются; - отдельные performance/security artifacts не требуются; - перед бетой выполняются общие golden, smoke и performance gates; Linux CI остаётся каноном полного HA harness. ## 14. Принятые предположения 1. Непустой `room_id` вместе с `area:null` однозначно означает намеренное назначение в комнату без HA Area; это уже записывает текущий редактор. 2. `marker.space` у такой сохранённой записи валиден. Если он отсутствует или указывает на удалённое пространство, применяется текущий безопасный fallback без новой диагностики или автоматической очистки. 3. Текущая политика позиции — сохранить координаты внутри того же пространства и центрировать только при смене пространства — является продуктовым контрактом и не пересматривается этой задачей. 4. Исправление должно восстанавливать уже сохранённые записи только чтением; фоновая миграция и принудительный resave не допускаются. 5. Вопросов владельцу нет: ожидаемое поведение уже закреплено в issue и пользовательском руководстве.