Files
houseplan-card/docs/specs/170-room-without-area.md
T
2026-08-18 09:49:24 +03:00

24 KiB
Raw Blame History

Issue #170 — HA-устройство не привязывается к комнате без HA-зоны

  • Дата: 2026-08-18
  • Тип: bug · приоритет P1 · ценность 8/10 · сложность/риск 4/10
  • Issue: #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: "<room>"}, но runtime-проекция стала {space: "f1", area: "living_room"}. Повторное открытие редактора построило несуществующую пару f1#@<room>. Имеющийся 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.
  • После сохранения и повторного открытия поле «Комната» восстанавливает точное значение <space>#@<room_id>.
  • Если во время реконструкции редактора всё же получена несовпадающая пара <space>#@<room_id> (такой комнаты нет в указанном пространстве), диалог не падает, 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: <id>, area: null, room_id: <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:

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 и пользовательском руководстве.