Files
houseplan-card/docs/reviews/CODE-REVIEW-403-r1.md
T
2026-09-01 15:08:22 +00:00

215 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <export>`** — диф трогает `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 на месте.