Files
houseplan-card/docs/reviews/CODE-REVIEW-126-r1.md
T
2026-08-31 09:45:53 +03:00

24 KiB
Raw Blame History

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.