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

26 KiB
Raw Blame History

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 никогда не находят реального совпадения.

Воспроизведено исполнением, дважды:

  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.