docs: review document for #141

Issue: #141
User-Visible: no
This commit is contained in:
claude[bot]
2026-08-14 12:11:08 +00:00
parent 548677a99c
commit f11a4e1085
+233
View File
@@ -0,0 +1,233 @@
# 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`](../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`.