Files
houseplan-card/docs/reviews/CODE-REVIEW-485-r3.md
T
2026-09-09 03:54:09 +03:00

285 lines
27 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-485-r3
Issue: [#485](https://github.com/Matysh/houseplan-card/issues/485) — presence-radar
Stage 1. Этап: code (PROCESS.md §2.7). Заход r3, блокирующих циклов израсходовано
2 из 4 до этого раунда (r1 — красный, High 6/Medium 6; r2 — жёлтый, High 0/Medium 3).
Материал: ветка `issue/485-radar-presence`, `git log --oneline origin/dev..HEAD` /
`git diff origin/dev...HEAD`, HEAD = `f474793d9b5fb050be21e2f6273f723fbd009f7a`
("docs: record radar field evidence"), дерево `33297f2255a82b82d99f6269cb38d4f13cd94fe9`.
## Почему разбор полный, а не по дельте (PROCESS §2.10)
Вердикт r2 (комментарий issue) не называет SHA прямо, но документ
`docs/reviews/CODE-REVIEW-485-r2.md` называет его в блоке «Материал раунда»:
HEAD `1e6104100cc76195c3a05aa62e67af84793d9fd1`, дерево `da6ff25c3f68…`.
Оба мертвы в текущей истории: `git cat-file -t 1e610410…` →
`fatal: could not get object info`; `git log --all --format='%H %T' | grep
da6ff25c3f68` → пусто. Это само по себе не находка (§2.10 п.2), но проверка
показала больше, чем обычный мёртвый SHA:
- committer-время **всей** цепочки коммитов ветки, от самого первого
`cefa557d` (спецификация) до `a2b99e4e` (правки этого раунда), сбито в один
узкий интервал `02:57:02–03 +0300` 09.09 — признак сквозной переигровки всей
ветки набором `git rebase`, а не точечного `amend`;
- `git merge-base origin/dev HEAD` = `687056f3` (`ci: check-inputs…`,
09.09 02:29:08) — это **позже**, чем committer-время r2 (`~00:10` по
таймстампу issue-комментария вердикта); между r2 и этим раундом `dev`
реально продвинулся на посторонние задачи (#490, #491, #492, #503 и
CI-инфраструктуру), а не просто был переpull-ен без изменений;
- пересечение путей `git diff --name-only <dev-до-r2>..<dev-сейчас>` с путями
ветки радара даёт непустой список, включая **тот самый файл**, где лежит
находка M-2 из r2: `custom_components/houseplan/websocket_api.py`, а также
`custom_components/houseplan/__init__.py`, `store.py`, `src/houseplan-card.ts`.
Это прямо названный в процессе триггер («ребейз на ушедший вперёд dev — после
ребейза это другой код»), поэтому ниже — полный повторный разбор, а не только
диффа по находкам r2. Пересечение путей проверено построчно (раздел «Что
проверено» ниже) — конфликтов смысла ребейз не внёс.
## Как проверялось
Прочитан весь diff `origin/dev...HEAD` (118 файлов) и построчно — фактический
новый код раунда, коммит `a2b99e4e` («fix: close radar review gaps»,
`8dec5be0` — только отпечаток скриншотов, `f474793d` — docs-only). Каждая
находка r1 и r2 перепроверена по актуальному коду (таблица «Закрытие раунда
r2» ниже), с точечным чтением на пересечённых с `dev` файлах.
**Прогнанные гейты:**
| Гейт | Результат |
|---|---|
| `npx tsc -p tsconfig.test.json` / `npm run build` (`tsc --noEmit && rollup`) | зелёные |
| `node --test test/*.test.mjs` (весь фронтенд-набор) | 2370 passed, 1 skipped, 0 failed (2371 всего) |
| `npm run bundle:sync` | дерево `dist` → `custom_components/houseplan/frontend` → `demo/srv/assets` синхронизировано, `git status` чист до и после — бандл воспроизводим побайтно |
| `npm run bundle:budget` | 0 (initial View 298 481 Б / потолок 299 000; предупреждение о запасе — унаследованный долг #367→#474, не новый в этом раунде) |
| `node scripts/no-new-any.mjs --base origin/dev --head HEAD` | «Новых any нет» (2078 строк / 14 файлов) |
| `node scripts/check-docs.mjs` | «Documentation checks passed» — отпечаток скриншотов актуален после `8dec5be0` |
| `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | 60 прямых совпадений + 1 зарегистрированная связь — см. обоснование ниже |
| `node demo/smoke_radar_setup.mjs` | OK, все поля отчёта `true` |
| `node demo/smoke_radar_live.mjs` | OK, все поля отчёта `true` |
| `python3 -m pytest tests_backend/ -q --ignore-glob='*/test_ha_*'` (чистый Python 3.12 + `pip install pytest voluptuous`, без HA) | **451 passed, 4 skipped** — включает весь `test_radar_geometry.py` (14/14) и весь `test_radar_validation.py` (42/42) |
| Ручная мутация трёх ранее непокрытых гвардов решателя (M-1 из r2): снятие дистанции ≥50см, RMS≤20/индивид.≤30, неоднозначности зеркала <10см | все три — **красные** (`DID NOT RAISE`) на соответствующих новых тестах `test_radar_geometry.py::test_two_point_fit_enforces_reference_distance_and_rms_guards` / `test_two_point_fit_rejects_ambiguous_mirror_candidates` |
| Ручная мутация четвёртого гварда — `radial_ok`/`radialOk` (backend и frontend) | **не красит ни один тест** — новая находка M-1 (см. ниже) |
| `node --test test/i18n-dead-keys.test.mjs test/i18n.test.mjs test/single-source-numbers.test.mjs` | 28/28, 3/3 — зелёные |
| Проверка ключей i18n `en/ru/de/fr` (`node -e` обход дерева) | 1363/1363 ключей, 0 расхождений во всех трёх переводах |
| `git diff --check` | чисто |
| Трейлеры (`git show -s --format=full`) трёх новых коммитов раунда | `Issue: #485` на всех; `a2b99e4e` — `User-Visible: yes` + правки `docs/CHANGELOG.md`/`.ru.md` в том же коммите; `8dec5be0`/`f474793d` — `User-Visible: no` |
| `npm run invariants` | не запускался — diff не трогает рёбра комнат/толщину/`layout`/`marker.space`/`open_spans` (`grep` по всем изменённым backend/frontend radar-файлам — 0 совпадений; `radar.room_id` только ссылается на существующую комнату) |
CI Validate на точном SHA `f474793d` **на момент написания документа не
завершён** (запуск `34293375714`, стартован автором): фронтенд/HACS/hassfest/
2 из 3 job «Мутанты по диффу» зелёные, `backend`/`perf`/`golden`/`смоки` ещё в
очереди. Не дожидался — гейты выше воспроизведены самостоятельно, а
эволюция того же прогона не меняет уже увиденный код.
## Находки
### High — нет
### Medium (в скоупе задачи — чинится в этой же ветке)
**M-1 (новая). Четвёртый числовой гвард двухточечного решателя —
согласованность радиальной дистанции — не покрыт ни одним тестом ни в
backend, ни в его TS-двойнике, и это не теоретический, а конкретно
воспроизводимый пропуск.**
`custom_components/houseplan/radar_geometry.py:196-197` (симметрично
`src/radar-geometry.ts` — `radialOk`, строки 70-74) требует, чтобы для каждой
референсной точки радиальное расстояние от датчика совпадало с ожидаемым
(вычисленным по плану) в пределах `max(20см, 15% от ожидаемого)`. Это ЧЕТВЁРТЫЙ
из четырёх гвардов, которые сам документ r2 перечислил как «несёт четыре
защиты» — но фикс M-1 из r2 (`a2b99e4e`) добавил тесты только на три
(дистанция ≥50см, RMS/индивидуальная ошибка, неоднозначность зеркала); заявление
r2 «Симметричная TS-реализация... все четыре гарда покрывает» неточно — её
`radialOk` тоже не тестируется отдельно.
*Проверка (снятие гварда не красит ни один тест):*
```
python3 -m pytest tests_backend/test_radar_geometry.py tests_backend/test_radar_validation.py -q
# с изменённым radar_geometry.py: `if radial_ok and rms <= 20 and max(errors) <= 30:`
# → `if rms <= 20 and max(errors) <= 30:`
# результат: 14 passed / 42 passed — без единого падения
```
Тот же результат для `src/radar-geometry.ts` (`radialOk &&` вырезано):
`node --test test/radar-geometry.test.mjs test/radar-setup.test.mjs
test/radar-editor.test.mjs test/radar-model.test.mjs test/radar-render.test.mjs`
→ 28/28 без изменений.
*Это не редундантный гвард — конкретный воспроизводимый случай, который он
один и ловит:* локальные точки `(100,0)` и `(0,100)` (валидный угол 90°),
целевые точки на плане — для второй референс идеален, для первой радиальная
дистанция сознательно завышена на 25 см (`(125,0)` вместо `(100,0)`, то есть
пользователь верно взял направление, но неверно — расстояние). Результат
незеркального решения: `rms_cm ≈ 17.68` (≤20 — проходит), `errors_cm =
[25.0, 0.0]` (оба ≤30 — проходит), но `radial_ok = False` (диапазон допуска
20см, разница 25см) — сейчас это **правильно отклоняется** как
`invalid_selection`; без гварда калибровка была бы принята с сообщением об
успехе и `rms_cm≈17.68`, то есть пользователь получил бы «хорошую» по RMS
калибровку с реально неверным расстоянием эталона — ровно тот класс ошибки
(верное направление/неверное расстояние), который field-данные автора запроса
(раздел «Полевые данные», п. 11) описывают как источник необнаруживаемого на
глаз смещения. Доказательство — арифметическое: `|hypot(local) −
hypot(target)| ≤ euclidean_error` (обратное неравенство треугольника, поворот
сохраняет норму), поэтому радиальная ошибка ограничена сверху потолком
индивидуальной ошибки (30см) — окно `(20см, 30см]` для близких точек (где
`15%·ожидаемое < 20`) реально достижимо и не отсекается двумя другими
гвардами. Не имитация, а математически гарантированно непустой класс входов.
*Почему Medium, а не High:* сам гвард в продуктовом коде уже есть и сейчас
работает правильно — это пробел в тестовом покрытии (риск тихой регрессии
при будущем рефакторинге того же файла), а не действующий сегодня дефект.
Чинится добавлением ровно такого теста на обеих сторонах (backend +
`test/radar-geometry.test.mjs`) в этой же ветке.
## Закрытие раунда r2
| Находка r2 | Чем закрыта | Где видно |
|---|---|---|
| M-1 (продолжение M6 r1) — 3 из 4 числовых гвардов решателя не тестированы | `tests_backend/test_radar_geometry.py::test_two_point_fit_enforces_reference_distance_and_rms_guards` (дистанция, RMS/индив. ошибка) и `::test_two_point_fit_rejects_ambiguous_mirror_candidates` (зеркало); зеркальный TS-аналог в `test/radar-geometry.test.mjs:40-58` | Подтверждено ручной мутацией всех трёх защит — каждая красит ровно свой новый тест (см. таблицу гейтов). **Четвёртый гвард (`radial_ok`) этим фиксом не закрыт — новая находка M-1 выше** |
| M-2 — `radar_stage1_api` объявлялся константой независимо от состояния координатора | `custom_components/houseplan/websocket_api.py:1460` — `**({"radar_stage1_api": 1} if rt.radar_coordinator is not None else {})` вместо безусловного `1` | Прочитано по коду; новый HA-тест `tests_backend/test_ha_websocket.py::test_config_get_advertises_radar_only_while_coordinator_is_ready` переключает `runtime.radar_coordinator = None` и обратно, проверяя исчезновение/появление ключа — тест не запущен лично (HA недоступна в песочнице ревью, см. «Чего не проверял»), но прочитан и логически годен как «чем краснеет» |
| M-3 — `radar.bad_references` объявлен, но недостижим; неоднозначная и «близко к монтажу»/коллинеарная ошибки схлопывались в общее `bad_fit` | `src/radar-geometry.ts:47,53` теперь бросает `'bad_references'` для дистанции <50см и коллинеарного угла (было `'invalid_selection'`); `src/radar-setup.ts:333-334` маппит `'bad_references'` → `'radar.bad_references'` отдельно от `'radar.bad_fit'` | `test/radar-geometry.test.mjs:37,45` (`/bad_references/` вместо `/invalid_selection/`) и новый `test/radar-setup.test.mjs` тест «calibration reports bad reference placement separately from a measurement mismatch» — прогнаны лично, оба зелёные; RMS-провал по-прежнему даёт `radar.bad_fit` отдельно (та же проверка) |
Все 6 High из r1 подтверждены присутствующими в текущем (пост-ребейз) коде
построчным чтением на тех же местах, что называл документ r2 (health
`partial`/`stale` — `radar.py:552-554`; teardown/lock — `radar.py:106-151`;
same-device LD2450 — `radar_validation.py:147-180`, дополнительно
перепроверено прогоном 42/42 в `test_radar_validation.py`; zones render —
`radar-render.ts:18-24`; `radarAfterBindingChange` —
`radar-editor.ts:65-75`/`houseplan-editor-runtime.ts:13015,13071`) — ребейз не
затронул ни один из этих файлов посторонними изменениями из `dev`.
## Что проверено сверх таблицы закрытия
- **Пересечение путей ребейза с веткой радара безопасно.** `dev` продвинулся
на `src/houseplan-card.ts` (несвязанная правка #490 — восстановление
`_capturedSnapshotSequence`/сбор `entityIds` сводной панели, другие строки),
`custom_components/houseplan/__init__.py`/`store.py` (рефакторинг
восстановления прерванной парной записи, #491) и
`custom_components/houseplan/websocket_api.py` (та же строка, что правит
M-2 этого раунда). Порядок вызовов не изменился: `radar_coordinator =
RadarCoordinator(...); await async_setup()` по-прежнему стоит **до** блока
восстановления прерванной пары (`async with data.write_lock:
async_resolve_pending_pair(...)`) — так было и до ребейза (радар всегда
инициализировался раньше этого блока в r1/r2 коде), это не новый риск.
Радар получает свежие данные после восстановления через уже существующую
подписку на `houseplan_config_updated`, которую сам блок восстановления и
генерирует при `optimize_revs is not None`.
- **i18n-структура заново пересчитана, не унаследована с доверием**: обход
дерева всех четырёх словарей построчно (`node -e`) — 1363/1363 ключей,
0 расхождений `ru`/`de`/`fr` от `en`.
- **Трейлеры и changelog** обоих `User-Visible`-коммитов раунда проверены
форматом `git show -s --format=full`.
- **Backend вне HA-харнесса зелёный практически полностью**: в отличие от r2
(которая не смогла запустить ни одного backend-теста в песочнице), в этой
песочнице системный Python 3.12 уже нёс рабочий `sqlite3`; после `pip
install pytest voluptuous` весь `tests_backend/`, кроме файлов
`test_ha_*.py` (жёстко пропускаемых без пакета `homeassistant` самим
`conftest.py`), прогнан лично — 451 passed. Это заметно сильнее прежнего
раунда: `test_radar_validation.py` (42, включая H3 same-device) и
`test_radar_geometry.py` (14, включая новые M-1-из-r2 тесты) выполнены, а
не только прочитаны.
- **Один источник числа**: новая правка (`a2b99e4e`) не добавляет ни одной
новой отображаемой величины — только маршрутизацию сообщений об ошибке и
видимость булева капабилити; регрессии класса #234/#233 не создаёт.
`test/single-source-numbers.test.mjs` — 3/3.
- **`smoke-select` даёт 60 «широких» совпадений** — это артефакт полного
диффа `origin/dev...HEAD` (вся ветка целиком, как и в r2), а не нового
кода этого раунда: символы `_markerDialog`/`_editorRuntime`/`_mode`/
`_cfgRev`/`cellCm` происходят из уже провёренных в r1/r2 файлов
(`marker-dialog-close.ts`, `houseplan-editor-runtime.ts`), которые
фактический дифф `a2b99e4e` не трогает вовсе (он меняет только
`radar-geometry.ts`/`radar-setup.ts`/`websocket_api.py`/тесты). Прогнаны
два **прямых** совпадения, специфичных для радара
(`smoke_radar_setup.mjs`, `smoke_radar_live.mjs`) — оба зелёные; остальные
58 широких совпадений и 1 зарегистрированная связь (`smoke_dialog_modal_recovery.mjs`)
относятся к коду диалога, который этот раунд не менял и который r2 уже
прогнала прицельно (`smoke_marker_stay`, `smoke_dialog_zombie`,
`smoke_new_device`, `smoke_toggle_entity` — все зелёные в r2, код с тех пор
не менялся).
## Чего не проверял
- **`python -m pytest tests_backend/test_ha_radar.py
tests_backend/test_ha_radar_websocket.py tests_backend/test_ha_websocket.py
-q` (HA-харнесс) лично не прогонял.** В песочнице ревью нет ни установленного
`homeassistant`, ни `.venv-backend`; `tests_backend/requirements.txt`
требует Python ≥3.14 для `pytest-homeassistant-custom-component`, в
песочнице — 3.12. Здесь же лежат H1/H2/H3(частично)/H6 из r1 и новый тест
M-2 этого раунда. Опираюсь на: (а) построчное чтение каждого — включая
новый `test_config_get_advertises_radar_only_while_coordinator_is_ready`,
который реально переключает `radar_coordinator` на `None` и обратно, а не
просто спрашивает поле; (б) автор в issue сообщил результат «180 passed» в
WSL/HA на этом же SHA (`5590037034`) — это заявление автора, а не
независимое доказательство, поэтому не заменяет проверку; (в) CI Validate
на этом SHA (`34293375714`) стартован, но job «Бэкенд» ещё не приступал на
момент публикации документа. **В отличие от r2, у этого раунда пока нет
готового зелёного Validate, на который можно сослаться как на замену
прогону** — если к моменту слияния job «Бэкенд» на `f474793d` окажется
красным, это отменяет вердикт независимо от прочтения кода.
- **Полный `demo/smoke_*.mjs` (234 файла)** не гонял — обоснование выбора двух
прямых радар-смоков и опоры на уже прогнанные в r2 нерадарные смоки диалога
— раздел «Что проверено» выше. Полный матрикс — предрелизная обязанность
(PROCESS §8).
- **`npm run golden:verify`** не прогонял — diff этого раунда не трогает
рендер (только сообщения об ошибке и видимость капабилити); r2 уже
зафиксировала отсутствие golden-сцен для радара вообще (Low, не растёт до
Medium третий раз подряд по той же логике: защитные утверждения уже
доказаны прицельными негативными тестами).
- **Perf-профили** — не запускал, diff не касается `iso-*`/`live-*`/
`render-*`/`houseplan-render-lifecycle.ts`, AC этой стадии не называют
perf-профиль отдельным доказательством.
- **`node scripts/mutation-gate.mjs`** не запускал локально — CI-джобы
«Мутанты по диффу» уже покрывают ровно эту роль для реального диффа против
`dev`; 2 из 3 job зелёные на момент документа, третья не завершена. Ручная
мутация гвардов решателя (таблица гейтов, находка M-1) — независимая
подмена того же по духу свидетельства для мест, которые diff-скоуп
инструмента мог не задеть (`radial_ok` не находится на изменённых
диффом `a2b99e4e` строках).
## Итог
0 High. 1 Medium в скоупе — новая находка M-1 (четвёртый гвард
`solve_two_point`/`solveRadarTwoPoint`, `radial_ok`/`radialOk`, не покрыт ни
одним тестом ни в backend, ни во фронтенде; воспроизведён конкретный входной
случай, который гвард сегодня корректно отклоняет, а без теста будущий
рефакторинг может тихо снять). Все три Medium из r2 (M-1 продолжение M6,
M-2 капабилити `radar_stage1_api`, M-3 мёртвый `radar.bad_references`)
закрыты предметно и подтверждены прогоном (кроме HA-зависимой части M-2,
проверенной чтением — см. «Чего не проверял»). Все 6 High из r1 подтверждены
присутствующими после ребейза. Вердикт — жёлтый: без High это возврат
автору на один точечный тест-фикс (backend + TS) по уже существующему,
корректно работающему гварду, не по архитектуре.
<!-- material-anchors: заполняется конвейером публикации -->
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/485-radar-presence`, коммит `f474793d9b5f` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `33297f2255a82b82d99f6269cb38d4f13cd94fe9`
```
git log --all --format='%H %T' | grep 33297f2255a8
```
- ТЗ `docs/specs/485-radar-presence-stage1.md`, блоб `322f040bfcfdba41802caad302da99188ab9d710`
```
git log --all --find-object=322f040bfcfdba41802caad302da99188ab9d710 -- docs/specs/485-radar-presence-stage1.md
```
- ТЗ `docs/specs/485-radar-presence-stage2.md`, блоб `b403e73fc26ba0ffb97be4ffa73be067d179880f`
```
git log --all --find-object=b403e73fc26ba0ffb97be4ffa73be067d179880f -- docs/specs/485-radar-presence-stage2.md
```
- ТЗ `docs/specs/485-radar-presence-stage3.md`, блоб `c65e28389d762216c47f00249bdb751f3c9d5082`
```
git log --all --find-object=c65e28389d762216c47f00249bdb751f3c9d5082 -- docs/specs/485-radar-presence-stage3.md
```
- ТЗ `docs/specs/485-radar-presence.md`, блоб `3297ed0140265ad86f2072a40cb9bf8097c0674d`
```
git log --all --find-object=3297ed0140265ad86f2072a40cb9bf8097c0674d -- docs/specs/485-radar-presence.md
```
- Вердикт конвейера: `yellow` · High 0