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

234 lines
18 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 — issue #141, cycle r1
Вердикт: **красный** · цикл r1/4 · High: 1 · Medium: 0
Ветка: `issue/141-wall-joints` · implementation-коммит
[`3e33f4a`](https://github.com/Matysh/houseplan-card/commit/3e33f4a5845a29694473697bea916bb3e2490ac2)
· ТЗ: [`docs/specs/141-wall-junctions.md`](../../../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`:
```ts
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`:
```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`.