mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 19:58:50 +00:00
294 lines
24 KiB
Markdown
294 lines
24 KiB
Markdown
# 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 и
|
||
пользовательском руководстве.
|