Files
houseplan-card/docs/reviews/CODE-REVIEW-229-r1.md
T
2026-08-21 09:57:36 +00:00

283 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CODE-REVIEW-229-r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/229
- **Спецификация:** `docs/specs/229-merge-collinear-partitions.md` (зелёное ревью r3, `docs/reviews/SPEC-REVIEW-229-r3.md`)
- **Ветка:** `issue/229-merge-collinear-partitions`
- **Коммиты разбора:** `e6be43b` (feat), `50a00e4` (docs: скриншоты)
- **Заход:** r1 · блокирующих циклов израсходовано 0/4 до этого вердикта
- **Диапазон:** `git diff origin/dev...HEAD`
## Скоуп
Новый чистый модуль `src/wall-merge.ts` (`mergeCollinearPartitions`,
`junctionAt`, `applyOpeningMoves`) плюс два места вызова: `_finishWallChain` →
`_mergeSpacePartitions` в `src/houseplan-card.ts` (слияние своей цепочки при
рисовании, §8.6) и `optimizePlans` в `src/plan-optimizer.ts` (слияние всего
пространства при «Оптимизировать планы», §4.2). Плюс i18n строка счётчика,
changelog RU+EN, `docs/USER-GUIDE.ru.md`, юниты, новый смок
`demo/smoke_wall_chain_merge.mjs`, семь записей мутационного гейта.
## Как проверялось
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | чисто |
| Unit | `npm test` | 1003 pass / 0 fail |
| Build + сверка бандлов | `npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js && cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` | идентичны |
| Смок (AC1/§8.6, названный автором) | `node demo/smoke_wall_chain_merge.mjs` | OK, но см. High-2 — смок не способен обнаружить найденный дефект (объяснение ниже) |
| Смежные смоки (пересчёт проёмов/стыков, задетые diff'ом) | `node demo/smoke_partition_openings.mjs`, `node demo/smoke_wall_junctions.mjs`, `node demo/smoke_optimize_coordinate_canonicalization.mjs` | все OK, регрессий не нашёл |
| Собственная проверка через реальный клик-путь редактора (не смок из репозитория, вспомогательный скрипт вне репозитория, тот же `demo/serve.mjs`) | см. воспроизведение High-1 и High-2 ниже | обнаружил 2 дефекта |
| Прямой вызов `optimizePlans` из `test-build/plan-optimizer.js` с комнатой | см. воспроизведение High-1 | обнаружил дефект |
**Не прогонял и почему:** `npm run golden:verify` — спецификация §15 явно
говорит «golden не затрагивается: видимый результат на плане не меняется»,
diff подтверждает — правки внутреннего представления, не рендера.
`python -m pytest tests_backend` — diff не касается `custom_components/**/*.py`.
Полный набор из 127 браузерных смоков — diff и AC называют одну подсистему
(перегородки/проёмы/оптимизатор), не весь продукт; прогнал названный автором
смок плюс три соседних по геометрии проёмов и стыков. Performance-профили — в
AC не названы, спецификация §10 заявляет отсутствие влияния на кадр рендера
достаточно правдоподобно (разовый проход по десяткам записей), не тронуто.
## Находки
### High-1 — «узел на стыке с комнатой» не работает: слияние проходит сквозь T-стык к стене комнаты (§8.2, AC2)
И `src/houseplan-card.ts:6556-6559` (`_mergeSpacePartitions`), и
`src/plan-optimizer.ts:506-509` (`optimizePlans`) строят `geometry.roomPolygons`
так:
```ts
roomPolygons: rooms
.map((room) => roomPoly(room))
.filter((poly): poly is number[][] => !!poly)
.map((poly) => poly.map((point) => [point[0] / NORM_W, point[1] / NORM_W])),
```
`roomPoly(room)` в обоих местах вызывается на **уже сохранённом** `space.rooms`
(не на де-нормализованной модели из `spaceModels()`), а `room.poly` хранится
**уже нормализованным** к `[0,1]` — это видно из места, где полигон комнаты
записывается: `houseplan-card.ts:12638`, `poly: verts.map((p) => [p[0] / NORM_W, p[1] / NORM_W])`.
Тот же `space.partitions`, переданный на предыдущей строке в
`mergeCollinearPartitions(space.partitions || [], …)`, используется БЕЗ какой-либо
конверсии — потому что он в той же нормализованной шкале. Тот же файл
`plan-optimizer.ts` двумя строками раньше (492-495) явно демонстрирует
обратное преобразование: `degradeWalls(walls, space.rooms || [], GRID_STEP_N, 1,
cuts.map((c) => [c[0] / NORM_W, …]))` — здесь `cuts` (сырые, в масштабе
NORM_W) **делятся** на `NORM_W`, чтобы сравняться по шкале с `space.rooms`,
используемым без конверсии. То есть в этом же файле уже задокументировано,
что `space.rooms` — нормализованная шкала; новый код применяет к ней ещё одно
деление, получая координаты около `~0.0001` — на три порядка меньше типичных
координат перегородок (`~0.1…0.9`). Практический эффект: полигон комнаты
после этого находится в точке, неотличимой от начала координат, и
`distToSegment`/`junctionAt` никогда не находят реального совпадения.
**Воспроизведено исполнением, дважды:**
1. Прямой вызов `optimizePlans` (`test-build/plan-optimizer.js`, тот же билд,
что использует `npm test`) с комнатой, чья верхняя сторона идёт по
`y=0.4` от `x=0.1` до `x=0.5`, и двумя коллинеарными перегородками
`[0.1,0.4]→[0.3,0.4]` и `[0.3,0.4]→[0.5,0.4]` (стык — ровно середина
стороны комнаты, canonical T-стык из AC2 и `docs/specs/141-wall-junctions.md`):
`result.report.partitionsMerged === 1`, две записи схлопнулись в одну —
AC2 в этой части ложно-зелёный.
2. То же в браузере через реальный редактор: комната с тем же полигоном,
три клика вдоль её верхней стороны (`(100,400)→(300,400)→(500,400)` в
координатах канвы), `_activateMarkupTool('select')` для завершения цепочки
— итог `space.partitions.length === 1` вместо ожидаемых двух.
Юнит-тесты `test/wall-merge.test.mjs` (`a room side keeps the node…`) этот
дефект не ловят, потому что вызывают `mergeCollinearPartitions` напрямую с
полигоном комнаты, уже заданным в масштабе, совпадающем с перегородками, —
интеграционное лишнее деление там не участвует. `test/plan-optimizer.test.mjs`
тоже не ловит: все три новых теста для AC7 используют `rooms: []`. Мутант
`junction-checks-room-vertices-only` в `scripts/mutation-gate.mjs` целится в
`src/wall-merge.ts` напрямую тем же юнитом — интеграционную обвязку не
проверяет.
**Почему в скоупе и почему High.** Это ровно тот случай T-стыка к середине
комнатной стены, который потребовал двух раундов ревью ТЗ (M2 → r2, находка
r2 → r3) и явно назван нормативным в §8.2/AC2 как обязательный к сохранению.
В реальной интеграции защита не работает: любая перегородка, упирающаяся в
середину стены комнаты, теряет узел при рисовании или при «Оптимизировать
планы», как только на этом стыке есть ещё один коллинеарный отрезок той же
толщины. Правка не требует пересмотра контракта — убрать лишнее `/NORM_W` в
обоих местах (`houseplan-card.ts:6559`, `plan-optimizer.ts:509`).
### High-2 — слияние «во что упёрлась цепочка» не работает через реальный клик-путь (§8.6)
`_finishWallChain` (`houseplan-card.ts:6595-6616`) вызывает
`this._mergeSpacePartitions(sp, drawnIds)` (строка 6612) **до** того, как
активный черновик убирается из `sp.room_drafts` (фильтрация — строки
6613-6616, ПОСЛЕ вызова слияния). Каждый клик при рисовании стены сохраняет
прогресс через `_persistActiveDraftSegment` (`houseplan-card.ts:7420-7443`) —
в `sp.room_drafts` появляется запись с `id = this._activeDraftId` и
`points = this._path`, то есть полный путь **именно той цепочки, которая
сейчас завершается**. `_mergeSpacePartitions` строит `geometry.draftEnds` из
`sp.room_drafts` безусловно (строки 6561-6564), не исключая
`this._activeDraftId` — в отличие от уже существующего в этом же файле
паттерна для точно такой же проблемы: `buildPlanSnapGeometry`
(`plan-snap-overlay.ts:150-151`) явно пропускает активный черновик
(`if (draft.id === options.activeDraftId) continue;`), потому что черновик,
который рисуется прямо сейчас, не является «другим» сохранённым черновиком,
на который можно вернуться.
Эффект: если новая цепочка начинается или заканчивается ровно там, где
кончается уже существующая коллинеарная перегородка той же толщины (самый
естественный сценарий продолжения стены во второй сессии рисования), точка
стыка совпадает с собственным `draftEnds` черновика, который вот-вот
исчезнет, — `junctionAt` находит «причину» и слияние не происходит.
**Воспроизведено исполнением через реальный клик-путь (`_markupClick`, не
через прямое присваивание `_path`):** нарисована стена `(200,500)→(300,500)`,
толщина 15, завершена сменой инструмента на `select` (`sp.partitions.length
=== 1`, `sp.room_drafts` пуст). Затем во второй сессии рисования — стена
`(300,500)→(420,500)`, та же толщина, начинающаяся ровно в конце первой.
После завершения: `sp.partitions.length === 2`, коллинеарные отрезки той же
толщины с общим концом не срослись.
**Смок `demo/smoke_wall_chain_merge.mjs` не ловит это** ровно потому, что
его третий чек (`chainMergesIntoTheWallItTouches`) задаёт `c._path`
присваиванием, минуя `_persistActiveDraftSegment` — `this._activeDraftId`
остаётся `null` весь тест, `sp.room_drafts` никогда не заполняется, и путь,
где лежит дефект, не исполняется вовсе. Смок проверяет «умеет ли модуль
слияния сращивать через существующую стену», а не «работает ли это при
рисовании» — то самое требование «тест умеет падать» (AGENTS.md/PROCESS.md
§2.7) в этом месте не выполнено, хотя формально смок называется в AC и
хендоффе как покрывающий именно этот сценарий.
**Почему в скоупе и почему High.** §8.6 прямо формулирует это как часть
контракта («слияние затрагивает… сегменты самой цепочки и те существующие
перегородки, с которыми она имеет общий конец»), это же явно заявлено в
сообщении коммита («Рисование сращивает только свою цепочку и то, чего она
коснулась») и в самом смоке. В реальном использовании эта часть контракта не
работает для самого частого случая — продолжения стены в новой сессии
рисования. Фикс мелкий и уже есть образец в этом же файле: не включать
`this._activeDraftId` в `draftEnds` (например, отфильтровать перед вызовом
`_mergeSpacePartitions`, либо — надёжнее — переставить фильтрацию
`sp.room_drafts` перед вызовом слияния, чтобы `_mergeSpacePartitions` вообще
не видел запись, которая всё равно исчезнет).
### Low — комментарий про допуски (`EPS_ANGLE`) не соответствует реализации, поведение корректно
`src/wall-merge.ts:20-23`: `EPS_ANGLE` документирован как «a fraction of one
grid pitch», но в `mergeCollinearPartitions` (строка 157) используется
напрямую, без умножения на `pitch`: `const angle = EPS_ANGLE;`. Это
математически оправдано — модуль векторного произведения единичных
направляющих (`cross`) безразмерен и не должен масштабироваться шагом сетки
(в отличие от `EPS_JOIN`, который действительно домножается на `pitch`,
строка 156). Поведение корректно и не противоречит AC5 (проверено юнитом
«tolerance forgives float noise and refuses a real gap»); расхождение чисто
документальное — комментарий вводит в заблуждение, будто оба допуска
масштабируются одинаково. Не блокирует, снимаю с записью: автор может
поправить формулировку комментария (например, «expressed as a plain
tolerance on the sine of the angle, independent of pitch») при следующей
правке этого файла, отдельного цикла ради одной строки комментария не
считаю оправданным.
## Что проверено и корректно
- **AC1** (пять кликов по прямой → одна запись): доказано юнитом
(`test/wall-merge.test.mjs`, «a straight chain… collapses») и смоком
(`straightRunIsOneRecord`), дополнительно перепроверено собственным
прогоном через реальный клик-путь — не задето находками выше, поскольку
внутренние стыки цепочки не попадают в `draftEnds` (только первая и
последняя точка всего пути).
- **AC2**, случаи «третья перегородка», «колонна», «конец черновика в
стороне» — юниты корректны; интеграционный масштаб для `columns` и
`draftEnds` (кроме самоссылки из High-2) выставлен верно — `wall_columns[].center`
хранится нормализованным (`houseplan-card.ts:7579`) и передаётся без
лишней конверсии, `room_drafts[].points` тоже (`houseplan-card.ts:7434`).
Сломан только под-случай «ребро комнаты» (High-1).
- **AC3** (проём не двигается, оба представления — `host.t` и материализованная
проекция `x/y/angle`): юниты `wall-merge.test.mjs` и
`plan-optimizer.test.mjs` доказывают на прямом и на развернувшемся стыке;
тест сформулирован так, что красен при пересчёте только `host.t` без
проекции (проверено чтением реализации `applyOpeningMoves` — вызывает
`materializePartitionOpening` после `resolvePartitionOpeningCompat` для
каждого перемещённого проёма, `wall-merge.ts:238-262`) — доказательство
соответствует AC3 буквально.
- **AC4** (разная толщина не сращивается): юнит корректен, `pairAt` сравнивает
`cm` строго (`wall-merge.ts:132`).
- **AC5** (допуски прощают ULP-шум, не прощают разведённое): юнит корректен;
см. Low-находку про `EPS_ANGLE` — не влияет на результат.
- **AC6** (детерминизм, независимость от порядка входа): юнит проверяет
прямой/обратный/перетасованный порядок, корректно; сам механизм —
канонизация направления выжившей записи лексикографически
(`wall-merge.ts:189-192`) — устраняет реальный класс бага, который автор
описывает в хендоффе (переворот `host.t` при недетерминированном
направлении); переиспользование записей `moves` при цепочке слияний
(`wall-merge.ts:203-216`) проверено чтением и юнитом «an opening survives a
chain of merges and a reversed survivor» — корректно.
- **AC7** (оптимизатор сращивает накопленное, идемпотентен): юнит
`plan-optimizer.test.mjs` проверяет оба свойства на трёх коллинеарных
отрезках плюс независимую стену в стороне; повторный прогон даёт
`partitionsMerged === 0` — корректно для случая без комнат (см. High-1 для
случая с комнатами).
- **AC8** (ничего лишнего; область слияния при рисовании ограничена
компонентой связности новой цепочки): юниты «finishing a chain does not
sweep unrelated seams» и «a chain merges into what it was drawn onto»
доказывают это для чистого модуля; мутант `chain-merge-sweeps-whole-space`
проверен применением патча (1 падение, соответствует хендоффу). Механизм
`seedIds`/расширение множества выживших id при последовательных слияниях
(`wall-merge.ts:217`) проверен чтением — корректен. Это свойство **не
противоречит** High-2: там цепочка НЕ сращивается, когда должна была бы, а
не сращивается с чем-то лишним.
- **AC9** (release-артефакты): `docs/CHANGELOG.md`/`docs/CHANGELOG.ru.md`
правлены в том же коммите `e6be43b`, что и код (`User-Visible: yes`), i18n
строка добавлена в оба языка и проверена тестом
(`test/i18n.test.mjs`), три копии бандла байт-в-байт идентичны (проверено
исполнением, `cmp` × 2).
- **Docs/`50a00e4`**: отдельный коммит `User-Visible: no`, скриншоты — автор
утверждает, что отпечаток `check-docs.mjs` был просрочен ещё на чистом
`dev` (несвязанный долг); не перепроверял это утверждение исполнением
(не относится к AC #229), содержательных изменений в кадрах сам коммит не
подразумевает.
- **Трейлеры и процесс:** оба коммита несут `Issue: #229`; `e6be43b` —
`User-Visible: yes` с изменением обоих changelog в том же коммите; `50a00e4`
— `User-Visible: no`, класс C (только `docs/images/**`,
`docs/images/screenshots.json`), корректно. `scripts/process-gate.mjs`
— не прогонял отдельно (автор указал прогон в хендоффе, офлайн-часть не
зависит от находок этого ревью).
## Чего не проверял
- Полный браузерный набор (127 смоков) — не запускал целиком, diff и AC не
требуют; прогнал названный автором смок плюс три смежных по геометрии
проёмов/стыков.
- `npm run golden:verify` — по спецификации видимый результат не меняется;
не перепроверял исполнением этого утверждения растеризацией.
- `python -m pytest tests_backend` — diff не касается Python.
- Производительность — не профилировал; утверждение спецификации о разовом
проходе по десяткам записей проверено чтением (`mergeCollinearPartitions`
— `O(n²)` на раунд слияния, вызывается на списке перегородок одного
пространства, не в цикле рендера).
- Обратимость через Undo/Redo слияния при рисовании (`_recordGeometry`
перед `_finishWallChain`, `houseplan-card.ts:6617`) — проверено только
чтением вызова, не отдельным сценарием отмены.
- Достоверность утверждения о просроченном отпечатке `check-docs.mjs` на
чистом `dev` в коммите `50a00e4` — принято со слов автора, не
перепроверял `git stash`/переисполнением на чистом `dev`.
## Вспомогательные материалы
Обе находки воспроизведены одноразовыми скриптами вне репозитория (не
коммитились, репозиторий не менялся): вызов `optimizePlans` напрямую из
`test-build/plan-optimizer.js` для High-1, и реальный клик-путь через
`demo/serve.mjs`/`page.evaluate` для обеих находок. Результаты вставлены в
текст находок буквально (значения `partitionsMerged`, `partitions.length`).
## Вердикт
**Красный.** Два High: критическая часть контракта §8.2 (узел на стыке с
комнатой) и §8.6 (слияние с тем, чего цепочка коснулась) не работают в
реальной интеграции, хотя специфицированы, заявлены в коммите и формально
«покрыты» смоком/юнитами, которые, как показано выше, не проверяют именно
эти пути. Обе находки — в скоупе задачи (это код, добавленный этим PR, а не
соседняя подсистема), обе чинятся точечно без пересмотра контракта:
убрать лишнее `/NORM_W` в двух местах (High-1) и не включать активный
черновик в `draftEnds`, либо переставить очистку `room_drafts` перед вызовом
слияния (High-2), плюс тест на каждый случай через реальный путь
(реальные клики / комната с полигоном), а не только через изолированный
`mergeCollinearPartitions`.