Files
houseplan-card/docs/reviews/CODE-REVIEW-162-r1.md
T
2026-09-03 16:47:41 +00:00

315 lines
25 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.
# Код-ревью #162 — заход r1
- Issue: [#162](https://github.com/Matysh/houseplan-card/issues/162)
- Этап: code (PROCESS.md §2.7)
- Ветка: `issue/162-vacuum-map-space-routing`, HEAD `4032b810`
- ТЗ: `docs/specs/162-vacuum-map-space-routing.md`, утверждено на редакции 2 / spec-review r2, SHA `4c56b389`
- Заход: r1 (первый код-ревью для этого issue) · блокирующих циклов израсходовано 0 из 4
## Вердикт
**Красный.** High: 1, Medium: 1 (оба в скоупе задачи, чинятся в ней же).
## Скоуп проверки
Материал: `git log --oneline origin/dev..HEAD` (11 коммитов) и
`git diff origin/dev...HEAD` (71 файл). Ревью полное (не дельта): это первый
код-ревью цикл для #162.
Прочитано и сверено построчно:
- `src/vacuum-routes.ts` и питоновское зеркало `custom_components/houseplan/vacuum_routes.py` —
типы, `validateMarkerRoutes`/`validate_marker_routes`, `effectiveRoutes`/`effective_routes`,
`resolveRoute`/`resolve_route`, `adoptLegacyRun`/`adopt_legacy_run`, `planVacuumOverlay`,
`routeWarningKey`;
- `src/vacuum-route-edit.ts` — `addRoute`, `removeRoute`, `changeRouteSpace`,
`saveRouteCalibration`, `convertLegacyRoutes`, `writeVacuumMatrix`, `planVacuumFit`,
`calibrationTarget`;
- `src/houseplan-card.ts` — снимок фактов (`facts.set('vacuum:<id>', …)`), новая
`_renderVacuums` (рендер по `route.space`, а не по фильтру устройств дока),
`_vacRouteBadge` (amber warning);
- `src/houseplan-editor-runtime.ts` — `_vacSaveMatrix`, `_vacAutoCalibrate`,
`_vacApplyCalibrationProposal`, `_vacStartFit`, `_vacFitSave` (весь путь калибровки);
- `src/editors/vacuum-maps-section.ts` — весь UI блока «Карты и этажи»;
- `src/space-deletion.ts` — `routesIntoSpace`, `collectSpaceMarkerDependencies`,
`createSpaceDeletionCandidate` (AC16);
- `custom_components/houseplan/trails.py` — `same_run_identity`,
`can_resume_trail_run`, `TrailBook.on_point`/`drop_unknown_routes`,
`TrailRecorder.async_refresh`/`async_purge_orphans`/`_route_id`;
- `custom_components/houseplan/validation.py` — `validate_marker_vacuum_routes`
(change-aware, как badge-валидатор) и её проводка в
`websocket_api.py` (config/set, optimize) и `import_export.py` (оба import flow);
- `scripts/mutation-gate.mjs` — определения восьми заявленных + новых мутантов
(14 по факту), два прогнаны лично (см. ниже);
- `docs/CHANGELOG.md`/`.ru.md`, `docs/VACUUM.md`, `docs/USER-GUIDE.ru.md`,
`docs/CONFIG-COMPATIBILITY.md`, `docs/ARCHITECTURE.md`, `docs/TESTING.md`.
Не читал построчно (низкий риск, не в фокусе AC): i18n-словари полностью
(точечно сверил RU/EN парность нужных ключей), `docs/images/screenshots.json`
(бинарный/сгенерированный артефакт), `dist/**` и
`custom_components/houseplan/frontend/**` (класс D, генерируются билдом —
сверил, что `bundle:sync` не оставляет расхождений).
## Гейты — что прогнал сам и с каким результатом
Зелёного Validate на SHA `4032b810` не найдено, поэтому прогнал сам:
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | чисто |
| Unit (frontend) | `npm test` | 1854 pass / 0 fail / 1 skip |
| Build | `npm run build` | собирается, `dist` создан |
| Docs fingerprint | `node scripts/check-docs.mjs` | `Documentation checks passed (7 files, 10 external links)` |
| Backend (пул #162) | `python3 -m pytest tests_backend/test_trails.py tests_backend/test_trail_recorder.py tests_backend/test_vacuum_route_validation.py tests_backend/test_vacuum_routes.py -q` | **70 passed** |
| Backend (полный доступный набор) | `python3 -m pytest tests_backend -q --ignore=test_ha_setup.py --ignore=test_ha_upload.py --ignore=test_ha_virtual_lights.py --ignore=test_ha_websocket.py --ignore=test_ha_import_export.py` | 363 passed, 8 failed — все 8 падений в `test_coordinate_canonicalization.py`/`test_ha_support_transport.py` из-за отсутствующего `pytest-asyncio` в этой песочнице (окружение, не #162; `test_ha_*` файлы у меня тоже не поднимаются — нет `pytest_homeassistant_custom_component`, canonical harness тут недоступен, как и задокументировано в AGENTS.md) |
| Mutation gate (точечно) | `node scripts/mutation-gate.mjs --id=vacuum-calibration-solves-against-the-dock` | `поймано 1 из 1` |
| Mutation gate (точечно) | `node scripts/mutation-gate.mjs --id=vacuum-overlay-back-to-the-dock-space-filter` | `поймано 1 из 1` |
| Смок (профильный) | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | 24 прямых совпадения (список ниже), 37 слабых связей |
| Смок `smoke_vacuum_multifloor.mjs` | запускался автором, вывод сверен по коду — логика соответствует | не перезапускал: сценарий не пересекается с найденным дефектом (см. Находка 1) |
Смоки из «прямого совпадения», которые не гонял отдельно (автор уже прогнал
пересекающийся набор — `smoke_vacuum`, `smoke_vacuum_firstuse`,
`smoke_cold_view_vacuum`, `smoke_danger_confirm_branches`; остальные 20 —
`_confirmDanger`/`_config`/`_model`/`_saveConfig`/`DevItem` только по общей
инфраструктуре редактора, не по vacuum-логике — не пересекаются с изменённым
кодом по существу): не прогонял, полный набор здесь избыточен относительно
diff. `smoke_editor_gestures.mjs` (совпадение по `_vacFit`) не гонял — вместо
него написал точечный воспроизводящий скрипт (Находка 1), который прямо
отвечает на вопрос, для которого этот смок мог бы пригодиться.
Golden и performance не гонял: diff не меняет визуальный стиль puck/path
(§10.1 ТЗ явно это запрещает) и не затрагивает performance-чувствительные
пути сверх заявленного в АC20/пре-бета цикла; они канонически прогоняются
перед бетой (AGENTS.md «Gates»), не на этом ревью.
## Находка 1 (High, в скоупе) — калибровка мимо своего этажа в пути «высокий residual → ручная подгонка»
**Файл:** `src/houseplan-editor-runtime.ts:11043-11062`, метод `_vacApplyCalibrationProposal`.
```ts
public _vacApplyCalibrationProposal(manual: boolean): void {
const proposal = this.host._vacCalConfirm;
...
if (manual) {
const dev = this.host._devices.find((candidate) => candidate.id === proposal.markerId);
const fit = fitFromMatrix(proposal.matrix);
if (!dev || !fit) return;
this.host._markerDialog = null;
if (dev.space !== this.host._space && !this.host._commitSpace(dev.space)) return;
this.host._vacFit = {
markerId: proposal.markerId, source: proposal.source, routeId: proposal.routeId,
mapId: proposal.mapId, p: fit, drag: null,
};
return;
}
```
`_vacAutoCalibrate` (той же файл, стр. 11020-11042) правильно вычисляет
`target = calibrationTarget(d.id, d.marker?.vacuum, d.space, src, mapId)` и
решает матрицу против `target.space` (это и есть коммит `4032b810`, AC8).
Когда residual выше порога, он кладёт в `_vacCalConfirm` только
`{markerId, source, mapId, routeId: target.routeId, matrix, rooms, error}` —
**без `space`**. Когда пользователь выбирает «Подогнать вручную» в диалоге
подтверждения, `_vacApplyCalibrationProposal(true)` заново берёт `dev.space`
(пространство ДОКА), а не пространство маршрута, которое было использовано
для решения матрицы. Для сравнения, независимый путь `_vacStartFit`
(стр. 11073-11083) делает это правильно: коммитит `plan.space`, посчитанный
`planVacuumFit`.
**Воспроизведено исполнением** (не только чтением): сборка + demo harness,
сценарий — робот с доком на `f1` и route `vr2` (`m2`) на `garden`, `_space`
уже равен `f1` (типичная ситуация: диалог устройства открыт с этажа дока).
Симулирован ровно тот `_vacCalConfirm`, который кладёт `_vacAutoCalibrate` при
высоком residual для `vr2`, затем вызван `_vacApplyCalibrationProposal(true)`:
```json
{
"spaceBefore": "f1",
"spaceAfter": "f1",
"fitOpened": true,
"fitRouteId": "vr2",
"expectedRouteSpace": "garden"
}
```
`_space` остаётся `f1`, хотя `fitRouteId` — `vr2`, чья `route.space` —
`garden`. Диалог ручной подгонки открывается на этаже ДОКА и рисует матрицу,
решённую против комнат `garden`, поверх геометрии `f1`: это ровно тот
дефект, ради исправления которого создан #162 (робот показывается не на том
этаже — только внутри редактора калибровки, а не в View), и прямое
нарушение AC8 («Auto/manual calibration использует rooms/viewBox/cell size
target `route.space`, а не dock space»).
**Почему не поймано мутант-гейтом.** Заявленный мутант
`vacuum-calibration-solves-against-the-dock` (проверил лично —
`node scripts/mutation-gate.mjs --id=vacuum-calibration-solves-against-the-dock`
→ «поймано 1 из 1») патчит саму функцию `calibrationTarget()` и охраняется
unit-тестом на неё же. Он честно защищает то, что защищает, но не покрывает
этот конкретный стейтфул-вызов в `_vacApplyCalibrationProposal`, который
пересчитывает `dev.space` заново вместо того, чтобы использовать
`calibrationTarget`/сохранённый `space`. `smoke_vacuum_firstuse.mjs`
(единственный смок, гоняющий `_vacApplyCalibrationProposal(true)`) не ловит
это, потому что его сценарий однопространственный: `dockSpace === route.space`
там всегда, поэтому баг структурно не может проявиться. `smoke_vacuum_multifloor.mjs`
не трогает калибровку вообще, только рендер уже откалиброванных routes.
**Почему в скоупе и почему это не Medium.** AC8 — нормативный, явно
перечисленный критерий приёмки задачи, и путь, в котором он ломается
(«высокий residual → ручная донастройка»), не редкий частный случай:
это штатная ветка калибровки, которую спецификация специально ввела для
недостаточно точного auto-calibration (§9.4, риск-таблица §19 «Recalibration
портит trails»). Обходной путь пользователю недоступен без документации
(диалог визуально откроется, но на неправильном этаже, что почти наверняка
собьёт с толку админа, а не подскажет переключить пространство вручную).
**Что нужно поправить.** Сохранить `space` в `_vacCalConfirm` (типовой узел
`{markerId, source, mapId, routeId?, space, matrix, rooms, error}` в обоих
местах — интерфейс `host._vacCalConfirm` объявлен в
`src/houseplan-card.ts:2511` и в `HouseplanEditorHostPort`
(`src/houseplan-editor-runtime.ts`) — и коммитить именно его в
`_vacApplyCalibrationProposal`, как это уже делает `_vacStartFit`.
## Находка 2 (Medium, в скоупе) — §9.3 «Добавить источник карты» не реализован
**Файл:** `src/editors/vacuum-maps-section.ts` (весь модуль). Кнопка
«Добавить текущую карту» (`addCurrent`, стр. 70-81) — единственный способ
завести route; отдельного выбора camera source (§9.3 ТЗ: «переиспользует
capability picker и ленивую секцию "Все камеры"») в блоке нет.
Это прямо признано автором в комментарии реализации («Отступления от ТЗ»,
п.1) и подтверждено чтением файла — других путей создания route, кроме через
текущий root `source`, не существует. Проблема не гипотетическая: тело
issue #162 перечисляет это вторым обязательным сценарием дословно —
«Поддерживаются оба варианта интеграций: один источник с меняющимся `map_id`
и отдельная camera entity на каждую карту» — и §5.2 ТЗ включает «явный выбор
другой camera source через существующий picker» в список обязательного
scope Device editor.
Обходной путь существует (сменить root `source` на вторую камеру, добавить
её карту, затем при необходимости вернуть root обратно) и он функционально
работает, потому что route хранит свой `source` независимо от root. Поэтому
это не блокирует рендер/резолвер (AC4, AC5, AC7 проверяют resolution уже
настроенных multi-source routes и проходят), но заявленный сценарий
настройки «с нуля второй камерой, не трогая первую» не имеет прямого пути в
UI — только обходной, который нигде не объяснён пользователю.
**Почему Medium, а не High:** нет потери данных, нет неверного отображения;
это пробел в scope UI с рабочим (хоть и не описанным) обходным путём, а не
неверное поведение уже настроенного routing.
**Почему в скоупе задачи, а не отдельный issue:** §9.3 и часть §5.2 — это
нормативный текст ТЗ, утверждённого на spec-review r2 этого же issue, и один
из двух сценариев, названных в оригинальном теле #162. Это не соседнее
поведение — это заявленная, но недоделанная часть текущей задачи.
## Что проверено и корректно
- **Резолвер и его питоновское зеркало** (`vacuum-routes.ts` /
`vacuum_routes.py`) синхронны построчно: `resolveRoute`/`resolve_route`,
`adoptLegacyRun`/`adopt_legacy_run`, `validateMarkerRoutes`/
`validate_marker_routes`, `effectiveRoutes`/`effective_routes` — идентичная
логика, идентичный порядок веток. Ambiguity — по `routeIds`, не по первому
совпадению (AC5, AC14, оба спот-чек мутанта поймали регрессию).
- **Рендер больше не фильтрует по пространству дока** — `_renderVacuums`
теперь получает `this._renderDevices` (всех устройств) плюс `spaceId`
текущего рисуемого пространства, и раздельно решает `live`/`previous` через
`planVacuumOverlay` по `route.space`, а не по `d.space`. Вызывается ровно
один раз на кадр (единственный call site,
`src/houseplan-card.ts:11794`), поэтому многократного пересчёта на
несколько пространств одновременно нет. Мутант
`vacuum-overlay-back-to-the-dock-space-filter`, возвращающий фильтр по
док-пространству, красит `smoke_vacuum_multifloor` — лично перепроверил.
- **Legacy-путь не переписывает конфиг сам по себе**: `effectiveRoutes`/
`effective_routes` только читают `calibration`, ничего не пишут; явная
конверсия (`convertLegacyRoutes`) — all-or-nothing, требует exact source
(§7.3). Прочитано, логика соответствует.
- **`adoptLegacyRun`/`adopt_legacy_run`** — ровно три исхода (unique/
orphan/ambiguous), отбор по `rootSource` отключается при его отсутствии, как
зафиксировано в правке spec-review r1 (§11.3.1); оба зеркала идентичны.
- **Backend recorder** (`trails.py`) подписывается на объединение всех route
source (не только root), `_route_id` находит совпадающий route по
`(source, map_id)`, `same_run_identity` разводит запуски по `route_id`, если
он есть, иначе — по `map_id` (legacy-совместимость сохранена буквально).
`drop_unknown_routes` вызывается из `async_purge_orphans`, которая, в свою
очередь, вызывается при config/set (websocket_api.py:1661) и после import —
то есть удаление space/route действительно чистит связанные runs в той же
логической операции (AC16), а не когда-нибудь потом.
- **`validate_marker_vacuum_routes`** — change-aware (не блокирует
непричастный Save при уже сломанных legacy/future routes), подключена к
`config/set` (оба места, `websocket_api.py:1582,1941`), `import_export.py`
(полный и частичный import). Прочитано и сверено с описанием §7.2 ТЗ.
- **`space-deletion.ts`** — считает `routeMarkerIds`/`routeCount` для чужих
маркеров, ссылающихся на удаляемое пространство, и вырезает только их
routes, не трогая dock/другие routes того же маркера (AC16, читал код,
подтверждено).
- **Ratchet ядровых файлов** соблюдён: `houseplan-card.ts` 13605/13659,
`houseplan-editor-runtime.ts` 14321/14323 (`wc -l`, сверено лично) — весь
новый код в `vacuum-routes.ts`, `vacuum-route-edit.ts`,
`vacuum-maps-section.ts`.
- **Trailers и changelog**: все 11 коммитов несут `Issue: #162` и корректный
`User-Visible`; каждый коммит с `User-Visible: yes`
(`c38501ef`, `ca6ebc30`, `567b4568`) правит оба changelog в себе самом —
сверено по `git show --stat`.
- **`node scripts/check-docs.mjs`** зелёный на текущем дереве — отпечаток
скриншотов не устарел.
- **RU/EN i18n parity** для новых ключей `vac.route_*`/`vac.routes_*`
(лениво, `i18n/support/{en,ru}.json`) и `vac.route_warn_*` (основной
словарь, обязателен для View) — точечно сверил наличие и параллельность.
## Что не проверял и почему
- **Полный HA harness** (`test_ha_*.py`, включая новые
`test_ha_import_export.py::issue_162_*`) — недоступен в этой песочнице
(`pytest_homeassistant_custom_component` не устанавливается, канон —
Linux CI/WSL, см. AGENTS.md). Автор указал то же ограничение в
комментарии реализации и привёл отдельный изолированный прогон логики
экспорта; сам этот отдельный прогон я не видел (не приложен как лог), но
логика `import_export.py` (проверка cross-space routes, remap на
импортируемый space) прочитана и не вызывает вопросов.
- **AC11 (reload/warm remount)** — принимаю аргумент автора (маршрутизация
пересчитывается из конфига на каждый кадр, состояние между рендерами не
хранится) как правдоподобный по чтению кода: `facts.set('vacuum:<id>', …)`
пересобирается в `render()` безусловно, `resolveRoute` — чистая функция от
текущего конфига и снапшота состояний, ничего не кэширует между вызовами
card. Backend-часть continuity покрыта существующими
`test_resume_fails_closed_on_clock_rollback_and_survives_restart_shape` +
новыми `test_route_run_does_not_resume_into_another_route`/
`test_same_route_keeps_one_run`. Отдельный E2E-смок на reload/remount не
считаю обязательным добавлять в рамках этого возврата — но если ревью после
находки 1 потребует более широкой правки калибровки, стоит заодно
зафиксировать это явным тестом, раз уж придётся трогать соседний код.
- **Golden и performance-профили** — не гонял; вне AC этого раунда, canonical
запуск — пре-бета (см. таблицу гейтов выше).
- **`docs/images/screenshots.json`** — не сверял побайтово с реальными PNG,
доверился `check-docs.mjs` (зелёный).
- **Остальные 20 файлов из «прямого совпадения» смок-селектора** (кнопка
danger-confirm, config writer, редакторские вкладки и т.п.) — не гонял;
пересечение с изменённым кодом только по общей инфраструктуре редактора
(`_config`, `_saveConfig`, `_model`, `_confirmDanger`), не по vacuum-логике.
- **37 «слабых связей»** смок-селектора — не гонял ни одного; ни одно имя
(`_model`, `_config`, `_saveConfig`) не указывает на код, которого коснулся
diff по существу.
## Итог
Возврат автору. Обе находки — в скоупе текущей задачи и чинятся в ней же
(процесс #202): отдельный issue не заводится. Находка 1 (High) требует
правки перед тем, как задача может стать зелёной; находка 2 (Medium) может
быть закрыта в том же коммите или отдельным, но в этой же ветке.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/162-vacuum-map-space-routing`, коммит `4032b810b72b` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `f03a4fac82468b33357acb14bf76153213958a2a`
```
git log --all --format='%H %T' | grep f03a4fac8246
```
- ТЗ `docs/specs/162-vacuum-map-space-routing.md`, блоб `a8edcfbe6c37700095f6f8582258eac4fcb58ad9`
```
git log --all --find-object=a8edcfbe6c37700095f6f8582258eac4fcb58ad9 -- docs/specs/162-vacuum-map-space-routing.md
```