docs: review document for #126

Issue: #126
User-Visible: no
This commit is contained in:
claude[bot]
2026-08-31 09:45:53 +03:00
committed by Matysh
parent b319fc9548
commit 61dd7ccb89
+278
View File
@@ -0,0 +1,278 @@
# CODE-REVIEW-126-r1
- **Issue:** #126 — «Смена area устройства в HA не переносит иконку в новую комнату, если позиция была сохранена»
- **Этап:** code (PROCESS.md §2.7)
- **Заход:** r1 (первый код-ревью, спек уже принят зелёным на r2 `cfd9c0fd`)
- **Диапазон:** `origin/dev...HEAD`, HEAD = `34b99b95` (продуктовый коммит `24251a25` + `docs: refresh canonical screenshots`)
- **Вердикт:** жёлтый · блокирующих циклов кода 1/4 · High: 1 · Medium: 1 → в задаче
## Скоуп
Полный трек, новая подсистема: pure resolver `src/device-area-relocation.ts`,
интеграция в `houseplan-card.ts` (persistence coordinator, `_livePos`, drag
cancel/history clear), в `space-card.ts`/`space-render.ts`/`space-geometry.ts`
(hosted Static, read-only projection), backend schema/import-export
(`marker_area_snapshot`), правка семантики Save в Device dialog
(`roomTouched`), доки, unit/backend/browser-smoke тесты.
Разбор полный (не по дельте) — это первый код-ревью раунд для #126.
## Как проверялось
Дешёвые гейты прогнаны на `34b99b95`:
- `npx tsc --noEmit` — green;
- `npm test` — green, 1679/1680 pass, 1 known skip, 0 fail;
- `npm run build` — green;
- `npm run bundle:sync` — green, `dist`/`custom_components/.../frontend`/`demo/srv/assets` синхронны (`git status` после — чисто);
- `node scripts/check-docs.mjs` — green (docs-check запускался, т.к. диф трогает `src/**`; отпечаток скриншотов уже обновлён коммитом `34b99b95`).
Инварианты модели (`npm run invariants`) не запускал: диф не трогает рёбра
комнат, записи толщины, `layout` schema (`x/y/s/k` не менялись) или
`open_spans` — resolver только читает существующие `room.poly` для
point-in-room проверки, ничего геометрического не пишет.
Geometry/perf-профили не запускал — не названы в AC и не задеты диффом.
`python -m pytest tests_backend` не гонял целиком, но точечно прочитал новые
тесты в `tests_backend/test_validation.py` и `tests_backend/test_ha_import_export.py`
— оба соответствуют §16 ТЗ и реально проверяют границы (bad binding, лишний
ключ, `MAX_MARKER_AREA_SNAPSHOT+1`, cross-source drop, same-source preserve).
**Отбор smoke.** `node scripts/smoke-select.mjs --base origin/dev --head HEAD`
дал 38 «прямых совпадений» и 37 «слабых связей» (общие символы `_markerDialog`,
`_model`, `NORM_W`, `_showToast`, `_writeConfig`, `_reloadConfigOnly`, `_snap`
и т.п. — задеты фоново из-за размера диффа в `houseplan-card.ts`/
`houseplan-editor-runtime.ts`, не из-за новой логики Area relocation).
Прогнал целевую выборку по риску, не всю матрицу:
| Smoke | Категория | Прогнан | Результат |
|---|---|---|---|
| `demo/smoke_area_relocation.mjs` | новый, AC1/2/3/7/9/11 | да | OK (все 13 проверок true) |
| `demo/smoke_subarea.mjs` | слабая связь (`_markerDialog`), тематически прямое попадание — explicit room override между spaces | да | **FAIL, 6/14** |
| `demo/smoke_marker_stay.mjs` | слабая связь, «позиция не движется при смене комнаты» — тот же механизм записи room | да | OK, но см. находку H1 — тест не детектирует регрессию |
| `demo/smoke_controls.mjs` | слабая связь (`_markerDialog`) | да | OK (virtual binding, не задет) |
| `demo/smoke_hidden_flag.mjs` | не в списке инструмента, проверил вручную по тому же паттерну кода | да | OK (virtual binding, не задет) |
| `demo/smoke_device_inbox.mjs` | слабая связь, тематика #29 lifecycle, которую §4 ТЗ явно наследует | да | OK |
| `demo/smoke_new_device.mjs` | слабая связь, переиспользуемая метка `new_device_ids` | да | OK |
| `demo/smoke_disabled_device.mjs` | слабая связь, ha_disabled eligibility (§8 п.4 ТЗ) | да | OK |
| `demo/smoke_readonly_cold_start.mjs` | слабая связь (`_canEdit`), тематика read-only/cold-start, которую сам новый smoke не покрывает | да | OK |
| Остальные 30 «прямых» / 29 «слабых» | символы из типов (`DevItem`,`Marker`,`Layout`,`SpaceModel`) или несвязанные путём диффа строки (`_cfgContentFingerprint`,`_reloadConfigOnly` в контексте drag/upload/optimize, не Area) | нет | не прогонял — связь по имени, не по функции; риск низкий, задача их не касается |
`smoke_subarea.mjs` — единственный найденный красный результат; воспроизвёл
дважды и сверил с чистым `origin/dev` (отдельный `git worktree`, тот же
Chromium) — там он проходит полностью. Регрессия принадлежит этой ветке.
`npm run golden:verify` не запускал — рендер/геометрия/стили/слои не меняются
(маркер продолжает использовать существующий `defaultPositions()` путь и
существующую красную точку, новый визуальный язык не вводится, это прямо
зафиксировано в ТЗ §19 и подтверждается диффом — новых CSS/SVG путей нет).
## Находки
### H1 (High, блокирует) — Save диалога устройства без изменения комнаты больше не пишет явный room override; регрессия существующего smoke
**Файл:** `src/houseplan-editor-runtime.ts:8192-8213` (`_saveMarker`), зеркально `_markerDraft:12321-12330`.
Новый флаг `roomTouched` — правильная и нужная часть задачи: раньше **любое**
сохранение диалога устройства навсегда закрепляло эффективную HA-комнату как
`marker.room_id`/`marker.area` (потому что `dlg.room` всегда непустой — он
показывает текущее эффективное размещение), что при принятии #126 сделало бы
открытие/сохранение диалога **эквивалентным явному override** и тихо
уничтожало бы саму фичу (после первого же захода в Device editor устройство
переставало бы следовать HA Area). Флаг это чинит: писать `marker.room_id`
теперь можно только если `dlg.roomTouched === true` (реальный `@change` на
`#marker-room`) либо маркер уже был явным раньше (`previousExplicit`).
Побочный эффект: единственное место в продуктовом коде, которое выставляет
`roomTouched: true`, — это буквально обработчик `@change` `<select
id="marker-room">` (`houseplan-editor-runtime.ts:12852-12855`). Любой код
(включая существующие тесты), который выставляет `_markerDialog.room`
напрямую, минуя реальное DOM-событие, **больше не производит запись явного
room override** — `writePlacement` остаётся `false`.
`demo/smoke_subarea.mjs` — существующий, не тронутый в этом диффе smoke —
делает именно это: `c._markerDialog = { ...c._markerDialog, room:
'garden#@garden-shed-a' }` для реального HA-устройства `d_light1` без
`marker`, затем `_saveMarker()`. Раньше это был штатный (и единственный)
способ протестировать «явный выбор комнаты в другом space побеждает HA Area»
(#83/#317 контракт). Сейчас:
```
$ node demo/smoke_subarea.mjs
FAILED (6):
- haMarkerSaved: expected true, got false
- haRuntimePlacement: expected true, got false
- crossSpaceCentered: expected true, got false
- haReopenRoom: expected true, got false
- invalidPairPlaceholder: expected true, got false
- sameSpaceRoomSaved: expected true, got false
```
На чистом `origin/dev` (тот же Chromium, отдельный worktree) этот smoke
проходит полностью (все 14 проверок true) — регрессия принадлежит этой ветке,
не окружению.
Настоящий пользовательский путь (клик по `<select>`) действительно
выставляет `roomTouched: true` вместе с `room`, так что для живого клика в
браузере регрессии, видимо, нет — но:
1. это не доказано ни одним тестом с реальным DOM-событием (`dispatchEvent`)
на `#marker-room`, только предположением по коду;
2. `demo/smoke_marker_stay.mjs` использует тот же паттерн прямой записи
`room` для auto-устройства без `marker` (строка 16) и теперь **тоже
тестирует не то, что заявлено**: `stayAfterRoomChange: true` проходит не
потому что позиция осталась на месте при смене комнаты, а потому что смена
комнаты стала no-op (запись `marker.room_id` не происходит вовсе) — тест
зелёный, но перестал быть доказательством;
3. `git diff --stat` подтверждает, что ни `smoke_subarea.mjs`, ни
`smoke_marker_stay.mjs` не редактировались в этом диффе — автор не
перепрогнал существующую smoke-матрицу после изменения контракта
`_markerDialog`.
**Воспроизведение:** `node demo/smoke_subarea.mjs` на `34b99b95` (см. вывод
выше); `node demo/smoke_subarea.mjs` на `origin/dev` (worktree) для контраста
— зелёный.
**Что нужно:** обновить существующие smoke (`smoke_subarea.mjs`,
`smoke_marker_stay.mjs` и любые другие с тем же паттерном) так, чтобы они
ставили `roomTouched: true` вместе с `room` при симуляции пользовательского
выбора — это восстановит их как реальное доказательство приёмки #83/#317, а
не тихо превратит их в vacuous pass. Альтернативно, если реальный сценарий
сохранения room должен остаться доступным программно без `roomTouched`,
нужно явное решение о контракте, а не побочный эффект нового поля.
Это находка внутри скоупа задачи (её же код это сломал) — правится в этой же
ветке, без отдельного issue.
### M1 (Medium, в скоупе) — Browser smoke не покрывает 4 из 8 сценариев, обязательных по §19 ТЗ
`docs/specs/126-ha-area-marker-relocation.md` §19 требует, чтобы
`demo/smoke_area_relocation.mjs` (или эквивалент) «обязан проверить»:
same-space (✓ есть), cross-space, standalone entity marker, explicit room
override и composite exclusion, cold-start stale backfill, red badge (✓),
hosted Static (✓), no-op при unchanged rebuild (✓).
Фактический файл покрывает 4 из 8: same-space drag, badge/acknowledgement,
hosted Static read-only projection, no-double-write. Cross-space, standalone
entity, explicit override/composite exclusion и cold-start backfill
проверены только на уровне чистого resolver в
`test/device-area-relocation.test.mjs` — это доказывает корректность формул,
но не то, что `_maybeRebuildDevices`, `_syncAreaRelocations`,
`resolveHaBindingStatus`, `_openMarkerDialog`/`_saveMarker` и cold-boot таймингов
`authoritative`-флага действительно проводят эти случаи через реальный
production bundle. Ни один из этих четырёх интеграционных путей не имеет
альтернативного покрытия (в `houseplan-card.ts` вообще нет unit-тестов
уровня класса — весь integration-контур тестируется только smoke).
Учитывая, что H1 — это как раз пример: логика, безупречная в pure-модуле,
сломалась именно на стыке с Device dialog, которого чистый unit-тест не
видит. Отсутствие cross-space/entity/explicit/cold-start smoke — это
конкретно обязательство ТЗ (AC2 «unit + smoke», AC6 «unit + cold-start
smoke»), которое сейчас закрыто только наполовину.
**Что нужно:** дополнить `demo/smoke_area_relocation.mjs` (или отдельный
файл) четырьмя недостающими сценариями либо явно задокументировать в ТЗ/issue,
почему интеграционное покрытие для них не требуется — молчаливое сужение
недопустимо.
Находка в скоупе — правится в этой же ветке, без отдельного issue.
## Что проверено и корректно (по коду, где явно указано «прочитано, не исполнено»)
- **AC1/AC2 (resolver-формулы для same-space/cross-space).** Прочитано
построчно `resolveDeviceAreaRelocations` (`src/device-area-relocation.ts:137-230`)
против §11/§12 ТЗ: порядок проверок (duplicate-Area guard → known-binding
area-changed → backfill by saved point) соответствует §11 п.1-3 и §12
случаям 1-5 один в один; юнит-тесты (`test/device-area-relocation.test.mjs`)
реально могут падать — проверил, что при обратной логике (`sameRoom`/`staleRoom`)
падают ожидаемо. Cross-space путь через полный бандл — см. M1, не доказан.
- **AC3 (read-only/static truth).** `space-render.ts:280-286` считает
`areaRelocationIds` тем же чистым resolver'ом без побочных эффектов;
`markerPos()` (`space-geometry.ts:629`) получает `ignoreSaved` и не
обращается к записи/сети. Подтверждено smoke `staticReadOnlyProjection` +
`staticMadeNoWrites`.
- **AC4/AC5 (explicit wins, eligibility).** `registryFollowingBinding`
(`device-area-relocation.ts:95-107`) читает как отсекающий guard: virtual,
unverified, explicit area/room, removed, binding mismatch, markerless entity
(композитная light-group). Прочитано против §8 построчно, юнит-тест
`explicit placement, virtual markers and composite groups...` реально
различает 7 кейсов включая `disabled` (eligible per §8 п.4 — код специально
НЕ фильтрует `ha_disabled`, только `unverified`; прочитано в паре с
`devices.ts:1203-1206`, где `ha_disabled` маркер остаётся в `rest[]` с
актуальным `area` из полного реестра — реактивация получит верную комнату).
- **AC8 (rebind provenance).** `previous?.binding === binding` guard —
различие binding трактуется как новый baseline, не relocation; юнит-тест
`rebind establishes a new baseline...` бьётся именно на этом.
- **AC9 (fail-safe, delete-first).** `_syncAreaRelocations`
(`houseplan-card.ts:5137-5251`): цикл `await this._persistDevicePlacement(id,
null)` до формирования `nextSnapshot`/`nextAttention`; при неудаче удаления
`deleteFailed=true` → `_areaRelocationSyncKey=''` для ретрая на следующий
authoritative pass; при неудаче записи конфига — восстановление
`marker_area_snapshot`/`new_device_ids` только если конкурентная мутация не
успела заменить именно эту попытку (сверка по `contentFingerprint`).
Подтверждено smoke (`failedConfigRetryable`, `configRetrySucceeded`) —
сценарий реально симулирует отказ `houseplan/config/set` через перехват
`hass.callWS` и проверяет ретрай на следующий `window.__setRegistryArea`.
- **AC10 (no Undo).** При `_areaRelocationIds.size` вызывается
`_cancelDeviceDrag()` и `_devicePositionHistory.clear()`
(`houseplan-card.ts:5058-5059`) на каждый authoritative rebuild, в котором
есть relocation — до применения новых позиций. Прочитано, не покрыто
отдельным unit/smoke именно на срез «Undo не воскрешает» — риск невысокий,
т.к. `.clear()` детерминированно опустошает стек, но формально AC10 просит
«history unit/source contract», а такого точечного теста нет (не поднимаю
до Medium: это самый безопасный из недостающих кейсов — `.clear()`
тривиален по семантике и уже покрыт существующими unit-тестами
`device-position-history.ts`).
- **AC12 (compatibility).** `custom_components/houseplan/validation.py:1936-1949`
— схема соответствует §16 (bounded map, `_NONEMPTY_TEXT`, regex на binding,
`MAX_MARKER_AREA_SNAPSHOT`); `import_export.py:1727` роняет
`marker_area_snapshot` вместе с `known_devices`/`new_device_ids` при чужом
источнике — совпадает с §16 «Full import из другого source удаляет...».
Backend-тесты реально проверяют оба направления (same-source preserve,
cross-source drop) и границы схемы (5 негативных кейсов + предел+1).
`scripts/config-field-registry.mjs` и `docs/CONFIG-COMPATIBILITY.md`
обновлены в одном духе.
- **Save-диалог, `writePlacement`/`previousExplicit` (кроме регрессии H1).**
Логика сохранения существующего явного override при повторном Save без
касания комнаты (`previousExplicit && !dlg.roomTouched` → взять значения из
`previousMarker`, не из `dlg.room`) корректна и необходима для §15 ТЗ
(«При Save обязан заново разрешить binding... stale room draft не может
вернуть marker в прежнюю комнату»). Именно это подтверждает
`roomDraftRefreshed`/`noStaleExplicitOverride` в новом smoke.
- **Doc/gate discipline.** Оба changelog правлены в том же коммите, что и
продуктовый код (`24251a25`, `User-Visible: yes`); трейлеры `Issue`/
`User-Visible` на месте на обоих коммитах; `docs/CONFIG-COMPATIBILITY.md`,
`docs/ARCHITECTURE.md`, `docs/FILTERING.md`, EN/RU `USER-GUIDE`,
`docs/TESTING.md` обновлены по существу, без выдумывания несуществующей
терминологии (используют «Area», «House Plan room», «red attention dot» —
термины уже приняты в USER-GUIDE.ru.md/en).
- **Одно число — один источник.** Диф не вводит новую видимую пользователю
величину, дублируемую в двух местах (нет второго badge/подписи/числа);
единственная воспроизведённая сущность — булево «отмечено вниманием»,
которое явно **переиспользует** существующий `new_device_ids`
(`_newIds` getter объединяет `settings.new_device_ids` и
`_areaRelocationIds` в одно множество, `houseplan-card.ts:5254-5257`) — один
источник для чтения, один для записи (после коммита `_areaRelocationIds`
пусто, `new_device_ids` содержит id). Регрессии по классу #234/#233 не вижу.
## Чего не проверял
- Полную smoke-матрицу (210 файлов) — не запускал, обоснование выбора выше по
выводу `smoke-select.mjs`; 30 «прямых»/29 «слабых» совпадений не прогнаны —
риск по существу (не по имени символа) оценил как низкий, диф их не
затрагивает функционально.
- `npm run golden:verify`, `npm run invariants`, performance-профили,
`python -m pytest tests_backend -q` целиком — обоснование выше (визуальный
язык/геометрия/производительность не меняются; backend прочитан точечно, но
полный прогон питон-сьюта не выполнял).
- Реальный DOM `dispatchEvent('change')` по `#marker-room` для верификации,
что живой пользовательский клик действительно не задет H1, — сделал вывод
чтением обработчика (`houseplan-editor-runtime.ts:12852-12855`), не
исполнением; учитывая, что именно предположение по коду и подвело в этой же
задаче, эту конкретную гипотезу стоит подтвердить исполнением при доработке.
- Мобильный/touch рендер и kiosk-режим для самой Area-релокации специально —
косвенно закрыто тем, что `renderSpaceStatic` (used by hosted Static/kiosk)
и interactive card используют один и тот же resolver и одну и ту же
`markerPos`/`_livePos` точку входа; отдельного touch-smoke для #126 не
запускал, т.к. в §15 ТЗ явно сказано «View и kiosk на touch обязаны
показать то же положение, что desktop View» без нового UI на touch.