24 KiB
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, так что для живого клика в
браузере регрессии, видимо, нет — но:
- это не доказано ни одним тестом с реальным DOM-событием (
dispatchEvent) на#marker-room, только предположением по коду; demo/smoke_marker_stay.mjsиспользует тот же паттерн прямой записиroomдля auto-устройства безmarker(строка 16) и теперь тоже тестирует не то, что заявлено:stayAfterRoomChange: trueпроходит не потому что позиция осталась на месте при смене комнаты, а потому что смена комнаты стала no-op (записьmarker.room_idне происходит вовсе) — тест зелёный, но перестал быть доказательством;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и не обращается к записи/сети. Подтверждено smokestaticReadOnlyProjection+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 === bindingguard — различие 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/RUUSER-GUIDE,docs/TESTING.mdобновлены по существу, без выдумывания несуществующей терминологии (используют «Area», «House Plan room», «red attention dot» — термины уже приняты в USER-GUIDE.ru.md/en). - Одно число — один источник. Диф не вводит новую видимую пользователю
величину, дублируемую в двух местах (нет второго badge/подписи/числа);
единственная воспроизведённая сущность — булево «отмечено вниманием»,
которое явно переиспользует существующий
new_device_ids(_newIdsgetter объединяет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.