docs: review document for #397

Issue: #397
User-Visible: no
This commit is contained in:
claude[bot]
2026-08-31 00:15:10 +00:00
parent f4c66bed3f
commit 037a19d093
+252
View File
@@ -0,0 +1,252 @@
# CODE-REVIEW-397-r2
- Issue: #397 — «Undo позиций #74: своё же эхо сбрасывает историю, а
доказывающий это смок подстроен»
- ТЗ: `docs/specs/397-device-position-echo.md` (ревизия 2, принята
SPEC-REVIEW-397-r1/r2 → зелёным)
- Этап: code, заход r2, блокирующих циклов израсходовано 1 из 4
- r1: `docs/reviews/CODE-REVIEW-397-r1.md`, вердикт жёлтый, SHA `08d56122`
(Medium-1: AC3 не имел теста; Low-1, Low-2 — не блокировали)
- Диапазон этого раунда: `git diff 08d56122..HEAD` (HEAD `f4c66bed`).
Единственный продуктовый коммит раунда — `f4c66bed` («test: prove AC3 on
the reconnect path, with a probe that can fail»); `b17f4167` — сам документ
r1, артефактов не меняет.
## Почему разбор сужен до дельты, а не полный
Дельта раунда — 26 добавленных / 3 удалённые строки в одном файле
(`demo/smoke_device_position_history.mjs`), только фикстура. Продуктовый код
(`src/houseplan-card.ts`) в этом раунде не менялся — фикс `d87cc298` тот же,
что проверялся в r1. Ребейза на dev не было (`git merge-base HEAD origin/dev`
не менялся между раундами — диапазон `origin/dev..HEAD` идентичен по базе),
контракт поведения не менялся, новая подсистема не задета. Условия §2.9 для
полного разбора не выполняются → разбор по AC ограничен AC3 (предмет
Medium-1) и тем, до чего дотягивается сама дельта — общими для нескольких AC
частями фикстуры, которые дельта тоже трогает (см. ниже).
## Скоуп проверки дельты
`demo/smoke_device_position_history.mjs`:
1. Новый обработчик `houseplan/config/get` в фейковом `callWS` — эхо
`c._serverCfg`/`c._cfgRev`, чтобы `_adoptStructuralResponses` не отличил
`configChanged` по посторонней причине и проверка отвечала только за
layout-вопрос AC3.
2. `echoProbe`-позиция сменена с канонической `{x:0.42, y:0.42}` на
заведомо неканоническую `{x:0.024999999999999942, y:0.7/7}` — та же пара,
что уже использовалась в ТЗ как пример расхождения канонизации (39 из 115
вида `px/800*0.997+0.0013` расходятся; `0.024999999999999942 → 0.025`).
3. Новая проверка `out.reconnectKeepsHistory`: после перемещения маркера —
`await c._loadFromServer()` (полный reconnect-путь, читает ОБА ответа) —
`canUndo` должен остаться `true`.
Это ровно то, что просило Medium-1 из r1: доказательство для
`_adoptStructuralResponses` через `_loadFromServer`, а не только через
`_reloadLayoutOnly`.
Поскольку `echoProbe`-позиция теперь общая для трёх проверок в одном блоке
(AC3 `reconnectKeepsHistory`, AC5б `deleteEchoKeepsHistory`, AC7
`inFlightPositionWinsTheMerge`), дельта фактически затрагивает доказательства
всех троих — разбор по коду и прогон распространён на них тоже, а не только
на новую строку.
## Как проверялось
Зелёного Validate на `f4c66bed` нет — все гейты прогнаны локально.
1. **Чтение диффа.** `git diff 08d56122..HEAD -- demo/smoke_device_position_history.mjs`
построчно сверен с текстом Medium-1 и с планом автотестов ТЗ (пункт 5).
2. **Чтение вызываемого пути.** `_loadFromServer` (`src/houseplan-card.ts:4283-4369`)
действительно шлёт `Promise.all([houseplan/config/get, houseplan/layout/get])`
и передаёт оба ответа в `_adoptStructuralResponses(cfgResp, layResp)`
(:4320) — то есть новая проверка идёт через второй путь, который в r1 был
не покрыт, а не через `_reloadLayoutOnly` ещё раз. Прочитан также
`_adoptStructuralResponses` (:4203-4269): `configChanged` считается по
`contentFingerprint(nextConfig)` vs `_cfgContentFingerprint` — при эхе
`c._serverCfg` без изменений это `false`, значит очистка истории по
`configChanged` (реальная, но не относящаяся к AC3 причина) исключена, и
единственная переменная в проверке — расхождение `_layout` из B3.
3. **Дешёвые гейты**, все на `HEAD` (`f4c66bed`), после `npm run bundle:sync`:
- `npx tsc --noEmit` — чисто.
- `npm test` — **1657 pass / 0 fail / 1 skip** (совпадает с r1; продуктовый
код не менялся, расхождения не ожидалось).
- `npm run build` — зелёный; `git status --short` после сборки пуст → три
копии бандла (`dist/`, `custom_components/houseplan/frontend/`,
`demo/srv/assets`) совпадают с закоммиченными.
- `node scripts/check-docs.mjs` — не прогонял: дельта не трогает `src/**`
(только `demo/**`), условие обязательности гейта не выполняется.
- `npm run invariants` / `python -m pytest tests_backend` — не применимы:
геометрия, рёбра, `layout`-ключи решётки и `custom_components/**/*.py`
дельтой не затронуты.
- `npm run golden:verify` — не применим: диф не меняет рендер/геометрию/
стили/слои, только служебную фикстуру теста.
4. **`node scripts/smoke-select.mjs --base 08d56122 --head HEAD`** — вывод:
«Исполняемого frontend-диффа нет (`src/**/*.ts` не тронут). Browser-smoke
этим диффом не выбираются — это не "пропустить проверки", а "выбирать
нечего": смоки проверяют собранную карточку. Тронуто файлов: 2.» Полную
матрицу (209 файлов) не гонял: инструмент прямо говорит, что выбирать
нечего, диф — только фикстура одного уже названного АС3 смока, продуктовый
код не менялся.
5. **`node demo/smoke_device_position_history.mjs`** — прогнан явно на
`HEAD`: все 31 проверка зелёные, включая новую `reconnectKeepsHistory:
true`.
6. **Дисциплина «тест умеет падать» — проверена вручную, не принята на
слово автора.** В `_persistDevicePlacement` (`src/houseplan-card.ts:5246-5250`)
временно заменил
```
const pos = canonicalizePosition(this._layout[deviceId]);
if (contentFingerprint(pos) !== contentFingerprint(this._layout[deviceId])) {
this._layout = { ...this._layout, [deviceId]: pos };
}
pending = pos;
```
на
```
const pos = canonicalizePosition(this._layout[deviceId]); void pos;
pending = pos;
```
(тот же мутант, что описан в `device-echo-keeps-local-noncanonical`),
пересобрал (`npm run bundle:sync`) и перезапустил смок. Результат:
```
FAILED (5):
- localCopyEqualsTheWire: expected true, got false
- sameContentReloadKeepsHistory: expected true, got false
- ownEchoMatchesWhatWentOverTheWire: expected true, got false
- reconnectKeepsHistory: expected true, got false
- deleteEchoKeepsHistory: expected true, got false
```
Совпадает буквально с заявлением автора в тексте коммита («five checks
red… including reconnectKeepsHistory and deleteEchoKeepsHistory»).
`deleteEchoKeepsHistory` краснеет не потому, что ветка удаления сама
сломана — а потому, что предшествующий `_loadFromServer()` в этом же
прогоне уже обнулил `_devicePositionHistory` (расхождение реальное,
`canUndo` остаётся `false` до конца блока); при исправленном коде тот же
вызов ничего не чистит, и `deleteEchoKeepsHistory` проверяет свою
собственную ветку независимо — это подтверждено зелёным прогоном на
восстановленном коде (см. п.5). Каскад ожидаем и не обесценивает
проверку. После теста откатил файл (`git checkout -- src/houseplan-card.ts`,
`git status --short` снова пуст) и пересобрал (`npm run bundle:sync`),
вернув дерево в состояние `HEAD`; финальный прогон смока — снова все 31
зелёные (см. «Что проверено и корректно»).
7. **Трейлеры.** `git show f4c66bed` — `Issue: #397`, `User-Visible: no`.
Корректно: изменение затрагивает только тестовую фикстуру, видимого
поведения не меняет (сам видимый эффект уже описан в CHANGELOG коммитом
`d87cc298` из r1). Отдельной правки CHANGELOG не требуется и не добавлено.
## Закрытие раунда r1
| Находка r1 | Чем закрыта | Где это видно |
|---|---|---|
| **Medium-1**: AC3 не имел теста — `_adoptStructuralResponses`/`_loadFromServer` не упоминались ни в одном изменённом файле | `demo/smoke_device_position_history.mjs` теперь вызывает `await c._loadFromServer()` сразу после записи неканонической позиции и пишет `out.reconnectKeepsHistory`; серверный конфиг в фейковом WS зеркалит `c._serverCfg`, чтобы исключить `configChanged` как постороннюю причину очистки | `demo/smoke_device_position_history.mjs:296-304` (проверка), `:37-42` (мок `config/get`); падение подтверждено вручную (раздел «Как проверялось», п.6) — 5 красных, включая `reconnectKeepsHistory` |
| **Low-1**: AC1 доказан не тем способом, что назвало ТЗ (браузерный смок + текстовый юнит вместо юнита с перехватом `callWS`) | Не тронуто в этом раунде | Оставлено на усмотрение автора, как и предлагал r1; не блокирует |
| **Low-2**: план ТЗ (риски, п.3) просил отдельный юнит на «каноническая позиция не создаёт лишней записи» — теста нет, только код-гвард | Не тронуто в этом раунде | То же; гвард в коде не менялся и остаётся корректным (см. «Унаследовано») |
Low-1 и Low-2 не были предметом Medium и не требовали правки в этом раунде;
формально они остаются открытыми, но не блокируют — решение об их закрытии
(комментарием или правкой) остаётся за автором, как и было сказано в r1.
## Разбор по AC (только то, чего касается дельта)
- **AC3** (тот же инвариант для `_adoptStructuralResponses`, reconnect-путь)
— **выполнено**. Доказано смоком `reconnectKeepsHistory` через реальный
`_loadFromServer()`; падает без фикса, зеленеет с фиксом (проверено
вручную, не на слово). Пункт 5 плана автотестов ТЗ закрыт.
- **AC5б** (эхо удаления не чистит историю) — уже был «Выполнено» в r1;
затронут дельтой (та же `echoProbe`-позиция, тот же блок). Перепроверено:
зелёный на `HEAD`, независимо подтверждает свою ветку (без искусственной
зависимости от AC3 при исправленном коде — см. п.6 «Как проверялось»).
Остаётся выполненным.
- **AC7** (запись в полёте побеждает ответ сервера) — уже был «Выполнено» в
r1; использует тот же `echoProbe`, но собственные значения позиции
(`inFlightPos = {x:0.63,y:0.21}`), не изменённые этой дельтой. Перепроверено
прогоном — зелёный, `inFlightPositionWinsTheMerge: true`. Остаётся
выполненным.
## Унаследовано из r1
Документ: `docs/reviews/CODE-REVIEW-397-r1.md`, SHA `08d56122`. Принято без
повторной проверки в этом раунде (продуктовый код не менялся):
- **AC1** — порядок операций в `_persistDevicePlacement`, локальная запись
канонического значения раньше `_sentPos.set` и раньше `callWS`. Юнит
`test/device-position-echo.test.mjs` и смок `localCopyEqualsTheWire` не
тронуты дельтой, продуктовый код (`src/houseplan-card.ts:5226-5261`) тоже —
оснований перепроверять нет.
- **AC2** (`sameContentReloadKeepsHistory` + `ownEchoMatchesWhatWentOverTheWire`)
— файл-источник (`_reloadLayoutOnly`, `:4904-4948`) не менялся, фикстура в
этой части (использует `deviceId`, не `echoProbe`) дельтой не затронута.
- **AC4** (`remoteContentClearsHistory`) — то же самое, вне блока `echoProbe`,
код и фикстура в этой части не менялись.
- **AC5а** (`applyDevicePlacement(layout, id, null)` удаляет ключ до
фиксации отпечатка) — юнит-доказательство, продуктовый код не менялся.
- **AC6** (смок краснеет на коде до фикса) — доказательство r1 относилось к
прежней версии смока (3 красных проверки); в этом раунде переисполнено
заново на новой версии смока (см. «Как проверялось», п.6, 5 красных
проверок) — это не наследование, а повторное независимое доказательство,
что фиксирую отдельно, чтобы не создавать видимость слепого доверия там,
где дельта как раз и меняет предмет AC6.
- **Мутант `device-echo-keeps-local-noncanonical`**, регистрация в
`scripts/mutation-gate.mjs`, согласованность с `test/mutation-gate.test.mjs`
— файлы не в дельте, не перепроверялись.
- **Трейлеры и CHANGELOG коммита `d87cc298`** — не в дельте, r1 подтвердил.
- **Бандл-артефакты, актуальность скриншотов на момент `08d56122`** — вне
дельты по скриншотам; актуальность на `HEAD` подтверждена заново в этом
раунде (см. «Как проверялось», п.3 — `git status --short` пуст после
пересборки), а не унаследована слепо, так как сборка в любом случае
перезапускалась для falsification-теста.
## Что проверено и корректно
- `houseplan/config/get`-мок зеркалит `c._serverCfg`/`c._cfgRev` без
изменений → `configChanged` в `_adoptStructuralResponses` остаётся `false`
на протяжении всего сценария AC3, значит `reconnectKeepsHistory` отвечает
ровно на layout-вопрос, а не на посторонний config-вопрос.
- Проба `{x:0.024999999999999942, y:0.7/7}` — действительно неканоническая
(снапается на `0.025`/`0.1`), то есть чек не мог бы проходить «по случаю»,
как это было с прежним `{0.42,0.42}` (M1-класс дефекта, но для AC3, а не
только для AC2, — и он же теперь закрыт заодно).
- Финальный прогон смока на восстановленном `HEAD` — 31/31 зелёных, включая
`reconnectKeepsHistory`, `deleteEchoKeepsHistory`, `inFlightPositionWinsTheMerge`.
- Дерево репозитория после ручного falsification-теста восстановлено:
`git status --short` пуст, `git diff` пуст.
- Трейлеры `f4c66bed`: `Issue: #397`, `User-Visible: no` — корректно для
теста-only коммита.
## Чего не проверял и почему
- **GitHub-комментарии к issue #397** — MCP-инструмент `get_issue_comments`
отклонён средой (permission not granted) на трёх попытках подряд; вердикт
и контекст r1 восстановлены по коммитам и по самому документу
`CODE-REVIEW-397-r1.md`, чего достаточно для этого раунда — SHA и находки
там названы явно. Если в комментариях есть более свежее решение по Low-1/
Low-2 (например, автор их уже закрыл словом), эта информация не попала в
документ; на вердикт это не влияет, так как Low не блокирует независимо от
того, закрыт он явно или нет.
- **`node scripts/check-docs.mjs`** — дельта не трогает `src/**`, условие
обязательности гейта не выполняется.
- **`npm run invariants`, `python -m pytest tests_backend`** — геометрия,
`layout`-ключи решётки и `custom_components/**/*.py` не затронуты ни этой
дельтой, ни диапазоном в целом (r1 это тоже отметил).
- **`npm run golden:verify`** — диф не меняет рендер/геометрию/стили/слои.
- **Полная матрица `demo/smoke_*.mjs` (209 файлов)** — `smoke-select.mjs`
прямо ответил «выбирать нечего» (диф вне `src/**`); диф — фикстура одного
уже названного АС смока, поверхность не расширена.
- **`scripts/mutation-gate.mjs` (полный/точечный)** — не в дельте, не
перезапускал; r1 уже подтвердил регистрацию и согласованность
(`test/mutation-gate.test.mjs`, 10/10) на неизменном с тех пор файле.
- **HA-харнесс** — в песочнице нет Home Assistant; предрелизный гейт, не
гейт ревью.
## Вывод
Medium-1 закрыт доказательно (не на слово автора — falsification
воспроизведена и совпадает буквально: 5 красных проверок, включая
`reconnectKeepsHistory`). AC3 выполнен. Побочно исправлена скрытая слабость
доказательства AC5б (прежняя каноническая проба `0.42` не могла бы поймать
регрессию своей же ветки — теперь может, за счёт общей неканонической
`echoProbe`-позиции). Low-1 и Low-2 остаются открытыми, но не блокирующими,
как и в r1. Продуктовый код не менялся, три копии бандла синхронны,
трейлеры корректны, User-Visible: no — CHANGELOG не требуется. Находок в
скоупе задачи, требующих новой правки, нет.
**Вердикт: зелёный.**