diff --git a/docs/specs/170-room-without-area.md b/docs/specs/170-room-without-area.md new file mode 100644 index 00000000..508592ff --- /dev/null +++ b/docs/specs/170-room-without-area.md @@ -0,0 +1,257 @@ +# 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[]`, версия конфигурации или миграция хранилища; +- новая обработка удалённого/несуществующего `room_id`; +- изменение автоматического размещения устройств без ручной комнаты; +- изменение поведения виртуальных маркеров и комнат с 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. +- После сохранения и повторного открытия поле «Комната» восстанавливает точное + значение `#@`. +- При смене комнаты внутри того же пространства существующая позиция маркера + не меняется. +- При переносе в другое пространство действует существующее центрирование по + целевой комнате. + +Новый отдельный 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-маркер в целевое пространство, а повторное открытие редактора восстанавливает точный выбор. | Расширенный `demo/smoke_subarea.mjs`. | +| AC6 | Перенос между пространствами использует существующее центрирование, а выбор другой комнаты в том же пространстве не двигает расставленный маркер. | Browser-smoke с обеими ветками позиции. | +| AC7 | Полный и статический планы используют единую исправленную проекцию без второго resolver. | Unit общего `buildDevices` и code review shared call sites. | +| AC8 | Изменение проходит рабочие implementation-gates. | `npm run typecheck`, `npm run test:unit`, `npm run build`; targeted browser smoke до code review. | + +## 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. доказать сохранение позиции при смене комнаты внутри пространства. + +Golden/screenshot не нужен: пиксельный контракт не меняется. Полный smoke, +golden и performance остаются общими пред-бета gates по runbook. + +### 10.3. Executable mutation gate + +Тесты обязаны падать хотя бы при следующих намеренных мутациях: + +- возврат `marker.area || registryArea` в device-ветке; +- исправление только `device:*`, без `entity:*`; +- применение area-less-семантики ко всем `area:null`, без проверки `room_id`; +- построение reopened room через registry-derived space; +- центрирование уже расставленного маркера при смене комнаты в том же + пространстве. + +## 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 и + пользовательском руководстве. diff --git a/docs/specs/README.md b/docs/specs/README.md index 2749fb8b..467a770a 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -52,6 +52,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным | [#164](https://github.com/Matysh/houseplan-card/issues/164) Активный цикл стиральной машины должен быть жёлтым | [164-washer-active-cycle.md](164-washer-active-cycle.md) | | [#166](https://github.com/Matysh/houseplan-card/issues/166) Солнечные лучи зеркально учитывают направление севера | [166-sun-north-rotation.md](166-sun-north-rotation.md) | | [#167](https://github.com/Matysh/houseplan-card/issues/167) Экспорт «только планировка» | [167-plan-only-export.md](167-plan-only-export.md) | +| [#170](https://github.com/Matysh/houseplan-card/issues/170) HA-устройство не привязывается к комнате без HA-зоны | [170-room-without-area.md](170-room-without-area.md) | ## P2