Files
houseplan-card/docs/reviews/CODE-REVIEW-397-r2.md
T
2026-08-31 00:15:10 +00:00

253 lines
21 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-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 не требуется. Находок в
скоупе задачи, требующих новой правки, нет.
**Вердикт: зелёный.**