Волна 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
24 KiB
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 нет, сохраняется текущая семантика:
- явная Area маркера определяет Area и пространство;
- для старого/metadata-only HA-маркера без явной комнаты применяется Area из entity/device registry;
- если Area не разрешается, применяется существующий fallback пространства;
virtualпродолжает использовать свою текущую ветку;- удалённые, 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-сценарий:
- открыть реальный HA device/entity, исходно принадлежащий Area другого пространства;
- выбрать комнату без Area и сохранить;
- проверить persisted
space/area/room_idи runtimespace/area; - повторно открыть редактор и проверить точное значение выбора;
- доказать центрирование при межпространственном переносе;
- доказать сохранение позиции при смене комнаты внутри пространства;
- передать редактору несовпадающую пару
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. Принятые предположения
- Непустой
room_idвместе сarea:nullоднозначно означает намеренное назначение в комнату без HA Area; это уже записывает текущий редактор. marker.spaceу такой сохранённой записи валиден. Если он отсутствует или указывает на удалённое пространство, применяется текущий безопасный fallback без новой диагностики или автоматической очистки.- Текущая политика позиции — сохранить координаты внутри того же пространства и центрировать только при смене пространства — является продуктовым контрактом и не пересматривается этой задачей.
- Исправление должно восстанавливать уже сохранённые записи только чтением; фоновая миграция и принудительный resave не допускаются.
- Вопросов владельцу нет: ожидаемое поведение уже закреплено в issue и пользовательском руководстве.