Files
houseplan-card/docs/reviews/CODE-REVIEW-273-r1.md
T
2026-08-23 17:33:06 +00:00

18 KiB
Raw Blame History

CODE-REVIEW-273-r1

  • Issue: #273 — Optimize сохраняет sub-grid островок толщины у T-узла: 22→15→22
  • Этап: код-ревью, заход r1, блокирующих циклов израсходовано 0 из 4
  • Диапазон: origin/dev...HEAD, коммиты e2de0db (spec r1→r2 правка), 3e8733a/6e2247a (ревью-документы ТЗ), 105a8f7 (fix: collapse thickness island beside one T-node), 3ab3cdc (docs: record optimizer topology evidence)
  • ТЗ: docs/specs/273-optimize-topology-island.md, ревью ТЗ зелёное на r2 (docs/reviews/SPEC-REVIEW-273-r2.md)

Скоуп проверки

Продуктовое изменение — один файл: src/plan-optimizer.ts (collapseIsolatedWallThicknessIslands). Остальное — тесты/фикстуры (test/plan-optimizer.test.mjs, demo/smoke_optimize_micro_interval.mjs, scripts/mutation-gate.mjs) и документация/changelog. Бэкенд (custom_components/**/*.py) не тронут, i18n-ключи не добавлены, миграций и compatibility-полей нет — соответствует §5 ТЗ («Не входит»).

Коммит 105a8f7 несёт Issue: #273 и User-Visible: yes с правками в обоих changelog в том же коммите; 3ab3cdc — User-Visible: no, только скриншот и его манифест. Ветка issue/273-optimize-topology-island, трейлеры корректны.

Как проверялось

Разбор кода (диф src/plan-optimizer.ts)

Старое условие #198:

if (isTopologyNode(a) || isTopologyNode(b)) continue;

где nodes был единым списком room-vertices + opening-endpoints. Новое:

if (isNode(a, openingNodes) || isNode(b, openingNodes)) continue;
if (isNode(a, roomNodes) && isNode(b, roomNodes)) continue;

Разбор по истине/лжи:

  • любой конец на opening/open-span endpoint → блок, как и раньше (условие не ослаблено для проёмов);
  • оба конца — room/T-topology vertex → блок (сохраняет AC3, «два topology endpoints»);
  • ровно один конец — room-vertex, второй не room-vertex и не opening → не блокируется — это и есть узкое расширение, заявленное ТЗ §6.2;
  • ни один конец не room-vertex и не opening → не блокируется, как и в исходном коде #198 (случай, к этой задаче не относящийся, поведение не изменилось).

roomNodes/openingNodes строятся из всех комнат/openCuts конфигурации (не только текущей комнаты профиля), поэтому «T-узел» ловится, даже если он только вершина другой (перпендикулярной) комнаты — ровно репродукция из issue, где island соседствует с T-узлом чужого профиля.

