Files
houseplan-card/docs/reviews/CODE-REVIEW-296-r1.md
T
2026-08-24 19:37:27 +00:00

329 lines
30 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-296-r1
- **Issue:** #296 «Оптимизировать» не убирает невидимую геометрию, которая блокирует ресайз
- **Этап:** код-ревью (PROCESS.md §2.7)
- **Заход:** r1 · блокирующих циклов израсходовано 0/4 до этого ревью
- **SHA ревью:** `89ac96a8` (ветка `issue/296-optimize-hidden-obstacles`, приведена к `dev` до ревью — 1 коммит dev поверх, разбор полный по правилу §7.2/§2.9)
- **Вердикт: красный**
## Скоуп
Диапазон: `f7a19a35` (docs(spec)) → `033ad11b` (docs: review document r1) → `89ac96a8`
(fix, User-Visible: yes). Продуктовый код и тесты лежат в одном коммите `89ac96a8`.
Проверялись: piecewise-согласование составных `partitions` (§6), удаление доказанно
избыточных `room_drafts` (§7), backend fail-closed доказательство (§8), диагностический
слой скрытой геометрии в редакторе Плана (§9), отчёт/Undo/идемпотентность (§10),
i18n/документация (§11, §16), AC1–AC9 (§13).
## Как проверялось — гейты
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | зелёный |
| Unit | `npm test` | 1267 pass / 1 skip / 0 fail |
| Build + сверка бандлов | `npm run build`; `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js`; `npm run bundle:sync` + `cmp` с `demo/srv/assets/houseplan-card.js` | все три копии идентичны |
| Docs fingerprint | `node scripts/check-docs.mjs` | **красный** — `ERROR screenshot source fingerprint is stale`. Проверено: на коммите-родителе `4feeebc3` (до этой задачи) тот же скрипт зелёный (`Documentation checks passed`) — регрессия внесена именно этим диффом |
| Смок-выбор | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | 27 «прямое совпадение» + 3 «зарегистрированная связь» = 30 из 185 |
| Named smokes (30, список ниже) | `node demo/smoke_<name>.mjs` | **29 OK, 1 FAIL** (`smoke_edit_walk.mjs`) |
| Model invariants на новой фикстуре | `node scripts/model-invariants.mjs --config test/fixtures/real-plan-second-floor.json --json` | 3 нарушения `hidden_obstacles`, как и заявлено в issue/ТЗ (до Optimize) |
| Golden capture двух новых сценариев | `node demo/golden/run.mjs --mode=capture --scenario=hidden-wall-diagnostics-plan-{light,dark}` | захват прошёл (`missing-baseline`, ожидаемо), но **визуально сценарий не показывает диагностический слой** — см. High-2 |
| Backend unit | `python -m pytest tests_backend/test_validation.py -q` (агентом, после `pip install --user pytest voluptuous`) | 136 passed, 1 skipped (приватная фикстура владельца) |
Смоки, прогнанные из 30 (все «прямое совпадение» + все «зарегистрированная связь»):
`smoke_merge_split, smoke_zero_divider_taper, smoke_decor_layer_order, smoke_decor,
smoke_edit_walk, smoke_editor_gestures, smoke_free_walls, smoke_glow,
smoke_grid_scale_invariance, smoke_open_passage, smoke_opening_measure,
smoke_opening_tunnel_fill, smoke_optimize_coincident_partition,
smoke_optimize_micro_interval, smoke_optional_space_model, smoke_plan_drawing_repairs,
smoke_plan_snap_overlay, smoke_resize_audit_1550, smoke_room_autoclose, smoke_room_resize,
smoke_space_scale_defaults, smoke_split_corner_wall, smoke_split_nonsnap,
smoke_unified_wall_tool, smoke_wall_chain_thickness, smoke_wall_face_overlap,
smoke_wall_junctions, smoke_resize_pointer_real_plan, smoke_resize_wall_thickness,
smoke_wall_key_roundtrip`. Все зелёные, кроме `smoke_edit_walk` (см. High-3).
**Не прогонялось и почему:** полный набор `demo/smoke_*.mjs` (185 файлов) — предрелизный
гейт, задача не задевает всё; `npm run golden:verify` (нет принятых baseline для новых
сценариев — принятие вне ревью, канон CI; для существующих сценариев дрейф не
проверялся отдельно, см. «Не проверено»); performance-бенчмарки
(`demo/benchmark_coincident_partitions.mjs`, `demo/benchmark_safe_resize*.mjs`) — не
названы явно упавшими, а спецификация делает обновление бюджетов условным
(«только при обоснованной необходимости»); полный HA/Linux-харнесс backend — недоступен
в этой среде, каноном остаётся CI на точном SHA.
## Находки
### High-1 — backend не доказывает удаление partition без hosted opening (§8, AC6)
`custom_components/houseplan/validation.py`, `_safe_optimize_partition_rehost`
(строки ~173–274). Функция вызывается **на каждый opening**, и восстанавливает
`target_a/target_b` только как footprint конкретного opening
(`expected_x/y ± opening_length/2`), а не ось всей старой partition. Диапазоны старой
partition, на которых нет opening, вообще не проверяются backend'ом — ни на покрытие
solid room-стеной, ни на неуменьшение толщины.
Это ровно основной сценарий issue: `partition-mt2on9ou-0` и `partition-room-mt7ijuyq-0`
в приложенном плане владельца **не несут ни одного opening** (`"openings": []` в этом
пространстве). Подтверждено исполняемым репро (см. отчёт субагента): (1) partition без
единого opening молча удаляется без единой проверки; (2) partition с opening,
покрывающим лишь часть длины (0.4–0.6 из 0–1), после Optimize теряет реальный
непокрытый остаток (0.6–1.0) — валидатор не возражает.
Спецификация §8 п.1–2 требует ровно обратного: backend обязан «восстановить ось и
атомарное покрытие старой partition стенами комнат в новой конфигурации» и «доказать,
что удалённые диапазоны полностью покрыты solid room walls не уже старой partition»
— для **всей** partition, а не только для той её части, где есть opening. Сейчас
`allow_optimize_rehost=true` доверяет frontend-delta без проверки именно там, где
проверка нужнее всего.
**Воспроизведение:** `tests_backend/test_validation.py` не содержит теста с partition
без openings; при добавлении такого случая (partition без openings, "удалённая" в
candidate без замещающей стены нужной толщины) `validate_partition_opening_hosts(...,
allow_optimize_rehost=True)` не поднимает `PartitionOpeningHostError` — вызов молча
проходит.
### High-2 — AC7 нарушен: диагностический слой не виден в инструменте Draw (§9.2)
`src/houseplan-card.ts:19582-19583` (`_renderHiddenWallDiagnosticOverlay`) и вызывающая
точка `src/houseplan-card.ts:17391-17394` явно гасят слой при `this._tool === 'draw'`:
```ts
private _renderHiddenWallDiagnosticOverlay(): TemplateResult {
if (!this._markup || this._tool === 'draw') return svg`` as unknown as TemplateResult;
...
${this._tool === 'draw'
? this._renderPlanSnapOverlay()
: this._renderHiddenWallDiagnosticOverlay()}
```
`this._tool` по умолчанию равен `'draw'` (`src/houseplan-card.ts:1270`) — то есть при
входе в редактор Плана диагностика не видна вовсе, до явного переключения инструмента.
AC7 (§13): «**Во всех инструментах** редактора Плана перекрытая partition и сохранённый
draft показывают полную ось и исходные endpoints...» — «во всех» не имеет исключения
для Draw ни в АС, ни в §9.2 («Слой существует во всём редакторе Плана **при любом
активном инструменте**»).
Это не только текстовое несоответствие: именно в инструменте Draw активен
`buildPlanSnapGeometry()`, который **дедуплицирует** совпадающую ось в пользу комнаты
(подтверждено существующим тестом «a completed room remains the authority for a
coincident deduplicated axis»). То есть ровно там, где пользователь мог бы начать
рисовать рядом со скрытым объектом, скрытый объект не показан вовсе, а показанная
«архитектура» — дедуплицированная, без отдельных исходных endpoints, которые и есть
единственный смысл диагностики (§9.1, последний пункт).
**Воспроизведение исполнением:** golden-сценарии `hidden-wall-diagnostics-plan-light`
/`-dark` (добавлены этим же диффом для AC7) не задают `_tool`, поэтому остаются на
дефолте `'draw'`. Захват (`node demo/golden/run.mjs --mode=capture
--scenario=hidden-wall-diagnostics-plan-light`) даёт скриншот с чекбоксом «Thickness» и
подсказкой «click a grid dot to start a wall chain» — интерфейс инструмента Draw, а не
диагностический слой. AC7-евиденс «editor golden light/dark for скрытая
partition/draft» фактически не демонстрирует заявленную функциональность.
`demo/smoke_plan_snap_overlay.mjs` эту ветку не проверяет (переключается на
`_activateMarkupTool('select')`, не на `draw`), поэтому регрессия не поймана смоком.
### High-3 — `smoke_edit_walk.mjs` красный на новой фикстуре (AC6, §253-класс дефекта)
```
node demo/smoke_edit_walk.mjs --seed 2 --plan real-plan-second-floor.json
```
падает детерминированно:
```
real-plan-second-floor.json, семя 2, шаг 0: ресайз room-a#2 на -3
wall_carrier · real-second-floor:1.020833,1.266667@0.0000 · 20 см
...
off_lattice_coordinate · config · вне сетки 0 → 1
FAILED: walk_second_floor_seed2: expected true, got false
```
`demo/smoke_edit_walk.mjs` — обход-фаззер из issue #297, уже существующий на `dev` со
своей таблицей известного долга (`KNOWN`, `PLANS[...].debt`). Этот же коммит трогает
файл ровно одной строкой — поднимает `debt` для `real-plan-second-floor.json` с `1` до
`3`, но комментарий в самом файле (строки 50-58, не изменён) прямо требует обратного:
«Когда #296 закроется, число здесь станет нулём, и тест это потребует». `KNOWN`-таблица
для seed 2 (строка 77) заявляет только `off_lattice_coordinate` на шаге 0; фактически
шаг 0 после ресайза даёт **дополнительно** `wall_carrier` — запись толщины, отвязавшуюся
от какого-либо ребра/перегородки (класс #253, «не исчезла ли запись толщины», один из
трёх инвариантов, прямо названных в задании на это ревью).
**Изолировано:** тот же результат воспроизводится на чистом `dev` (`4feeebc3`) с
одной лишь новой фикстурой (без единой строки продуктового кода этой задачи) —
`git worktree add origin/dev`, скопирован `test/fixtures/real-plan-second-floor.json` и
debt-строка из смока, `npm run bundle:sync`, тот же вызов даёт тот же `wall_carrier`.
Значит, баг живёт в `resize.ts` (не тронут этим диффом) и не является регрессией
координат-присвоения этой задачи — но именно эта задача **выбрала** внести три
проблемных записи прямо в `test/fixtures/real-plan-second-floor.json`, разделяемый с
уже существующим смоком #297, вместо предложенной самой спецификацией альтернативы
(«либо заводится третья фикстура», §13 AC6) — и не привела разделяемый смок в
соответствие. Сейчас гейт красный на коде, уходящем в `dev`.
### Medium-1 (в скоупе) — `check-docs` красный, скриншоты не пересняты
См. таблицу гейтов. `docs/**` изменения этой задачи вводят новый видимый слой
(диагностическая проекция) и правят множество канонических документов, но
`docs/images/screenshots.json`/фингерпринт не обновлён. PROCESS.md §8 называет ровно
эту ошибку («скриншоты не пересняли в #230 и #234, и `dev` стоял с красным job `docs`»)
и требует пересъёмки «тем же коммитом». Чинится: `Docs screenshots`
(`workflow_dispatch`) → `npm run docs:accept -- --reviewed --from=<артефакт>` тем же
issue.
### Medium-2 (в скоупе) — счётчик удаления drafts делит сообщение с «мусором сетки» (одно число, два разных источника смысла)
`src/plan-optimizer.ts:634-637`:
```ts
if (reconciled.removedDrafts) {
alignReport.removedDrafts += reconciled.removedDrafts;
...
```
`reconciled.removedDrafts` (новая причина: draft доказанно полностью совпадает с
solid-стенами, §7) прибавляется к тому же `alignReport.removedDrafts`, который питает
существующее сообщение `gs.align_removed_drafts` — «Invalid outlines collapsed by the
grid and removed: {n}.» / «Схлопнувшиеся на сетке некорректные контуры удалены: {n}.»
(`src/houseplan-card.ts:16510-16513`). Единственная i18n-правка этого диффа —
`gs.optimize_coincident_partitions` (теперь верно говорит про «hidden independent wall
sections»); для удаления drafts новой строки нет вовсе.
Пользователь, у которого Optimize удалит `draft-mt7igts5` (доказанно избыточный, лежит
на стене — причина №2 из этого самого issue), увидит текст «схлопнувшийся на сетке
некорректный контур удалён» — неверное объяснение причины. Это ровно тот класс
дефекта, который правило «одно число — один источник» просит проверять: одна и та же
цифра в отчёте теперь означает два разных, несвязанных события без разделения в тексте.
Не механический тест `test/single-source-numbers.test.mjs` (он про форматирование
единиц) — смысловая часть правила, ответственность ревью.
### Medium-3 (в скоупе) — порядок слоёв: диагностика рисуется НАД transient-превью инструмента «Проём»
`src/houseplan-card.ts:17381-17394`: `_renderOpeningPlacementPreview()` и
`_renderOpeningDimensionGuides()` (призрак двери/окна и размерные направляющие)
находятся в шаблоне **раньше** нового комбинированного слоя (`planSnap`/
`hiddenWallDiagnostic`) — то есть рисуются раньше = ниже по z-order, а диагностика
поверх них. §9.2 требует обратного: слой должен быть painted «до... transient
previews», то есть **под** ними. Ниже по документу диагностика по-прежнему корректно
остаётся под реальными openings и resize-chrome — нарушение касается только этих двух
превью инструмента «Проём». Подтверждено чтением (агент-ревьюер диагностического
слоя); не воспроизведено визуально из экономии времени, но расположение в шаблоне
однозначно.
### Medium-4 (в скоупе) — backend §8: неизвестные поля partition и rehost на другой residual не доказаны
Из отчёта агента-ревьюера backend (полные репро в его выводе, здесь — суть):
- Неизвестные поля **исходной partition** не проверяются вовсе:
`_safe_optimize_partition_rehost` читает у `old_partition` только `a`, `b`, `cm`; в
отличие от opening (`old_stable == new_stable`, полное сравнение ключей), партиция не
сверяется на неизвестные поля. §6.1/§8 явно требуют fail-closed на этот случай.
- Когда hosted opening по правилам §6.3 переезжает на **другой** id остатка (второй/
третий residual), проверку берёт на себя не `_safe_optimize_partition_rehost`, а
старый (до-#296) «строгий» путь «rigid host translation», который проверяет только
jamb-margin в новой конфигурации и не сверяет абсолютную геометрию (center/angle/
length) со старым opening. Существующий тест
`test_partition_opening_jamb_delta_preserves_legacy_and_checks_direct_geometry`
показывает, что перенос partition разрешён без такой сверки.
### Medium-5 (в скоупе) — тестовый план §14.7 (mutation gate) выполнен частично
`scripts/mutation-gate.mjs` в этом диффе только переименовывает/адаптирует два
**уже существовавших** мутанта #276 (`optimizer-coincident-opening-rehost-disabled`,
переименованный `optimizer-coincident-residual-dropped`) к новой структуре кода. Ни
одного нового мутанта нет для явно перечисленных в §14 п.7 сценариев: «заменить max
толщины», «доверить frontend delta», «поместить overlay под стенами». Проверено
`grep` по всему файлу — других #296-специфичных мутантов для `coincident-partitions.ts`
или `validation.py` нет.
### Medium-6 (в скоупе) — пробелы и регрессия тестового покрытия ядра алгоритма
Подтверждено субагентом чтением и точечными запусками против `test-build`:
- **AC9 явно требует «limit unit»** — ни один тест не упоминает `MAX_PARTITIONS`/
`MAX_WALLS` (2000/500). Логика по чтению верна и по чтению fail-closed (лимит
проверяется до мутации `walls/partitions`), но не защищена регрессией, как того
прямо требует акцептанс-критерий.
- Нет теста на opening, целиком лежащий на **остатке** (§6.4, средний из трёх
случаев) — только ручная проверка агентом (`host.id` меняется на `p~r-...`,
геометрия и `openingsRehosted` не трогаются, верно, но без автотеста).
- Нет теста на разную итоговую толщину **между несколькими безопасными кусками
одного** partition — фикстура AC1 даёт «все стали 30», что не демонстрирует
раздельный `max()` по кускам (агент подтвердил раздельность вручную: 10/25 см на
соседних кусках остаются раздельными).
- Нет теста на per-piece column-blocking (колонна блокирует один кусок многокускового
partition, другие реконсилируются).
- **Регрессия покрытия:** удалён тест-вариант «overlapping draft» из «issue 276 fails
closed for an orphan host, overlap, draft, column and unknown partition data»
(переименован без слова draft) и заменён только тестом на удаление **полностью**
избыточного draft. Случай «активный, не полностью избыточный draft блокирует
partition-piece, но сам не удаляется» — из логики жив (агент подтвердил запуском),
но теперь не защищён ни одним тестом ни в старом, ни в новом файле.
## Проверено и корректно
- **AC1/AC5 (реальный план).** `test/optimize-hidden-obstacles.test.mjs` (`issue 296 real
second floor removes all three hidden blockers in one Optimize`) — реальные три
объекта пропадают, `partitionsReconciled>=2`, `removedDrafts==1`, профиль
`30/30/30`, идемпотентность на повторном Optimize. Плюс
`test/resize-availability-audit.test.mjs`: на `real-plan-second-floor.json`
`duplicate-physical-wall: 7→0`, `enabled: 5→9`, `real-plan-first-floor.json` не
изменился — реальный сквозной путь через продуктовый аудит, не мок.
- **Направление входа, owner-kind, per-piece толщина, per-piece columnBlocks,
MAX_PARTITIONS/MAX_WALLS fail-closed, «open» ≠ «есть проём» в WallInterval** —
проверено чтением и точечными исполняемыми экспериментами (агент), контрпримеры не
найдены.
- **Гейт видимости диагностического слоя** (`_markup` = `_mode==='plan'`) и его
отсутствие в View/kiosk — тот же гейт, что у #137, подтверждено смоком
(`viewHasNoOverlay`).
- **pointer-events/aria-hidden**, изоляция от `buildPlanSnapGeometry()`/snap-resolver —
подтверждено чтением и юнит-тестом (`findSharedRoomSnapSegment(...)?.sourceKind ===
'room'` не меняется).
- **Кэширование диагностики по `_cfgEpoch`**, не по pointermove — подтверждено чтением,
паттерн идентичен существующему `_planSnapGeometrySnapshot()`.
- Backend: `allow_optimize_rehost=true` не просачивается в обычный `config/set` и
импорт (проверено чтением вызовов в `websocket_api.py`/`import_export.py`); там, где
проверка **есть** (opening с footprint внутри одного или нескольких room-edge),
`_segments_cover_target` — честный геометрический пересчёт (коллинеарность +
слияние интервалов), не сверка id-списков; при любом исключении парсинга —
`False`.
- Документация (`CHANGELOG.md/.ru.md`, `USER-GUIDE.md/.ru.md`, `CANVAS.md`,
`RESIZE.md`, `WALL-THICKNESS.md`, `ARCHITECTURE.md`) согласована с реализацией и
друг с другом по формулировкам piecewise-поведения и диагностики.
- Трейлеры коммита: `Issue: #296`, `User-Visible: yes`, оба changelog правлены в том
же коммите.
## Не проверялось (и почему)
- Полный набор `demo/smoke_*.mjs` (185 файлов) и `npm run golden:verify` по всем
существующим сценариям — предрелизный гейт (§8); дрейф пикселей у ранее принятых
golden-сценариев (не только двух новых) не проверялся построчно из бюджета времени.
Учитывая находку High-2 (изменился порядок отрисовки `_renderPlanSnapOverlay`
относительно `_renderOpeningPlacementPreview`/`_renderRoomHoverOutline`/editing
`_renderOpenWalls` — раньше слой стоял сразу после `_renderWallBodies`, теперь
значительно позже), нельзя исключить визуальный дрейф существующих сценариев с
активным #137-overlay; отдельно от AC7 не воспроизведено — оставляю как открытый
риск для `golden:verify` на предрелизе.
- Performance-бенчмарки (`demo/benchmark_coincident_partitions.mjs` и др.) — не
запускались; спецификация делает обновление бюджетов условным, а свежих мутантов на
производительность в этой задаче не добавлено.
- Полный HA/Linux backend harness — недоступен в текущей среде; прогнан только
«чистый» `pytest tests_backend/test_validation.py` без Home Assistant (не требует
импорта `homeassistant`/`fcntl`, тест грузит `validation.py` напрямую).
- Приватная фикстура владельца `C:\Temp\44.json` (backend/frontend оба) — недоступна,
пропускается тестами штатно (`t.skip`).
## Закрытие раунда r0 (спецификация)
Спек-ревью (`SPEC-REVIEW-296-r1`, зелёное) не образует цикл (§4, #227) и не входит в
бюджет код-ревью. Три Low-наблюдения того ревью (терминология §2, канонический
порядок id остатков §6.3/§12.3, несуществующий бенчмарк AC9/§14.7) сняты автором
ревью с явной пометкой «решить на код-ревью» — они здесь и разобраны: порядок id
остатков реализован (первый в каноническом порядке = исходный id, подтверждено
тестом с детерминированными id), бенчмарк не создан и не назван явно — учтено в
Medium-6/«не проверялось».
## Итог
Вердикт: **красный**. Три High-находки блокируют: backend не доказывает удаление
partition без openings (ровно основной сценарий issue), диагностический слой не
показывается в инструменте Draw (AC7 буквально не выполнен, и собственный golden-
евиденс это не ловит), и разделяемый смок-фаззер `smoke_edit_walk.mjs` красный на
фикстуре, добавленной этой же задачей. Шесть Medium-находок в скоупе (docs-фингерпринт,
смешение счётчиков, порядок слоёв, два backend-пробела, недоделанный mutation-gate,
пробелы/регрессия тестового покрытия ядра) чинятся в этом же issue при возврате.