# 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_.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 при возврате.