Files
houseplan-card/legacy/reviews/v1.64.0/CODE-REVIEW-141-r1.md
T
Claudeandclaude[bot] 0991c45374 fix(tools): архив переписывает относительные ссылки перенесённых документов (#682)
Ревью #682 r1, Medium: перенос добавляет документу уровень вложенности
(`docs/reviews/X.md` → `legacy/reviews/<тег>/X.md`, `docs/specs/` →
`legacy/specs/`), а относительные ссылки внутри перенесённых документов и в
соседях, ссылавшихся на них, никто не пересчитывал — на `97d19268` 53 битые
ссылки в 46 файлах (заявление «все 26 резолвятся» в `7feb6177` было верно
только до переноса документов ревью). Гейты архив не смотрят.

`reviews-archive.mjs`: `repairLinks` пересчитывает ссылку, если она не
резолвится от нового места, а цель находится от нового или старого места
через карту переносов; битая и до переноса ссылка не трогается. `--apply`
делает это само, `--repair-links=<rev>` — для всех переименований
`<rev>..HEAD`, `--check-links` печатает битые. Этим коммитом
`--repair-links=origin/dev` переписал ровно 53 ссылки в 46 файлах; остались
две прежние «...»-заглушки в CODE-REVIEW-448-r2 (битые и на dev). Тесты:
перенесённый документ, сосед со ссылкой в архив, ТЗ со ссылкой на позже
перенесённое ревью, битая-до-переноса не трогается, в `legacy/` битых нет;
мутант `reviews-archive-links-from-new-place-only`. PROCESS §2.10 и
DEVELOPMENT › Release называют переписывание и `--check-links`.

Issue: #682
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-27 22:10:47 +00:00

18 KiB
Raw Blame History

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)...) не выполняется.

Как воспроизведено (не только чтением):

  1. Прямой вызов скомпилированного 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() вызывается)
    
  2. Собственный 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.