mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 13:18:58 +00:00
192 lines
18 KiB
Markdown
192 lines
18 KiB
Markdown
# CODE-REVIEW-273-r1
|
||
|
||
- Issue: [#273](https://github.com/Matysh/houseplan-card/issues/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`:
|
||
|
||
```ts
|
||
if (isTopologyNode(a) || isTopologyNode(b)) continue;
|
||
```
|
||
|
||
где `nodes` был единым списком room-vertices + opening-endpoints. Новое:
|
||
|
||
```ts
|
||
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 сняты без правки с записью выше.
|