Кандидат по-прежнему требует, чтобы все три куска (left/centre/right) были детьми одного profile.parent — то есть лежали на одном исходном прямом polygon-edge. Я проверил отдельно тест atVertex (унаследованный из #198, не переписанный этим диффом): там вершина комнаты вставлена внутрь того же ребра, что физически расщепляет parent-группу на два разных индекса — кандидат поэтому даже не собирается, независимо от новой классификации узла. Это значит, что тест продолжает проходить не потому, что «одна вершина всё ещё блокирует», а потому что расщеплённый parent — другая причина блокировки; семантического противоречия с новым правилом нет, но стоит иметь в виду при следующем изменении этой логики: вставка вершины в середину ребра и «настоящий» T-узел от другой комнаты — разные code path, и оба сейчас блокируют/пропускают корректно, но по разным причинам. Не заводил как находку — это наблюдение о устройстве кода, не дефект.

collapseIsolatedWallThicknessIslands не мутирует входные rooms/walls (проверено чтением: out = walls.slice(), [...a, ...b] копии в exactMatches) и вызывается ровно из одного места — plan-optimizer.ts:501, внутри цикла optimizePlans(), до normalizeWallIntervals()/degradeWalls(). Runtime/Save/render helpers его не вызывают (AC7 подтверждён чтением, не исполнением).

Гейты — что прогнано и с каким результатом

Гейт Команда Результат
Typecheck npx tsc --noEmit чисто, без ошибок
Unit npm test # tests 1163 / pass 1163 / fail 0 / skipped 0
Build + bundle parity 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 Documentation checks passed (7 files, 10 external links) — подтверждает, что принятые скриншоты (3ab3cdc) соответствуют текущему src/**
Целевой mutation node scripts/mutation-gate.mjs --id=optimizer-single-topology-island-blocked поймано 1 из 1; чистый прогон (--test-name-pattern="issue 273 Optimize") зелёный, мутант (замена && на || в новом условии, т.е. откат к «любой конец — room-node» блокирует) красит тест — дисциплина «тест умеет падать» подтверждена лично, а не со слов автора
Выбор смоков node scripts/smoke-select.mjs --base origin/dev --head HEAD «Прямое совпадение» только demo/smoke_decor_layer_order.mjs (символ roomPoly); порог «широкого» символа — 34, здесь 1
Смок из выбора node demo/smoke_decor_layer_order.mjs OK, все 25 проверок true
Целевой смок из AC6 node demo/smoke_optimize_micro_interval.mjs OK, все 11 проверок true, включая новую reloadKeepsCanonicalRun: true
Инварианты модели входят в npm test (test/model-invariants.test.mjs подпадает под test/*.test.mjs) зелёные в общем прогоне

Гейты, которые не прогонял, и почему

  • npm run golden:verify — не прогонял. Diff меняет геометрию только в узко описанном граничном случае (island у ровно одного T-узла на прямом ребре); проверил demo/golden/matrix.mjs — единственные Optimize-сценарии в golden это optimize-preflight-dialog-* и optimize-orphan-references-*, оба про диалоги на фиксированном пространстве golden-geometry, не про эту конкретную топологию стен. Полный набор — предрелизный гейт (§8 PROCESS.md), совпадений с этим фиксом не нашёл, поэтому сознательно пропущен на этом раунде.
  • python -m pytest tests_backend — не прогонял, диф не касается custom_components/**/*.py (подтверждено git diff --stat).
  • Полный smoke-набор (ls demo/smoke_*.mjs) — не прогонял целиком, задача локальна к одному optimizer-хелперу; прогнаны названный в AC6 смок и единственный смок, на который указал smoke-select.
  • Performance-профили — не названы в AC, изменение не на чувствительном к перфу пути (единичный, admin-only Optimize pass, не render/state-tick).
  • Screenshot-приёмку (docs/images/09-device-info.png, screenshots.json) не переснимал и не сверял попиксельно вручную — доверился тому, что check-docs.mjs подтверждает согласованность зафиксированного sourceFingerprint с текущим деревом, и записи автора о Linux Chromium 151.0.7922.34, 10/10, единственная разница 17 px/1 канал в anti-aliasing зоне, не связанной с этой задачей.

AC — доказательства

AC Как доказан Проверено
AC1 (реальный 22→15→22 у T-node схлопывается) test('issue 273 Optimize collapses the beta.5 island beside one T-node'): minimised fixture с T-узлом от отдельной branch-комнаты; проверяет walls.length===1, cm===22, точные a/b, wallsMerged===2 Тест умеет падать: без фикса atVertex-класса блокировки candidate не собрался бы (walls.length осталось бы 3) — подтверждено мутационным гейтом
AC2 (визуальная толщина ровная) Тот же тест: wallBodiesGeometry() + pointInPhysicalGeometry пробы в 3 точках вдоль бывшей ступени, подтверждают непрерывную внешнюю грань Пробы стоят только после Optimize; отдельного «до»-пробника, различающего 15/22 см half-depth, в диффе нет — тесту это не обязательно (перед Optimize wallIntervals явно содержит cm===15, что доказывает наличие ступени иначе), но расположение доказательства не совпадает с ТЗ, которое ожидало его в test/wall-thickness.test.mjs. Файл не критичен, доказательство по существу есть — фиксирую как Low, не блокирует
AC3 (два topology endpoints защищены) Новый тест betweenTwoNodes в 'micro-interval cleanup preserves ambiguous and topological boundaries': island между двумя настоящими T-узлами (branch + branch-2) остаётся deepEqual исходным walls Проверено исполнением
AC4 (остальная negative-матрица #198 зелёная) Существующие тесты (unequal, overlapping, atVertex, atOpening, chain) не переписаны, прогнаны в том же файле npm test зелёный
AC5 (детерминизм/immutability) Существующий тест 'micro-interval cleanup is endpoint, input-order and room-order invariant without mutation' не тронут этим диффом; код классификации узла не участвует в порядке применения кандидатов Унаследовано: логика сортировки/применения кандидатов ([...candidates.values()].filter(...).sort(...)) этим диффом не менялась — только предикат допуска
AC6 (Preview/Apply/Undo/idempotence) demo/smoke_optimize_micro_interval.mjs, прогнан лично OK, 11/11 true, включая добавленный reloadKeepsCanonicalRun
AC7 (runtime lossless) Единственный call site collapseIsolatedWallThicknessIslands — plan-optimizer.ts:501 внутри optimizePlans() Проверено чтением, не исполнением
AC8 (мутант ловит широкий guard) scripts/mutation-gate.mjs, id optimizer-single-topology-island-blocked, патч &&→|| на новой строке Прогнан лично: поймано 1 из 1
AC9 (локальные гейты) См. таблицу выше Все, что применимо к диффу, прогнаны

Находки

Блокирующих (High) и находок в скоупе (Medium) нет.

Low (не блокирует, оставляю на усмотрение — по правилам §2.7 Low либо правится, либо снимается решением ревьюера с записью; снимаю без правки):

  1. AC2 доказательство лежит не в заявленном файле. ТЗ (§9 AC2) ожидало focused geometry unit в test/wall-thickness.test.mjs; по факту гео-пробы добавлены прямо в test/plan-optimizer.test.mjs (импортирует wallBodiesGeometry/pointInPhysicalGeometry оттуда). Доказательство по существу присутствует и я его исполнил — файл иной, но эквивалентен по покрытию. Снимаю без правки: место теста — техническое решение автора (§7.1 PROCESS.md — «где хранится тест» не наблюдаемо пользователем).
  2. Одно число — один источник: новый код не вводит ни одного нового видимого пользователю числа (спецификация прямо фиксирует «Нового счётчика/строки нет», и это так — wallsMerged считается там же, где и раньше, единственным местом в optimizePlans(), и отображается в preview из того же report). Проверка выполнена, риска дублирования источника не нашёл — фиксирую факт проверки, а не находку.

Что проверено и корректно

  • Контракт §6.2 ТЗ (ровно один T-endpoint, второй синтетический, не opening и не второй topology node) реализован именно так, как описан, включая случай, когда T-узел приходит от вершины другой комнаты, а не от вставки в тот же profile-edge.
  • §6.3 («всегда блокирующие случаи») сохранены: opening endpoint блокирует безусловно (проверка идёт первой), два topology endpoints блокируют, разные соседние cm не создают кандидата (leftCm !== rightCm уже до вызова isNode), конфликтующие exact owners отклоняются (new Set(...).size > 1 → continue) — покрыто существующим тестом overlapping.
  • T-node координата и инцидентная перпендикулярная комната действительно не меняются (AC1-тест сверяет rooms побайтово через canonicalizeConfigGeometry).
  • Документация (WALL-THICKNESS.md, CONFIG-COMPATIBILITY.md, TESTING.md, USER-GUIDE(.ru).md) точно описывает новый контракт и не содержит устаревшего/чрезмерного обещания про partition/draft (то, что было Medium на ревью ТЗ r1, закрыто в §14 п.4 предположением — код действительно не принимает partitions/drafts на вход этого хелпера, что подтверждает сигнатура функции).
  • Changelog RU+EN обновлены в том же коммите, что и поведение (User-Visible: yes).
  • Три копии бандла синхронны, typecheck и полный unit-набор зелёные.

Чего не проверял

См. раздел «Гейты, которые не прогонял, и почему» выше: golden:verify (полный набор, предрелизный гейт, ни один golden-сценарий не задевает эту топологию), pytest tests_backend (бэкенд не тронут), полный smoke-набор (прогнаны только AC6-смок и смок из smoke-select), performance-профили (не названы в AC), попиксельная сверка PNG-скриншота вручную (доверился check-docs.mjs + записи автора).

Вердикт

Зелёный. AC1–AC9 доказаны — либо исполняемым тестом, который я прогнал лично и для AC1/AC8 подтвердил его способность падать через mutation-gate, либо (AC5, AC7) чтением кода с явной пометкой «проверено чтением, не исполнением». High и Medium-в-скоупе находок нет; два Low сняты без правки с записью выше.