Files
houseplan-card/docs/reviews/CODE-REVIEW-545-r1.md
T
2026-09-12 20:36:47 +00:00

209 lines
15 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 — issue #545, заход r1
Материал: `81daebd788c532f9d0e321010ba471282ae076d1` (рабочая копия на нём же,
`git fetch`/`checkout` не выполнялись). Диапазон `origin/dev..HEAD` — один
коммит `81daebd7 fix: complete radar source inventory (#545)`.
## Скоуп диффа
```
custom_components/houseplan/radar_validation.py | 31 ++-
docs/ARCHITECTURE.md | 7 +
docs/CHANGELOG.md | 5 +
docs/CHANGELOG.ru.md | 5 +
docs/TESTING.md | 12 +
scripts/mutation-gate.mjs | 17 ++
tests_backend/test_ha_radar.py | 64 ++++-
tests_backend/test_ha_radar_websocket.py | 170 ++++++++++-
tests_backend/test_radar_validation.py | 62 ++++
9 files changed, 358 insertions(+), 15 deletions(-)
```
`radar.py` и `radar_websocket.py` **не менялись** — оба уже читали общий
inventory через `radar_source_entity_ids()`/`coordinator.source_ids()`,
поэтому исправление одного extractor'а автоматически чинит подписки
coordinator, setup-подписку и ACL. Соответствует «Затронутые файлы» ТЗ
(«radar.py/radar_websocket.py — только если потребуется», и не
потребовалось).
## Как проверялось
Прочитан весь диффнутый код построчно (`radar_validation.py`,
`mutation-gate.mjs`, три backend test-модуля, доки), плюс непосредственно
задействованные, но не изменённые call sites в `radar.py` (`source_ids()`,
`_resubscribe_sources`) и `radar_websocket.py` (`_can_read`,
`ws_radar_subscribe`, `ws_radar_setup_inspect`, `ws_radar_setup_subscribe`),
чтобы убедиться, что фикс действительно долетает до подписок и ACL, а не
только до валидации.
Дешёвые гейты уже подтверждены Validate на этом SHA (ссылка в хендоффе,
run 34716865575) — не перегонялись:
- `Фронтенд: типы, юниты, мутанты, синхрон бандла` → success (покрывает
`tsc --noEmit`, `npm test`, `npm run build`, `no-new-any`).
- `Бэкенд: pytest в Home Assistant` → success (полный `tests_backend`, а не
urlезанный набор без HA — так что backend действительно доказан, а не
тихо пропущен).
- `Hassfest`, `HACS` → success.
- `Мутанты по диффу` (все 6 шардов) → success. Проверил лог шарда 6/6
напрямую (`gh run view --job 103616172006 --log`):
```
ok radar-profile-source-entity-id-omitted: тест покраснел, как обязан
```
Это независимое подтверждение AC5 из самого CI-лога, а не только со слов
автора.
- `Golden`, `Смоки`, `Перф-смок` → skipped (тяжёлые гейты, диффом не задеты
`src/**`/рендер — корректно не входят в обычный push).
Не прогонял сам: `npx tsc --noEmit`, `npm test`, `npm run build`,
`pytest tests_backend`, `mutation-gate` — все уже зелёные на этой SHA
(см. выше), перегонка добавила бы только время. `check-docs.mjs` и golden
не нужны: диффом не тронут ни один файл `src/**`, экран/рендер не меняется.
`npm run invariants` не нужен: геометрия (`layout`, `marker.space`,
`open_spans`, толщина стен) диффом не тронута — меняется только
radar source inventory, HA-подписки и ACL. Браузерные smoke не нужны по той
же причине (нет фронтенд-файлов в диффе; `smoke-select.mjs` не запускал —
diff не содержит ни одного файла, который мог бы задеть выборку по
символам, а тема задачи (backend radar ACL) не пересекается с
рендер-путём).
## Проверка по AC
- **AC1 (exact profile inventory).** `_PROFILE_SOURCE_ROLES` в
`radar_validation.py:23-32` — явная таблица `profile → (group, roles)`,
заменяющая эвристику `key.endswith('_entity')`. Значения совпадают
построчно с контрактом ТЗ п.3: `range_v1` → `("entity_id",
"presence_entity")`, `zones_v1` → `("entity_id",)`, слоты — `x/y/distance/
angle_entity` + optional `presence_entity`, `presence_v1` — без вложенных
ролей. Параметризованный тест
`test_stage1_source_inventory_is_exact_for_each_profile`
(`test_radar_validation.py`) доказывает точный set для всех шести
profiles, включая контрольный `future_entity`-мусор внутри common и
вложенной группы — он остаётся вне set. Отдельный тест на неизвестный
`profile: "future_v2"` возвращает `set()` целиком (соответствует
контракту п.1: неизвестный профиль не даёт даже общих ролей). Доказано
автотестом, тест умеет падать (см. AC5 mutation witness — красный на
урезанном контракте).
- **AC2 (setup live-update range/zone).**
`test_setup_subscribes_to_range_and_zone_primary_sources` в
`test_ha_radar_websocket.py` гоняет реальный
`ws_radar_setup_subscribe` (мокаются только `_coordinator` и
`async_track_state_*`/`may_write`, как и в остальных тестах файла — то
есть production-shaped путь). Для `range_v1`/`zones_v1` с единственным
`entity_id`-источником (без occupancy/count/presence) проверяет, что оба
HA-стрима (`state_report`, `state_change`) подписаны именно на этот
entity, и что вызов callback приводит к новому `send_result` через
ограниченный `1/MAX_FRAME_HZ` коалесер — без переоткрытия. Проверены и
saved, и draft путь в одном тесте. AC выполнен.
- **AC3 (saved coordinator + lifecycle).**
`test_range_and_zone_primary_source_subscriptions_rebind_and_teardown` в
`test_ha_radar.py` — реальный `RadarCoordinator`, монkeypatch только на
`async_track_state_report_event`/`async_track_state_change_event`.
Проверяет: `coordinator.source_ids("radar")` содержит `entity_id`
range/zone; при rebind (смена документа + `async_refresh`) старые
подписки полностью отписаны (`cleaned`), новые — ровно на новый entity;
`teardown()` снимает оставшиеся. AC выполнен.
- **AC4 (ACL fail-closed).**
`test_range_and_zone_primary_sources_are_permission_checked_fail_closed`
использует записывающий `_Permissions.checked` (пер-entity, а не булев
allow/deny — именно то, что в «Рисках» ТЗ названо необходимым, чтобы тест
не проходил по общему allow/deny). Deny выставлен точно на
range/zone `entity_id`; проверены `ws_radar_setup_inspect` (saved и
draft) и `ws_radar_subscribe` (live) — во всех трёх ветках получен
`source_restricted`/`health: restricted` с пустыми `targets/ranges/zones`
(секретные `secret-range`/`secret-zone` из фикстуры `frames_for_space` не
утекают). AC выполнен.
- **AC5 (mutation witness).** Новый мутант
`radar-profile-source-entity-id-omitted` в `mutation-gate.mjs` вырезает
`entity_id` из обеих ролей (`range_v1`, `zones_v1`) и указывает guard'ом
именно новый параметризованный тест. Проверено чтением: удаление роли
ломает assertion точного set в тесте → тест обязан покраснеть. Проверено
исполнением — CI-лог шарда 6/6 (`Мутанты по диффу`, run 34716865575)
прямо печатает `ok radar-profile-source-entity-id-omitted: тест
покраснел, как обязан`. Двойное (чтением + исполнением) подтверждение.
- **AC6 (bounded runtime).** `radar.py` не менялся: coordinator по-прежнему
регистрирует ровно два агрегированных listener'а
(`_resubscribe_sources`, строки не тронуты), `MAX_FRAME_HZ` не менялся.
Setup-подписка в `radar_websocket.py` — тоже два listener'а на marker
(не менялась). Никаких polling/timer на source не добавлено — сам fix
расширяет только *set* уже читаемых entity, не механизм подписки.
Проверено чтением (файлы, реализующие лимиты, не в диффе).
- **AC7 (совместимость и артефакты).** Схема `marker.radar` не менялась
(только внутренний Python-словарь ролей). `docs/ARCHITECTURE.md`
описывает единый inventory-контракт как основу для listeners/ACL;
`docs/TESTING.md` называет три guard-модуля и мутанта; оба changelog
(`CHANGELOG.md`, `CHANGELOG.ru.md`) обновлены в том же коммите, без
раскрытия security-чувствительных деталей (совпадает с «Release
Metadata» ТЗ). Frontend/i18n не тронуты. Трейлеры коммита: `Issue: #545`,
`User-Visible: yes` — оба changelog присутствуют, условие выполнено.
## Что проверено чтением, но не отдельным тестом
- `_immediate_source_ids` в `radar.py:197-213` — отдельная, не
происходящая из нового `_PROFILE_SOURCE_ROLES`, ручная классификация
«мгновенных» источников (используется только для решения
immediate-vs-coalesced публикации, не для полноты подписки/ACL — та
берётся из `radar_source_entity_ids()` несколькими строками выше и уже
чинится этим диффом). Файл не тронут этим коммитом и был идентичен уже
на аудиторском SHA `66a64854` — то есть это существующее, не внесённое
этой задачей поведение. У неё есть асимметрия: `ranges[].presence_entity`
считается «мгновенным», а сам `ranges[].entity_id` — нет (`zones[].entity_id`
— считается). ТЗ прямо разрешает оставить отдельную политику для
immediate-классификации («Принято предположительно», п.2) и явно требует
сохранить существующую семантику немедленных/коалесцированных обновлений
(контракт п.9) — эта асимметрия под неё подпадает, а не под AC2/AC3
(те про полноту подписки/ACL, доказанную тестами выше). Не блокирует;
фиксирую как наблюдение, не находку — правка вне скоупа этой задачи и не
меняет ни completeness инвентаря, ни ACL.
## Находки
Нет ни High, ни Medium, ни Low. Диф точный, доказательства по каждому AC —
автотестом с проверкой «умеет падать» (AC1, AC5 — двойное подтверждение
чтением и исполнением CI), либо чтением production-shaped теста, который
реально гоняет проверяемый код (AC2-AC4, AC7), либо чтением незатронутого
кода с обоснованием, почему он не должен был измениться (AC6, наблюдение
по `_immediate_source_ids`).
## Не проверял и почему
- `npx tsc --noEmit`, `npm test`, `npm run build`, `pytest tests_backend`,
`mutation-gate.mjs --check`/`--changed` — не перегонял: Validate на точном
SHA `81daebd7` зелёный, включая полный backend HA harness и все 6 шардов
diff-мутантов (проверено по логу, не только по статусу).
- `golden:verify`, `demo/smoke_*.mjs`, performance-профили — не запускал:
диффом не тронут ни один файл `src/**` и ни один рендер-путь; Validate
корректно пропустил (skipped) golden/smoke/perf как тяжёлые гейты для
обычного пуша без `Release:`-трейлера.
- `npm run invariants` — не запускал: диффом не тронуты геометрия,
`layout`, `marker.space`, `open_spans` или толщина стен.
- Ручного тестирования в UI не проводил (задача backend-only, UI не
меняется по контракту ТЗ и подтверждено отсутствием файлов `src/**` в
диффе).
## Вердикт
Зелёный. Все AC1–AC7 доказаны либо исполняемым тестом с подтверждённой
способностью падать, либо чтением незатронутого кода с явным обоснованием.
Единственное наблюдение (`_immediate_source_ids` асимметрия) — не находка:
предсуществующий код вне скоупа, явно разрешённый ТЗ оставить как есть.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/545-radar-source-inventory`, коммит `81daebd788c5` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `f8ce151d796b63484e9b8b59865488102273291a`
```
git log --all --format='%H %T' | grep f8ce151d796b
```
- Тело issue: `8b063fe89abbf48179a894847dff1b5c223f394fa4f6e98ec284302624b71437`
- Вердикт конвейера: `green` · High 0