# CODE-REVIEW-403-r1 - Issue: https://github.com/Matysh/houseplan-card/issues/403 - ТЗ: `docs/specs/403-area-relocation-safety.md` (ревью: `SPEC-REVIEW-403-r1.md` жёлтый → `SPEC-REVIEW-403-r2.md` зелёный) - Диапазон: `origin/dev..HEAD`, SHA на момент вердикта — `9c4d07abe27de7d55b8fd78947dcf21579e8055a` - Ветка: `issue/403-area-relocation-safety` - Заход: r1 (первый код-ревью этой задачи; спек-ревью не тратит бюджет код-ревью, §7.2 PROCESS.md) - Ребейз: ветка приведена к `dev` конвейером до ревью (024114fd → 9c4d07ab, +1 коммит `dev`) — разбор ниже полный, не по дельте, как того требует §7.2/§2.9 ## Скоуп Две находки одного аудита (`AUDIT-2026-08-31-v1700beta2.md`), одна поверхность — `_syncAreaRelocations` в `src/houseplan-card.ts` и стек Undo позиций устройств: - **C2 (High)**: отказ `houseplan/config/set` после успешного удаления layout ранее терял ручную позицию устройства без следа и без метки внимания. - **M1 (Medium)**: переезд area одного устройства чистил весь стек Undo/Redo позиций, а не только записи переехавшего устройства. Коммиты диапазона: `0d338b9c` (ТЗ), `caddeb08`/`6b515800` (документы спек-ревью, кладутся конвейером), `94502d3d` (правка ТЗ по H1 спек-ревью r1), `71bbc953` (**код**: `src/houseplan-card.ts`, `src/command-stack.ts`, `test/command-stack.test.mjs`, `scripts/mutation-gate.mjs`, `demo/smoke_area_relocation_safety.mjs` (новый), `demo/smoke_area_relocation.mjs` (правка одной ветки), `docs/ARCHITECTURE.md`, `docs/CHANGELOG.md`/`.ru.md`, бандл-копии), `9c4d07ab` (docs: пересъёмка скриншотов, `Issue: #403`, `User-Visible: no`, обязательна т.к. `check-docs.mjs` считает отпечаток по всему `src/**`). ## Как проверялось ### Гейты (все на SHA `9c4d07ab`) | Гейт | Команда | Результат | |---|---|---| | typecheck | `npx tsc --noEmit` | 0 ошибок | | unit | `npm test` | `pass 1697 · fail 0 · skipped 1` (`# tests 1698`) | | build + сверка бандла | `npm run build`; `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js`; сверка `houseplan-assets.json` и списка файлов `houseplan-assets/`; затем `npm run bundle:sync` (пересборка + синхронизация трёх копий, включая `demo/srv/assets`) | все копии побайтово совпали **до** пересборки — в дереве уже лежит актуальный бандл; `git status --short` после пересборки пуст | | docs-фингерпринт (обязателен, diff трогает `src/**`) | `node scripts/check-docs.mjs` | `Documentation checks passed (7 files, 10 external links)` | | новый `any` | `node scripts/no-new-any.mjs --base origin/dev --head HEAD` | `Проверено добавленных строк: 52 в 2 файл(ах). Новых any нет` | | провенанс коммитов/веток | `node scripts/process-gate.mjs --base origin/dev --head HEAD` | `коммитов 6, гейт пройден, предупреждений 0` (проверка 8 — статус issue — не запускалась, не нужна для ревью) | | выбор смоков по diff | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | 22 «прямых совпадения» из 212; см. решение по каждому ниже | | смок AC1–AC6 (новый) | `node demo/smoke_area_relocation_safety.mjs` | все 11 полей `true`, `OK` | | смок AC7 (существующий #126) | `node demo/smoke_area_relocation.mjs` | все 18 полей `true`, `OK`, включая переименованное `failedConfigRestoredAndRetryable` | | смок по прямому совпадению символа (`_devicePositionHistory`, `_persistDevicePlacement`) | `node demo/smoke_device_position_history.mjs` | все 32 поля `true`, `OK` | | мутант 1 | `node scripts/mutation-gate.mjs --id=area-relocation-loses-position-on-refusal` | `тест покраснел, как обязан`, поймано 1/1 | | мутант 2 | `node scripts/mutation-gate.mjs --id=area-relocation-clears-whole-history` | `тест покраснел, как обязан`, поймано 1/1 | ### Что НЕ прогонялось и почему - **Остальные 19 «прямых совпадений» `smoke-select`** (`smoke_optimize_coordinate_canonicalization`, `smoke_room_resize`, `smoke_cold_view_toggle`, `smoke_cold_view_vacuum`, `smoke_help_affordance`, `smoke_junction_limits`, `smoke_lattice_write_barrier`, `smoke_linked_virtual_light`, `smoke_partition_openings`, `smoke_plan_upload_race`, `smoke_resize_audit_1550`, `smoke_resize_pointer_real_plan`, `smoke_save_race`, `smoke_space_tab_reorder`, `smoke_tap_ctx`, `smoke_v8_draft_write`, `smoke_virtual_light_toggle`, `smoke_ws_resilience`, `smoke_zero_wall_migration_unblocked`) — совпали только по одиночным широко используемым символам (`_showToast`, `_writeConfig`, `_reloadConfigOnly`), которые diff не переопределяет и вызывает теми же сигнатурами, что и раньше. Слабая связь по PROCESS.md §8 — повод посмотреть, не обязанность прогонять; ни один из этих смоков не касается area-relocation/undo-истории по теме. Полная матрица (212 смоков) — предрелизный гейт. - **`npm run invariants -- --config `** — диф трогает `layout` (позиции устройств), формально попадающий под правило PROCESS.md §доп. «трогает геометрию/ссылки на неё» (`marker.space`, `layout`). Решение: не запускать, явно. Причина: правка не вычисляет новые координаты/ключи и не производит новых записей толщины/рёбер — `_persistDevicePlacement(id, placement)` в ветке восстановления вызывается с тем же объектом `before`, захваченным `devicePlacement(this._layout, decision.id)` до удаления (`houseplan-card.ts:5180`), т.е. записывается обратно **байт-в-байт то же значение**, которое уже прошло инварианты до удаления; ключ (`deviceId`) не меняется. Сама логика инвариантов (`checkReferences`, `latticeProfile`) не тронута и продолжает проверяться юнит-тестами (`test/model-invariants.test.mjs` и смежные — в зелёном `npm test` выше). Ни `src/device-area-relocation.ts` (резолвер, вне скоупа ТЗ), ни wall/thickness/open_spans код не задеты. - **`npm run golden:verify`** — ТЗ прямо утверждает «Видимого оформления не меняем» (раздел UX), и чтение диффа это подтверждает: нет правок CSS, разметки, `render()`; единственная пользовательская связь — переиспользуемый toast (`toast.pos_save_failed`, уже существующий ключ) и существующая метка внимания (`.newdot`, уже существующая разметка — её же проверяет наш смок через `fallbackEl?.querySelector('.newdot')`). - **`python -m pytest tests_backend`** — diff не трогает `custom_components/**/*.py`. - **Performance-профили** — не названы в AC; ТЗ утверждает «нет доп. циклов, подписок, сетевых вызовов сверх уже выполняемых `_syncAreaRelocations` / `_writeConfig`» — проверено чтением: цикл восстановления идёт по `deletedPlacements` (Map, максимум по числу решений `relocate` в одном проходе — уже ограничено существующим циклом `for (const decision of current.decisions)`), доп. запись конфига только при `restoreFailed.size > 0` (редкая ветка, не на каждый тик). ## Находки Нет. High: 0, Medium: 0, Low: 0 (нет находок, требующих правки или списания записью). Ниже — отдельно, для прозрачности разбора: одна проанализированная, но не блокирующая деталь. **Наблюдение (не находка).** Тройной отказ подряд — отказ `config/set`, затем отказ восстановления `layout/update`, затем отказ второй, дополнительной записи конфига с меткой внимания (`houseplan-card.ts:5273-5283`) — оставляет локальное состояние (`_serverCfg.settings.new_device_ids` содержит id) впереди серверного на один цикл: сама эта последняя запись не откатывается при неудаче, в отличие от первой (`previousSnapshot`/`previousAttention` restore чуть выше). Разобрано по коду, не воспроизведено намеренно (третий отказ подряд у одного и того же устройства — вырожденный случай, вне разумной проверки браузерным смоком). Самолечится: `_regSignature = ''` и `_areaRelocationSyncKey = ''` сброшены в этой же ветке, так что следующий authoritative rebuild пересчитает резолюцию заново и либо успешно допишет attention на сервер (через обычный путь #126 — `nextAttention` включает любой `committed` id независимо от исхода восстановления позиции), либо покажет очередной toast. Тот же риск закрыт в разделе «Риски» ТЗ («Восстановление на сервере тоже может отказать... этот случай обязан оставлять метку внимания» — оставляет, пусть и не гарантированно с первой попытки). Не Low, потому что не расходится с контрактом ТЗ и не имеет пользовательского воспроизводимого сценария потери данных — в худшем случае лишний toast и задержка в один rebuild-тик. ## Разбор по AC Все доказательства ниже — исполнением (браузерный смок `demo/smoke_area_relocation_safety.mjs`, запущен выше), кроме отдельно помеченных «прочитано, не исполнено». - **AC1** (отказ `config/set` не оставляет устройство без позиции: либо позиция на месте, либо `new_device_ids`). Доказано смоком: `rejectedWriteRestoresAfterDelete: true` (позиция восстановлена локально **и** на сервере — `layout/update` вызван после отказа `config/set`, порядок проверен по индексу вызова) и `failedRestoreLeavesAttention: true` (второй сценарий: отказывает и восстановление — устройство попадает в `new_device_ids` **и на сервере**, `.newdot` в DOM). Мутант `area-relocation-loses-position-on-refusal` красит именно этот смок при удалении восстановления — доказательство не тавтологично. - **AC2** (успешный переезд работает как раньше). Доказано полным прогоном `demo/smoke_area_relocation.mjs` (18/18 `true`, включая `staleLayoutDeleted`, `provenanceAdvanced`, `attentionShown`) и `successfulRetry: true` в новом смоке (тот же сценарий после снятия задержки ответа сервера). - **AC3** (delete-first: устаревшая точка не выигрывает, пока ответ сервера в пути). Доказано: `stalePointSuppressedWhilePending: true` — во время задержанного `config/set` `c._layout[relocatedId]` пуст, а устройство вычисляется в новой area, то есть старая точка не «воскресает» до подтверждения. - **AC4** (ветка `conflict` подчиняется AC1). Доказано: `conflictRestoresBeforeRetry: true` — порядок вызовов restore(`layout/update`) → `config/get` (reload) → повторный `layout/delete` подтверждён по индексам в логе WS-вызовов, не только по финальному состоянию; `conflictEventuallyCompletes: true`. Код-путь проверен построчно: `await this._reloadConfigOnly(true)` (было `void`) — сделано намеренно awaited, чтобы блок восстановления attention ниже читал уже перезагруженный `this._settings`, а не гонялся с ним. - **AC5** (переезд одного не гасит историю другого). Доказано: `unrelatedHistorySurvives: true`, `unrelatedUndoStillWorks: true` (Undo лампы после её drag остаётся рабочим после переезда другого устройства), `conflictKeepsUnrelatedHistory: true` в ветке conflict. Дополнительно юнит-тестом `test/command-stack.test.mjs` — `removeWhere` оставляет несвязанные undo/redo записи (`stack.undoName === 'lamp'`, `canRedo === true` после удаления записи `sensor`). - **AC6** (записи переехавшего устройства удалены из истории). Доказано: `relocatedHistoryRemoved: true` (после удаления записи `undoName` уже не «Move relocating light», `canRedo` пуст). Юнит-тест то же самое проверяет на уровне `CommandStack.removeWhere` изолированно. Мутант `area-relocation-clears-whole-history` красит смок при возврате безусловного `clear()` — доказательство не тавтологично. - **AC7** (существующее поведение #126 не сломано). Доказано полным зелёным прогоном `demo/smoke_area_relocation.mjs`; единственное изменённое утверждение (`failedConfigRetryable` → `failedConfigRestoredAndRetryable`) соответствует тексту ТЗ буквально — сверено построчно с `docs/specs/403-area-relocation-safety.md:174-180`. ## Что проверено и корректно - **Тип `DevicePlacement`/`deviceId`** — прочитано, не исполнено: `src/device-position-history.ts:1-20` подтверждает поля `x/y/s` у `DevicePlacement` и `deviceId/spaceId/placement` у `DevicePositionState`, что делает `removeWhere(({before, after}) => relocating.has(before.deviceId) ...)` типобезопасным (подтверждено также нулём ошибок `tsc --noEmit`). - **Идентичность восстанавливаемого значения** — прочитано: `before` в цикле удаления (`houseplan-card.ts:5180`) берётся через `devicePlacement(this._layout, decision.id)` **до** вызова удаления, и то же самое значение (без пересчёта) передаётся в `_persistDevicePlacement(id, placement)` при восстановлении — ветка восстановления не может записать испорченные координаты. - **i18n** — использованные ключи (`toast.pos_save_failed`, `toast.cfg_save_failed`, `toast.conflict`) уже существуют во всех четырёх словарях (`src/i18n/{en,ru,de,fr}.json`, сверено `grep`) — новых строк diff не добавляет, что соответствует разделу «i18n» ТЗ. - **Трейлеры и changelog** — `71bbc953` несёт `Issue: #403` / `User-Visible: yes` и правит `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` в том же коммите; текст точен относительно кода (сверено построчно — «restores the point before retrying, or marks the marker for attention if the restore also fails» соответствует именно реализованной логике, а не общей фразе). `9c4d07ab` (скриншоты) несёт `Issue: #403` / `User-Visible: no`, что верно — рендер не менялся. - **`docs/ARCHITECTURE.md`** — абзац про area-relocation writer дополнен точным описанием восстановления и точечной инвалидации истории, без расхождений с кодом. - **Одно число — один источник**: diff не добавляет и не меняет ни одной видимой пользователю величины (позиция маркера не отображается как число, Undo-кнопка — булев enabled/disabled без числа); неприменимо к этому diff. ## Унаследовано (без повторной проверки) Не применимо — это первый код-ревью задачи (r1). Вердикт и находки `SPEC-REVIEW-403-r1.md`/`SPEC-REVIEW-403-r2.md` относятся к другому этапу (§7.2 PROCESS.md: цикл считается по этапу, спек-ревью не тратит бюджет код-ревью и не подменяет его разбор) — их выводы не наследуются автоматически в код-ревью, но диагноз C2/M1 и AC1–AC7 из зелёного `SPEC-REVIEW-403-r2.md` использованы как контракт, против которого проверялся код в разделе «Разбор по AC» выше. ## Вердикт Зелёный. Обе находки аудита устранены и доказаны исполнением с мутационным контролем (не тавтологичны); AC1–AC7 доказаны; гейты, обязательные для объёма этой задачи, зелёные; трейлеры и changelog на месте.