18 KiB
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 Chromium151.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 либо правится, либо снимается решением ревьюера с записью; снимаю без правки):
- AC2 доказательство лежит не в заявленном файле. ТЗ (§9 AC2) ожидало
focused geometry unit в
test/wall-thickness.test.mjs; по факту гео-пробы добавлены прямо вtest/plan-optimizer.test.mjs(импортируетwallBodiesGeometry/pointInPhysicalGeometryоттуда). Доказательство по существу присутствует и я его исполнил — файл иной, но эквивалентен по покрытию. Снимаю без правки: место теста — техническое решение автора (§7.1 PROCESS.md — «где хранится тест» не наблюдаемо пользователем). - Одно число — один источник: новый код не вводит ни одного нового
видимого пользователю числа (спецификация прямо фиксирует «Нового
счётчика/строки нет», и это так —
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 сняты без правки с записью выше.