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

294 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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: "<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:
```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 и
пользовательском руководстве.