26 KiB
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
так:
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 никогда не находят реального совпадения.
Воспроизведено исполнением, дважды:
- Прямой вызов
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 в этой части ложно-зелёный. - То же в браузере через реальный редактор: комната с тем же полигоном,
три клика вдоль её верхней стороны (
(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.