mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 05:08:53 +00:00
223 lines
18 KiB
Markdown
223 lines
18 KiB
Markdown
# CODE-REVIEW-333-r1
|
||
|
||
Issue: #333 · Заход: r1 · Блокирующих циклов израсходовано 0 из 2
|
||
Материал: `git log --oneline origin/dev..HEAD` = один коммит `c29df29b`
|
||
(`fix: plan/optimize passes the junction gate; import stays free by design (#333)`),
|
||
родитель `73406225` (= `origin/dev` на момент проверки).
|
||
|
||
## 1. Скоуп
|
||
|
||
ТЗ (тело issue, small-трек, ревизия 2, оба раунда spec-ревью зелёные —
|
||
`docs/reviews/SPEC-REVIEW-333-r1.md`, `-r2.md`) требует:
|
||
|
||
1. `ws_plan_optimize` вызывает `validate_junction_limits(candidate, stored, …)`
|
||
внутри существующей executor-функции `_validate_optimize_cpu`, ПОСЛЕ
|
||
миграции кандидата — наследование по правилу (легаси-нарушение проходит,
|
||
новое — отказ `junction_limit_<rule>`).
|
||
2. Успешный optimize обновляет `rt.junction_baseline` счётчиками кандидата.
|
||
3. Import/restore остаются вне гейта; докстринг `junction_limits.py` и
|
||
`docs/specs/329-junction-limits.md` §5 фиксируют периметр честно.
|
||
|
||
Продуктовая рамка (`docs/SCOPE.md`): изменение защищает контракт «карточка
|
||
отказывает записи, ломающей П1–П5» (Core user jobs: геометрия остаётся
|
||
валидной) от обхода через второй командный путь — попадает в скоуп.
|
||
`User-Visible: no` в трейлере коммита корректен: поведение честного клиента
|
||
не меняется (AC10 #329 уже доказывает, что оптимизация не добавляет
|
||
нарушений), меняется только реакция на крафтовый payload.
|
||
|
||
## 2. Как проверялось
|
||
|
||
Дешёвые гейты, на которые ссылался бриф ревью («Validate на `c29df29b`
|
||
завершился success»), **не могут считаться подтверждёнными** — см. находку
|
||
H1 ниже. Прогнал сам:
|
||
|
||
- `npx tsc --noEmit` — чисто.
|
||
- `npm test` — 1425 passed, 0 failed, 1 skipped (без изменений от базовой
|
||
линии).
|
||
- `npm run build` + `cmp dist/houseplan-card.js
|
||
custom_components/houseplan/frontend/houseplan-card.js` — идентичны.
|
||
- `node scripts/mutation-gate.mjs --check` — реестр консистентен, новый
|
||
мутант `junction-limit-optimize-unguarded` зарегистрирован корректно
|
||
(patch-строка совпадает с реальным кодом).
|
||
- Ручная проверка «мутант умеет убивать»: применил patch мутанта
|
||
(`return validate_junction_limits(...)` → `return {}, None`) к
|
||
`websocket_api.py`, прогнал
|
||
`pytest tests_backend/test_ha_websocket.py -k test_333_optimize_refuses_a_crafted_violation` —
|
||
тест красный (`assert not True`), рабочее дерево вернул к исходному
|
||
(`git status` чист). Тест умеет падать.
|
||
- `python -m pytest tests_backend -q` (полный бэкенд-набор, HA-харнес
|
||
реально поднимается в этом окружении, вопреки заметке автора о песочнице) —
|
||
**1 failed, 428 passed, 1 skipped, 1 error**. Разбор ниже (находка H1).
|
||
- `node scripts/smoke-select.mjs --base 73406225 --head c29df29b` —
|
||
«исполняемого frontend-диффа нет», браузерные смоки не выбираются
|
||
(diff не трогает `src/**`) — решение подтверждено инструментом.
|
||
- `check-docs` / golden / invariants — не гонял, diff не трогает `src/**` и
|
||
не меняет геометрию/ссылки на неё (только добавляет вызов уже
|
||
существующего валидатора на втором командном пути); обоснование ниже, §4.
|
||
|
||
Не гонял и не буду: `python -m pytest tests_backend -q` уже покрывает
|
||
паритет П1–П4 (`test_junction_limits.py::test_parity_with_the_frontend_checks`)
|
||
без дополнительных прогонов.
|
||
|
||
## 3. Находки
|
||
|
||
### H1 (High, в скоупе). Optimize новым гейтом ломает существующий бэкенд-тест — подтверждённая регрессия
|
||
|
||
`plan/optimize` на конфиге, который НИКОГДА не сохранялся (пустое
|
||
`config_data.get("config")`, `expected_config_rev: 0`), теперь отказывает на
|
||
`junction_limit_length`, если в кандидате есть хоть один сегмент короче
|
||
20 см — потому что для «предыдущего» состояния `old_counts` пуст, и
|
||
существующее правило («первая запись не может прийти уже сломанной»,
|
||
`junction_limits.py:415`) считает любое нарушение новым.
|
||
|
||
Ровно это происходит с существующим (не тронутым этим диффом) тестом
|
||
`test_plan_optimize_persists_exact_storage_roundtrip_target`
|
||
(`tests_backend/test_ha_websocket.py:599`, фикстура
|
||
`test/fixtures/optimize-storage-roundtrip.json`, тест — про #248, байт-точный
|
||
роундтрип storage, а не про геометрию): пространство `fine` содержит комнату
|
||
6×6/12 «условных» см (специально крошечную — фикстура проверяет округление на
|
||
разных `cell_cm`), после миграции в ней есть стена длиной 6 см — П3, минимум
|
||
20 см.
|
||
|
||
Воспроизведение:
|
||
|
||
```
|
||
$ python -c "
|
||
from custom_components.houseplan.junction_limits import validate_junction_limits
|
||
import json
|
||
config = json.load(open('test/fixtures/optimize-storage-roundtrip.json'))['input']['config']
|
||
validate_junction_limits(config, None)
|
||
"
|
||
JunctionLimitError: space=fine; rule=length; subject=wall-2p5v3xgwtvsbainkhyk4; actual=6; limit=20
|
||
```
|
||
|
||
Доказано двумя прогонами полного `pytest tests_backend/test_ha_websocket.py -k
|
||
test_plan_optimize_persists_exact_storage_roundtrip_target`:
|
||
|
||
- на родителе `73406225` (до фикса #333): **1 passed** (плюс тот же фоновый
|
||
teardown-`ERROR`, см. ниже — он не от этого коммита);
|
||
- на `c29df29b` (после фикса): **1 failed** (`assert response["success"]` →
|
||
`False`).
|
||
|
||
Это не «недостающий тест», это диффом сломанный существующий зелёный тест —
|
||
полный набор `pytest tests_backend -q` на `c29df29b` красный
|
||
(`1 failed, 428 passed`). Мёртвого запуска этого набора на CI сейчас не
|
||
происходит (см. находку вне скоупа ниже), поэтому красный статус не был
|
||
замечен автоматически, но ревью обязано было его поймать — и поймало.
|
||
|
||
Фикс — решение автора; правдоподобные варианты: (а) поправить фикстуру
|
||
`optimize-storage-roundtrip.json`, вложив в тест уже сохранённое предыдущее
|
||
состояние с тем же нарушением (echo-паттерн AC2), раз тест не должен
|
||
проверять геометрию; (б) явно обсудить в ТЗ, должен ли `plan/optimize` как
|
||
первая-когда-либо запись (`config_rev==0`) подчиняться тому же правилу, что
|
||
и `config/set` — если да, чинится тест; если нет, гейту нужно исключение,
|
||
и это уже требует правки контракта, а не только кода.
|
||
|
||
**Блокирует.** Verdict: жёлтый.
|
||
|
||
### Вне скоупа (заведено отдельным issue)
|
||
|
||
Тестируя допущение брифа «Validate на `c29df29b` завершился success ⇒ tsc/
|
||
npm test/npm build уже подтверждены», обнаружил, что job'ы `Фронтенд: типы,
|
||
юниты, мутанты, синхрон бандла` и `Бэкенд: pytest в Home Assistant` в этом
|
||
прогоне — `skipped`, а не `success`; общий «success» рана этого не показывает.
|
||
Причина — в классификации изменённых файлов (`.github/workflows/validate.yml`,
|
||
job `changes`): `BEFORE_SHA` этого push ссылается на коммит, вышедший из
|
||
истории веткой при force-push (аменд предыдущего пуша той же ветки, run
|
||
33145390451, `cancelled`), фолбэк на `merge-base origin/dev HEAD` разрешился
|
||
так, что диапазон включил только два doc-коммита ревью и потерял сам код-фикс
|
||
— `backend`/`frontend` вышли `false`, тяжёлые job тихо пропущены.
|
||
|
||
Дефект соседний (инфраструктура CI, не продуктовый код этой задачи) и не
|
||
чинится в этой ветке. Заведён: **#347** — `ci: классификация файлов Validate
|
||
теряет диапазон после force-push, тяжёлые job тихо skip'аются` (labels:
|
||
`bug, infra, P1, S1-new`, ссылка на #333).
|
||
|
||
## 4. Что проверено и корректно
|
||
|
||
- **Контракт п.1 (вызов гейта в optimize).** `websocket_api.py:1609-1710`:
|
||
`validate_junction_limits(msg["config"], config_data.get("config"))`
|
||
вызывается ПОСЛЕ `CONFIG_SCHEMA`/миграции кандидата (`commit_wall_segment_model`
|
||
при необходимости) и после остальных семантических валидаторов, внутри
|
||
`_validate_optimize_cpu` — то же место в конвейере, что и в `config/set`
|
||
(websocket_api.py:1343). `JunctionLimitError` в except-списке (было мёртвым)
|
||
теперь действительно ловится и транслируется в `connection.send_error`
|
||
тем же стабильным кодом `junction_limit_<rule>`.
|
||
- **AC1 (крафтовый payload).** Тест
|
||
`test_333_optimize_refuses_a_crafted_violation_and_keeps_the_plan`
|
||
(`tests_backend/test_ha_websocket.py:2388`) прогнан — **PASSED**. Мутант
|
||
подтверждён ручным патчем (см. §2). Хранимые `config` и `rev`
|
||
побайтово не изменились после отказа — проверено ассертами теста.
|
||
Layout-неизменность из формулировки AC1 тестом не проверяется явно, но
|
||
гарантирована структурой кода: `JunctionLimitError` ловится ДО первого
|
||
`async_save_layout_state`/`async_save_config_state` в функции — физически
|
||
нечему меняться (см. Low-замечание ниже).
|
||
- **AC2 (наследование в optimize).** Тест
|
||
`test_333_optimize_inherits_stored_violations` — **PASSED**: эхо-оптимизация
|
||
пространства с унаследованным нарушением угла проходит. Согласуется с
|
||
#329 AC10 (легаси-оптимизация не создаёт новых нарушений).
|
||
- **AC3 (обновление кэша).** Тест
|
||
`test_333_optimize_refreshes_the_junction_baseline_cache` — **PASSED**:
|
||
через `recording`-обёртку над `validate_junction_limits` подтверждено, что
|
||
следующий `config/set` вызывается с `baseline_counts` из кэша
|
||
(`rt.junction_baseline`), не пересчитывая `previous`.
|
||
`websocket_api.py:1783` (`rt.junction_baseline = (int(new_config_rev),
|
||
optimize_counts or {})`) — симметрично строке 1396 в `config/set`.
|
||
- **AC4 (докстринг/спека).** Прочитал оба текста целиком:
|
||
`junction_limits.py:1-15` больше не обещает «hostile client cannot post»
|
||
без оговорок — явно называет периметр (`config/set` и `plan/optimize`) и
|
||
сознательно исключённый import/restore, с обоснованием (§3 #329: restore
|
||
не блокируется никогда). `docs/specs/329-junction-limits.md` §5 (новый
|
||
абзац, строки 127-134) — тот же периметр и тот же трейд-офф, ссылка на
|
||
решение владельца 2026-08-28. Текст соответствует коду, догадок, выданных
|
||
за решённое поведение, не нашёл.
|
||
- **Паритет П1–П4 и остальной бэкенд.** `test_parity_with_the_frontend_checks`
|
||
и все прочие 424 теста `tests_backend` (кроме H1) прошли без изменений.
|
||
- **Трейлеры коммита.** `Issue: #333`, `User-Visible: no` — оба на месте,
|
||
соответствуют характеру изменения (нет пользовательского поведения,
|
||
видимого честному клиенту).
|
||
- **Реестр мутаций.** `--check` зелёный, новый мутант описан честно
|
||
(`because` указывает на реальный риск — крафтовый optimize-payload
|
||
легализует нарушение для будущих `config/set`).
|
||
|
||
## 5. Чего не проверял и почему
|
||
|
||
- **check-docs, golden:verify, browser-смоки** — diff не трогает `src/**`
|
||
вообще (только `custom_components/**/*.py`, `docs/specs/*.md`,
|
||
`scripts/mutation-gate.mjs`, `tests_backend/*.py`); `smoke-select.mjs`
|
||
подтвердил инструментом «исполняемого frontend-диффа нет». Рендер и
|
||
геометрия не меняются — гейты не относятся к этому diff.
|
||
- **`npm run invariants`** — diff не меняет геометрию, рёбра комнат, записи
|
||
толщины, `layout`, `marker.space` или `open_spans`; добавляет только вызов
|
||
уже существующего Python-валидатора на втором командном пути. Инварианты
|
||
модели тут не про что проверять.
|
||
- **performance-профили** — не названы в AC; #330 §4.1 уже вынесла всю
|
||
дорогую цепочку (включая `validate_junction_limits`) в executor, это не
|
||
трогается.
|
||
- **Полный `node scripts/mutation-gate.mjs`** (все мутанты) — избыточен:
|
||
диффу соответствует ровно один новый мутант, он проверен точечно (ручной
|
||
патч + прогон целевого теста), это дешевле и доказательнее, чем гонять
|
||
весь реестр.
|
||
- **HA-харнес** — вопреки заметке автора («локальная песочница… не
|
||
поднимает»), в ЭТОЙ ревью-сессии `pytest-homeassistant-custom-component`
|
||
установился и харнес поднялся; воспользовался этим и прогнал реальные
|
||
прогоны, а не читал код вслепую. Один посторонний `ERROR` на teardown
|
||
каждого HA-теста (`assert isinstance(thread, threading._DummyThread) or
|
||
thread.name.startswith("waitpid-")`) — известная нестыковка версии
|
||
`pytest-homeassistant-custom-component` с Python 3.12 (первый поток теста
|
||
назван иначе), воспроизводится одинаково и на родителе `73406225`, и на
|
||
`c29df29b`, до и после патча мутанта — не связан с этим диффом, тестовые
|
||
тела он не затрагивает (различаю `FAILED`/`PASSED` в основном теле от
|
||
`ERROR` в teardown).
|
||
|
||
## 6. Вердикт
|
||
|
||
Контракт ТЗ реализован технически точно (AC1–AC4 каждый по отдельности
|
||
доказан тестом, который умеет падать), но диффом ломается несвязанный
|
||
существующий тест — реальная, воспроизведённая регрессия уровня High.
|
||
Правки ТЗ не требуется (если автор выберет чинить фикстуру/тест), либо
|
||
требуется ревизия контракта (если выберет явно исключить «первую запись»
|
||
из-под гейта optimize) — в обоих случаях нужен повторный код-ревью цикл.
|
||
|
||
Вердикт: жёлтый · заход r1 · блокирующих циклов 0/2 · High: 1 · Medium: 1 → #347
|