mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Ревью #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
234 lines
18 KiB
Markdown
234 lines
18 KiB
Markdown
# 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`.
|