30 KiB
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':
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:
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.jsonduplicate-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 при возврате.