18 KiB
Code review — issue #141, cycle r1
Вердикт: красный · цикл r1/4 · High: 1 · Medium: 0
Ветка: issue/141-wall-joints · implementation-коммит
3e33f4a
· ТЗ: docs/specs/141-wall-junctions.md
(reviewed 2858175, зелёный SPEC-REVIEW-141-r1).
Скоуп проверки
Диапазон git diff origin/dev...HEAD — 25 файлов, ядро изменений:
src/wall-thickness.ts, src/physical-geometry.ts, src/space-render.ts,
src/houseplan-card.ts, плюс тесты (test/*.test.mjs), новый
demo/smoke_wall_junctions.mjs, demo/golden/matrix.mjs/harness.mjs,
документация (ARCHITECTURE.md, WALL-THICKNESS.md, LIGHT.md,
ISOMETRIC.md, USER-GUIDE.ru.md, TESTING.md, STATUS.md) и оба
changelog. Три копии bundle идентичны между собой.
Трейлеры единственного коммита 3e33f4a: Issue: #141 ·
User-Visible: yes; оба changelog обновлены в этом же коммите — требование
выполнено.
Как проверялось
Дешёвые гейты (всегда):
npx tsc --noEmit→ зелёный, без вывода.npm test→ 793/793 green (на Linux упомянутый автором Windows-only сбойprocess-gate.test.mjsне воспроизводится — ожидаемо).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— обе команды молча завершились успехом,git statusпосле билда пуст (комитнутые копии побайтно совпадают со свежей сборкой).
Гейты по необходимости (diff меняет геометрию рендера/света/iso, и под задачу заведён именной smoke plus golden-сценарии):
node demo/smoke_wall_junctions.mjs(назван в ТЗ §13.2 и в AC2/AC5/AC7-9) — упал:lineTargetGetsLocalJoinPatch: expected true, got false. Разбор ниже, в разделе «Находки».npm run golden:verify— не прогонялся полным набором. Инструмент отказывается верифицировать по одному сценарию (golden verify must run the complete matrix; use capture for a diagnostic --scenario run), а полный набор (67 сценариев) — предрелизный гейт; прогонять его сейчас нецелесообразно: ниже уже есть блокирующий High, найденный дешевле и точнее через smoke и прямое чтение/воспроизведение кода, а после исправления понадобится новый прогон в любом случае.python -m pytest tests_backend— не прогонялся: Python не тронут (AC12 подтверждён и diff'ом, и по ТЗ backend/schema не меняются).- performance-профили — не прогонялись: это предрелизный гейт (AC11 говорит про кеш/bounded pass, что проверено чтением кода, а не про изменение бюджета); диф не даёт повода подозревать причину именно в перформансе.
Находки
[High] Живой rubber-band превью первого сегмента контура/перегородки не рисуется вовсе
Файл: src/wall-thickness.ts:622-676 (unionSimpleBodies,
drawWallPreviewD).
Сценарий отказа: пользователь в Plan editor выбирает инструмент
«Контур» или «Перегородка», ставит первую точку и двигает курсор — то есть
самое частое, стартовое состояние рисования любой независимой стены/контура.
До клика на второй точке предпросмотр состоит ровно из одного сегмента.
drawWallPreviewD() в этом случае строит один linearWallBody, вызывает
linearWallJoinPatches(segments) (которая гарантированно возвращает [] при
segments.length < 2), получает joined.length === 1 и передаёт его в
unionSimpleBodies:
function unionSimpleBodies(bodies: number[][][]): any | null {
let geom: any = null;
for (const body of bodies) {
...
const piece: any = closedRing(body);
geom = geom ? union(geom, piece) : piece; // <-- первое тело НЕ проходит union()
}
return geom;
}
Для одного тела geom становится «голым» Polygon (Ring[], то есть
[ring]), а не ожидаемым MultiPolygon (Polygon[], то есть [[ring]]),
который во всех остальных местах кодовой базы гарантированно возвращает
union(...) (см. physical-geometry.ts: unionBodies, где даже единственное
тело всегда идёт через union(polygons[0]) и получает корректную обёртку —
проверено отдельно, ниже). drawWallPreviewD передаёт этот неверно
сформированный geom в polyclipToPathD, которая ожидает MultiPolygon и
итерирует for (const poly of geom) for (const ring of poly); получив вместо
poly голое кольцо (массив точек), она видит на месте «колец» отдельные точки
[x, y] (ring.length < 4 всегда) и молча ничего не добавляет в d. Функция
возвращает пустую строку, хотя выше стоит if (geom) return polyclipToPathD(geom); — то есть путь фиксируется как «успешный», и
единственный fallback (joined.map(polyToPath)...) не выполняется.
Как воспроизведено (не только чтением):
-
Прямой вызов скомпилированного
test-build/wall-thickness.js:import { drawWallPreviewD } from './test-build/wall-thickness.js'; drawWallPreviewD([[0,0],[100,0]], 8, false); // -> "" (было бы '"M ... Z"' до #141) drawWallPreviewD([[0,0],[100,0],[100,100]], 8, false); // -> корректный путь (2 сегмента, union() вызывается) -
Собственный smoke задачи (
demo/smoke_wall_junctions.mjs), написанный тем же автором для AC2/AC5, воспроизводит это в реальном рендере card: при одном сегменте рубер-бэнда, коснувшемся T-цели, DOM должен получить два элемента.drawwall-preview(основной штрих + join-patch), но получает один — потому что основной штрих пуст, а виден только join-patch. Отладочный прогон (см. приложенный вывод) подтверждает: атрибутdединственного найденного.drawwall-preview— это на самом деле patch ("M 500 700 L 491.66... Z M 500 700 L 508.33... Z"), а не полоса от (500,540) до (500,700), которую должен рисовать основной сегмент.
Почему это не только про «T-цель» из имени упавшей проверки. Причина не в
логике поиска target-сегментов (_drawPreviewJoinPatchD), а в
unionSimpleBodies: пустая строка возвращается для любого одиночного
сегмента без соединений — то есть при рисовании одиночной «Перегородки» (два
клика: старт+конец) весь live-preview во время наведения между этими кликами
не рисуется совсем, не только в стыке. Это прямой регресс базового поведения:
до этой задачи drawWallPreviewD строила d конкатенацией
polyToPath(quad) без union и работала для любого числа сегментов, включая
один.
Нарушенный контракт: ТЗ §7.5.1/7.5.2 и AC2 требуют, чтобы «после каждого
законченного segment открытый контур показывает joined body немедленно» и
чтобы rubber-band «использует тот же bounded mitre/bevel к предыдущему
segment». Ни то, ни другое не выполняется, когда сегмент один — вместо
недостающего угла (как было до, по формулировке ТЗ §2) пользователь не видит
вообще никакой заливки/штриха. Это хуже исходного дефекта issue, а не лучше, и
напрямую бьёт по J4/J6 (docs/SCOPE.md): администратор перестаёт видеть, что
рисует, в самый частый момент рисования.
Также замечу: ни один существующий unit-тест не покрывает именно
однократный (двухточечный) вызов drawWallPreviewD без закрытия — новый тест
«drawWallPreviewD returns a path for open and closed outlines» и его
дополнение используют 3-точечные (двухсегментные) входы, поэтому регресс не
пойман npm test и обнаружился только через специально написанный smoke.
Это не отдельная находка, а причина, по которой AC3/AC13 «зелёный unit»
не является доказательством для этого случая.
Требуется: доработка автора (например, всегда вызывать union(), а не
присваивать первое тело напрямую — по аналогии с уже корректным
unionBodies() в physical-geometry.ts), новый unit-тест на ровно один
сегмент без соединений, и повторный прогон
demo/smoke_wall_junctions.mjs до зелёного.
Что проверено и корректно
- AC1/AC3/AC4/AC6 (сохранённая геометрия узлов) — юнит-тесты
test/physical-geometry.test.mjs(«joined partitions fill straight and oblique endpoint teeth…», «endpoint-on-line T join…») иtest/wall-thickness.test.mjs(«linear wall joins bevel an excessive mitre…») проходят и содержательны: проверяют bounded mitre/bevel, инвариантность к порядку/направлению записей, near-miss вне epsilon, malformed/zero-length входы. Не вакуальны — наorigin/devфизически не существуетphysicalBodySet/linearWallJoinPatches, так что тест не мог бы даже собраться. - AC7/AC8 (единая физическая семантика для presentation/occlusion) —
прочитан
houseplan-card.ts:13625-13637: Glow/sun/light теперь получаютphysical(join-patches включены) какextraBodiesвwallBodiesGeometry(), а старый отдельный обходfor (const body of physical) occluders.push(...)убран из «счастливого» пути и остался только как fail-opaque fallback, когдаwallBodiesGeometryвернулаnull— именно то, что требует ТЗ §7.7.3. Подтверждено также прогоном smoke:cleanFloorUsesJoinedCorner,lightUsesJoinedCorner,savedRightAngleToothIsFilled— всеtrue. - AC7 (Plan/View/static/iso общий footprint) — smoke:
planViewParity,isoUsesJoinedFootprint,staticUsesSameJoinedPath— всеtrue;space-render.tsполучил собственныйstaticPhysicalBodiesCacheс тем же fingerprint-паттерном, что уже был уstaticWallGeometryCache. - AC9 (identity редактируемых записей) — smoke:
rawIdentityCountStaysPerSegment: true(raw-тел ровно 9 = 7 partitions + 2 сегмента drafts, конфиг не меняет число записей);_furnWallsтеперь явно использует_rawPhysicalBodiesR()(raw, не joined) — корректно для мебельного магнита, как и требует ТЗ. - AC12 (схема/бэкенд/i18n не меняются) — подтверждено diff'ом: ни один
файл
custom_components/**/*.py,src/types.ts(схема), i18n JSON не затронут. - AC13 (гейты, бандлы, документация в одном коммите) — единственный
implementation-коммит
3e33f4aнесётIssue: #141/User-Visible: yes, оба changelog,ARCHITECTURE.md/WALL-THICKNESS.md/LIGHT.md/ISOMETRIC.md/USER-GUIDE.ru.md/TESTING.md/STATUS.mdи все три копии bundle — свежая локальная сборка побайтно совпала с закоммиченными файлами. - previewDoesNotWriteOrSplitTarget / renderNeverRewritesConfig (smoke) —
true: вычисляемые узлы не пишут конфиг ни при рендере, ни при hover — соответствует ТЗ §9. - Мёртвый
draftBodies()(physical-geometry.ts:71) остался экспортирован, но больше нигде не используется продуктовым кодом (только собственное имя дублирует уже инлайненную вphysicalBodySetлогику для drafts) — Low, не блокирует; безопасно удалить в этой же задаче при исправлении High, но можно оставить, если автор считает риск правки того же файла в цикле r2 выше пользы. Записано, не заводится отдельным issue — правится или сознательно снимается автором вместе с основным фиксом.
Чего не проверял
npm run golden:verify(полный набор) — не прогонялся. Причина проста и явная: инструмент запрещает частичный прогон в режиме verify, а полный прогон (67 сценариев) — предрелизный гейт, непропорциональный ревью, когда блокирующий High уже найден дешевле. Новые golden-сценарииwall-junctions-*/isometric-wall-junctions-darkне просмотрены визуально — это стоит сделать в r2 вместе с починкой (эти сценарии, скорее всего, как раз показывали бы пустой rubber-band, если бы захватывали превью-момент;wall-junctions-plan-preview-light— по имени похоже, что именно этот сценарий должен был поймать баг, но без прогона это предположение, не факт).- Полный browser smoke-suite (127 файлов) — не прогонялся, вне
относящихся к задаче поверхностей; прогнан только целевой
smoke_wall_junctions.mjs, который и нашёл проблему. python -m pytest tests_backend— не прогонялся, Python не тронут.- Performance smoke / Full Performance — не прогонялись; это предрелизный гейт, и diff не даёт повода подозревать регресс производительности отдельно от найденного High.
- Ручное визуальное сравнение в браузере (не headless) — не выполнялось;
вывод основан на smoke/golden harness и прямом воспроизведении через
скомпилированный
test-build. - Drag/Undo/Redo отдельных partitions после join (часть AC9) — smoke проверяет только сохранение количества и id raw-тел, не сам факт перетаскивания/истории в интерактивном сценарии; отдельный ручной проход по этому пункту не делался.
Итог
High: 1 (описан выше, блокирует). Medium: 0. Low: 1 (мёртвый экспорт
draftBodies, не блокирует, правится по усмотрению автора).
Вердикт красный: цикл возвращается автору на исправление
unionSimpleBodies/drawWallPreviewD для одиночного сегмента, с последующим
зелёным прогоном npm test (включая новый регресс-тест на один сегмент) и
node demo/smoke_wall_junctions.mjs.