# CODE-REVIEW-397-r1 - Issue: #397 — «Undo позиций #74: своё же эхо сбрасывает историю, а доказывающий это смок подстроен» - ТЗ: `docs/specs/397-device-position-echo.md` (ревизия 2, принята жёлтым → зелёным в SPEC-REVIEW-397-r1/r2) - Этап: code, заход r1, блокирующих циклов израсходовано 0 из 4 - Диапазон: `git diff origin/dev...HEAD` (HEAD `08d56122`), реализация в коммите `d87cc298` («fix: the card keeps the position it sent, so its echo is not foreign (#397)»), артефакты — в `08d56122` ## Скоуп проверки Диф вне docs/reviews и бандлов: - `src/houseplan-card.ts` — 5 строк, `_persistDevicePlacement` - `demo/smoke_device_position_history.mjs` — правка фикстуры (M1) + новые проверки AC2/AC5б/AC6/AC7 - `test/device-position-echo.test.mjs` — новый юнит (3 теста) - `scripts/mutation-gate.mjs` — новый мутант `device-echo-keeps-local-noncanonical` - `docs/CHANGELOG.md` / `docs/CHANGELOG.ru.md` — пункт User-Visible - `dist/**`, `custom_components/houseplan/frontend/**`, `docs/images/**` — артефакты сборки/скриншотов, сверены с исходником (см. «Как проверялось») Продуктовый код в узком скоупе ТЗ: только `_persistDevicePlacement`. Геометрия, рёбра комнат, `layout`/`marker.space`/`open_spans` не затронуты — гейт инвариантов (`npm run invariants`) не применим. ## Как проверялось Зелёного Validate на `08d56122` не было — все гейты прогнаны локально. 1. **Чтение диффа.** `git diff origin/dev...HEAD -- src/houseplan-card.ts` построчно сверен с описанием B3 в ТЗ и с текстом коммита. Прочитаны контекст `_persistDevicePlacement` (:5224–5261), `applyDevicePlacement` (`src/device-position-history.ts:45-67`), `_reloadLayoutOnly` (:4904–4948), `_adoptStructuralResponses` (:4203–4269), `_loadFromServer` (:4280–…) — чтобы понять, какой путь реально вызывается при reconnect. 2. **Дешёвые гейты** (все на `08d56122`, после `npm run bundle:sync`, чтобы поднять `demo/srv/assets`, — без него браузерные смоки не грузят карточку вообще, что не связано с задачей, а с локальным окружением): - `npx tsc --noEmit` — чисто. - `npm test` — **1657 pass / 0 fail / 1 skip** (совпадает с заявлением автора). - `npm run build` — зелёный; `git status` после сборки чист → три копии бандла (`dist/`, `custom_components/houseplan/frontend/`, плюс git-игнорируемый `demo/srv/assets`, пересобранный отдельно) совпадают с закоммиченными. - `node scripts/check-docs.mjs` — «Documentation checks passed (7 files, 10 external links)», включая отпечаток скриншотов (diff трогает `src/**`, значит гейт обязателен) — прошёл, скриншоты (`docs/images/01-view-desktop.png`, `09-device-info.png`) и `docs/images/screenshots.json` в коммите `08d56122` актуальны. - `node scripts/mutation-gate.mjs --check` — все патчи реестра, включая новый `device-echo-keeps-local-noncanonical`, ложатся на текущий код (`ok device-echo-keeps-local-noncanonical`). - `node --test test/mutation-gate.test.mjs` — 10/10, реестр не разошёлся с кодом. 3. **`node scripts/smoke-select.mjs --base origin/dev --head HEAD`** — результат «НЕОПРЕДЕЛЁННОСТЬ»: единственный изменённый символ `_layout` слишком широк (встречается почти везде), `contentFingerprint` не связан ни с одним смоком явно. АС задачи прямо называют `demo/smoke_device_position_history.mjs` как способ доказательства — этого достаточно, полную матрицу (209 файлов) не гонял: диф не расширяет поверхность за пределы одного метода и уже названного смока. 4. **`demo/smoke_device_position_history.mjs` — прогнан явно** (`node demo/smoke_device_position_history.mjs`, после `npm run bundle:sync`): все 31 проверка зелёные, включая новые `localCopyEqualsTheWire`, `ownEchoMatchesWhatWentOverTheWire`, `deleteEchoKeepsHistory`, `inFlightPositionWinsTheMerge`. 5. **Дисциплина «тест умеет падать» — проверена вручную**, а не принята на слово. В `_persistDevicePlacement` временно заменил ``` const pos = canonicalizePosition(this._layout[deviceId]); if (contentFingerprint(pos) !== contentFingerprint(this._layout[deviceId])) { this._layout = { ...this._layout, [deviceId]: pos }; } ``` на `const pos = canonicalizePosition(...); void pos;` (то же преобразование, что делает мутант `device-echo-keeps-local-noncanonical`), пересобрал (`npm run bundle:sync`) и перезапустил смок. Результат: ``` FAILED (3): - localCopyEqualsTheWire: expected true, got false - sameContentReloadKeepsHistory: expected true, got false - ownEchoMatchesWhatWentOverTheWire: expected true, got false ``` Это ровно то расхождение, которое ТЗ требует для AC6, и ровно три проверки, которые называет коммит-сообщение автора («three checks red without the fix»). После проверки откатил файл (`git diff` снова пуст) и пересобрал, чтобы вернуть дерево в исходное состояние. 6. **`node --test test/device-position-echo.test.mjs`** — 3/3 pass отдельно от общего прогона, чтобы увидеть их независимо. 7. **Трейлеры и changelog.** `git show d87cc298` содержит `Issue: #397` и `User-Visible: yes`; правки обоих `docs/CHANGELOG*.md` лежат в этом же коммите (не в отдельном). `git show 08d56122` — `User-Visible: no`, корректно для чисто артефактного коммита. ## Разбор по AC - **AC1** (после `_persistDevicePlacement` `_layout[deviceId]` побайтово раве отправленному, отпечаток по нему). Доказано двояко: поведенчески — смок `localCopyEqualsTheWire` (зелёный, покраснел при отмене фикса); структурно — `test/device-position-echo.test.mjs` (`AC1: the update branch stores what it sends, before sending it`) проверяет порядок операторов в исходнике. ТЗ просило «юнит с перехватом callWS» — по факту это не Node-юнит с мокнутым `hass.callWS`, а (а) браузерный смок с фейковым WS и (б) текстовый юнит по исходнику. Проверил, что это не слабее: смок реально перехватывает `message.pos` на проводе и сравнивает с `_layout`, то есть доказывает AC1 не хуже мокнутого юнита. Отклонение от буквы метода доказательства — Low, не блокирует. - **AC2** (честный reload не чистит историю) — `sameContentReloadKeepsHistory` + новый `ownEchoMatchesWhatWentOverTheWire` (пин того, что стороны реально совпали — без этого проверка AC2 могла бы быть тавтологией). Зелёные, красные при отмене фикса. Выполнено. - **AC3** (тот же инвариант для `_adoptStructuralResponses`, reconnect-путь, пункт 5 плана автотестов) — **не выполнено**. См. находку Medium-1 ниже. - **AC4** (чужое изменение чистит историю) — `remoteContentClearsHistory`, код и фикстура не менялись в этой части, поведение унаследовано и подтверждено прогоном (зелёное). - **AC5а** (удаление: ключ удалён локально до отпечатка) — юнит `AC5a: the delete branch removes the key before the fingerprint`, плюс чтением подтверждено: `applyDevicePlacement(layout, id, null)` удаляет ключ (`src/device-position-history.ts:50-54`), вызывается безусловно в начале `_persistDevicePlacement` (:5230) до отправки и до фиксации отпечатка. Выполнено. - **AC5б** (эхо удаления не чистит историю) — `deleteEchoKeepsHistory`, зелёный. Отдельно проверено, что `echoProbe` в текущей демо-фикстуре разрешается (не `null`) — прогон подтвердил `true`, ветка реально исполняется, а не проваливается в `else`-заглушку. Выполнено. - **AC6** (смок краснеет на коде до фикса) — доказано вручную (раздел «Как проверялось», п.5): 3 проверки красные без фикса, все остальные 28 — зелёные (значит, откат не сломал ничего постороннего, поломка точечная). Выполнено. - **AC7** (запись в полёте побеждает ответ сервера) — `inFlightPositionWinsTheMerge`, зелёный; прочитан код `_reloadLayoutOnly` (:4912-4926) — `_sentPos` накладывается на `remote` после `mine`, порядок не менялся этим диффом. Выполнено. ## Находки ### Medium-1 (в скоупе, чинится в этом же раунде). AC3 не имеет ни одного теста — `_adoptStructuralResponses` не упомянут ни в одном изменённом файле ТЗ (`docs/specs/397-device-position-echo.md:114-117`) требует отдельного доказательства для `_adoptStructuralResponses` («полная перезагрузка конфига и layout, reconnect-путь») именно потому, что сам документ называет его **вторым независимым путём**, читающим то же расхождение (раздел «(1) B3», строки 41-49): "Дальше расхождение читают два независимых пути: `_reloadLayoutOnly`… `_adoptStructuralResponses`…". План автотестов, пункт 5, прямо предписывает: «Перемещение → полный `_adoptStructuralResponses` (reconnect) → `canUndo === true` (AC3)». Ни `demo/smoke_device_position_history.mjs`, ни новый `test/device-position-echo.test.mjs`, ни любой другой файл в диффе не упоминают `_adoptStructuralResponses` или `_loadFromServer` (проверено: `git diff origin/dev...HEAD | grep -i adoptStructural` — пусто). Все новые проверки (`sameContentReloadKeepsHistory`, `ownEchoMatchesWhatWentOverTheWire`, `deleteEchoKeepsHistory`, `inFlightPositionWinsTheMerge`) идут через `_reloadLayoutOnly` — это первый путь, не второй. **Воспроизведение**: `grep -n "_adoptStructuralResponses" demo/*.mjs test/*.mjs` внутри изменённых файлов не находит совпадений; в `docs/specs/397-device-position-echo.md:154` пункт 5 плана заявлен, но не реализован. **Разобрано по коду (проверено чтением, не исполнением) — риска регрессии в проде я не вижу**: `_adoptStructuralResponses` (:4245-4258) сравнивает `contentFingerprint(nextLayout)` с `(this._layoutContentFingerprint || contentFingerprint(this._layout))` — тем же `_layoutContentFingerprint` и тем же `_layout`, которые фикс в `_persistDevicePlacement` теперь синхронизирует с отправленным на сервер значением. Других мест, где этот путь читал бы неканонический слепок, нет. То есть B3 закрыт для обоих потребителей одним и тем же присваиванием — функционального дефекта, скорее всего, не осталось. Но это не заменяет обязательство: ТЗ приняло AC3 с явным способом доказательства, автор его не выполнил и не заявил как исключение. «Верно по рассуждению ревьюера» — это ровно то временное доверие, из-за которого заводился прецедент #102 (правка по одному замечанию тихо ломает непроверяемый соседний путь) — только с обратным знаком: здесь никто и не проверял. Нужно: добавить в `demo/smoke_device_position_history.mjs` сценарий пункта 5 (переместить маркер → вызвать полный путь конфиг+layout, которым реально идёт `_loadFromServer`/reconnect, с честным серверным слепком → `canUndo === true`) либо явно проговорить в ТЗ/PR, почему этот путь достаточно покрыт первым, и получить это как решение ревью, а не как факт по умолчанию. ### Low-1. AC1 доказан не тем способом, что назвало ТЗ ТЗ: «Доказательство: юнит с перехватом `callWS`». Реализация: браузерный смок (перехват фейкового WS в `demo/`) + текстовый юнит по исходнику в `test/`. По существу доказательство не слабее (см. разбор AC1 выше), но название файла в плане (`test/device-position-persist.test.mjs`) и метод («юнит с перехватом callWS») разошлись с тем, что реализовано (`test/device-position-echo.test.mjs`, текстовый разбор + смок). Не блокирует, автор может закрыть комментарием одной строкой, если согласен, что смок сильнее. ### Low-2. Юнит-план, пункт 3 («каноническая позиция не создаёт лишней записи»), не имеет отдельного теста Раздел «Риски» ТЗ (`docs/specs/397-device-position-echo.md:169-172`) просил юнит на то, что уже каноническая позиция не вызывает лишнего присваивания `_layout` (мера против лишнего ре-рендера). Гвард в коде (`if (contentFingerprint(pos) !== contentFingerprint(this._layout[deviceId])) {…}`) присутствует и корректен — проверено чтением: при равенстве отпечатков блок не выполняется, второе присваивание `_layout` не происходит. Отдельного теста на это нет ни в `test/`, ни в смоке. Riск-митигация без теста хуже, чем без митигации, но здесь код всё же есть и логика прямолинейна (`!==` перед присваиванием) — не блокирует, оставляю на усмотрение автора. ## Что проверено и корректно - Порядок операций в `_persistDevicePlacement` соответствует контракту ТЗ: локальная запись канонического значения → `_sentPos.set` → `hass.callWS(...)` → `_layoutContentFingerprint` по актуальному `_layout`. Порядок «локальная запись раньше `_sentPos`» (риск-митигация из ТЗ) сохранён — проверено чтением :5241-5253. - Ветка удаления использует тот же безусловный `applyDevicePlacement` в начале функции (:5230), отдельного расхождения между «обновить» и «удалить» в части фиксации отпечатка нет. - M1 закрыт: `serverLayout = structuredClone(c._layout)` перед `_reloadLayoutOnly()` убран из смока; сервер теперь только то, что реально ушло по фейковому WS. Подтверждено падением 3 проверок при отмене фикса. - `_sentPos` продолжает побеждать в `_reloadLayoutOnly` (:4918-4926) — код этого пути не тронут диффом, AC7 подтверждает поведение, а не чинит регрессию. - CHANGELOG (`docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`) — пункт добавлен в том же коммите, что и фикс; трейлеры `Issue: #397` / `User-Visible: yes` на месте. - Бандл-артефакты (`dist/`, `custom_components/houseplan/frontend/`, скриншоты) актуальны — подтверждено пересборкой и чистым `git status`. - Мутант `device-echo-keeps-local-noncanonical` зарегистрирован корректно, guard (`node demo/smoke_device_position_history.mjs`) существует и реально ловит поломку (проверено вручную, см. «Как проверялось», п.5), реестр не разошёлся с кодом (`test/mutation-gate.test.mjs` зелёный). ## Чего не проверял и почему - **`node scripts/mutation-gate.mjs` (полный/по `--id=device-echo-keeps-local-noncanonical`)** — не гонял через штатный раннер: он в этой песочнице падает на не связанном с задачей препятствии (`python3 -m pytest tests_backend/...` — «No module named pytest», модуль не установлен) ещё на этапе чистого прогона, до применения самого мутанта. Это гейт предрелизного окна (`.github/workflows/mutation-gate.yml`), не гейт ревью, и файл сам себя так описывает («прогон дорогой… его место — перед стабильным релизом»). Заменил его точечной ручной проверкой (см. п.5 «Как проверялось»), которая доказывает то же самое для конкретного мутанта. - **`python -m pytest tests_backend -q`** — диф не трогает `custom_components/**/*.py`, гейт не применим. - **`npm run invariants`** — диф не трогает геометрию/рёбра/`layout`-ключи решётки, гейт не применим. - **`npm run golden:verify`** — диф не меняет рендер/геометрию/стили/слои, только служебное поле `_layout` и порядок присваиваний; визуального следа у AC нет. - **HA-харнесс** — в этой песочнице нет Home Assistant; это предрелизный гейт на точном SHA, автор пометил то же самое в своём комментарии. - **Полная матрица `demo/smoke_*.mjs` (209 файлов)** — не запускал: `scripts/smoke-select.mjs` не нашёл сильной связи за пределами уже названного в АС смока, а сам диф не расширяет поверхность (один метод, один смок, названный ТЗ явно). ## Унаследовано из этапа spec SPEC-REVIEW-397-r1 (жёлтый, Medium: AC5б/AC7 без способа доказательства) и SPEC-REVIEW-397-r2 (зелёный) заранее проверили точность самого диагноза (B3, M1) построчно против кода на SHA `83692e79`/`6edcde01` — до реализации. Диагноз принят без повторной проверки номеров строк из ТЗ (они успели сместиться в ходе реализации на единицы строк, что нормально и не меняет сути); сам код B3/M1 в этом раунде я перечитал заново на HEAD, а не унаследовал.