Files
houseplan-card/docs/reviews/CODE-REVIEW-445-r1.md
T
claude[bot] e30c73d5c7
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Successful in 37s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 27s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 47s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
docs: review document for #445
Issue: #445
User-Visible: no
2026-09-03 23:07:40 +00:00

112 lines
22 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-445-r1
- Issue: https://github.com/Matysh/houseplan-card/issues/445
- Этап: code (PROCESS.md §2.7), трек: full
- Заход: r1 · блокирующих циклов израсходовано 0 из 4
- Материал: ветка `issue/445-furniture-wall-face`, `HEAD` = `081cdbc6f3682c31e548b66c6cee4e231d24d2c0`
- `04e3f5ee437c512c562432619932690f1045ad25` — `fix: snap furniture to wall surfaces` (Issue: #445, User-Visible: yes)
- `081cdbc6f3682c31e548b66c6cee4e231d24d2c0` — `docs: accept screenshots for #445` (Issue: #445, User-Visible: no)
- merge-base с `origin/dev`: `560ca214b0d7d9018ff7916b25ebaea96a80e912` = `origin/dev` (ребейза не требовалось, история линейна)
- Предыдущий этап: spec принят зелёным на `SPEC-REVIEW-445-r2.md` (материал `a6d0c0b980bb…`, High:0/Medium:0). Это первый код-ревью раунда, поэтому разделы «Закрытие раунда r0» и «Унаследовано из r0» не применяются (§2.9 говорит о round-to-round внутри одного этапа; первый заход этапа `code` разбирается полностью).
## Скоуп
`git diff origin/dev...HEAD --stat`: 33 файла — новые `src/furniture-wall-surface.ts` (152 строки) и `src/furniture-placement.ts` (188 строк), урезанный `src/furniture.ts` (-151, старый магнит удалён), точечная интеграция в `src/houseplan-editor-runtime.ts` (+26/-24 в трёх местах: `_furnWalls`, `_resolveFurniturePlacement`, `_furnMoveUpdate`), расширенный `test/furniture.test.mjs` (+новые unit), расширенный `demo/smoke_furniture.mjs`, пять новых мутантов в `scripts/mutation-gate.mjs`, `docs/FURNITURE.md` + оба changelog, три бандл-копии (class D) и `docs/images/screenshots.json` (провенанс скриншотов). `src/space-geometry.ts` и `src/wall-thickness.ts` не тронуты — новый модуль только читает уже существующие `roomWallProfile`/`inwardNormal`, ничего в них не меняя.
Разбирался диапазон `git log --oneline origin/dev..HEAD` (2 коммита) и `git diff origin/dev...HEAD` целиком против ТЗ `docs/specs/445-furniture-wall-face-snap.md` (принятая версия, зелёный SPEC-REVIEW-445-r2) и AC1…AC10.
## Как проверялось
1. Прочитано тело issue #445 и все комментарии (аналитика → Q1/Q2 → решение владельца → ТЗ → SPEC-REVIEW-445-r1 (жёлтый) → SPEC-REVIEW-445-r2 (зелёный, AC10 закрыт)) и полный текст принятого ТЗ.
2. Построчно сверены `src/furniture-wall-surface.ts`, `src/furniture-placement.ts`, диффы `src/furniture.ts` и `src/houseplan-editor-runtime.ts` против контракта («Физический кандидат стены», «Выбор стороны» пп.1–6, «Положение предмета», «Радиус магнита», «Preview, commit и move», «Кэш и производительность»).
3. Ручным прогоном арифметики проверены пограничные тесты `test/furniture.test.mjs` (толстая внешняя стена, общая стена с точным попаданием на ось, локальная атомарная толщина, независимые тела, corner tie-break AC10) — числа сошлись с кодом `snapFurnitureToWall`/`roomFurnitureWallSurfaces`.
4. Гейты прогнаны сам (зелёного Validate на этом SHA нет):
- `npx tsc --noEmit` — чисто;
- `npm test` — 1884 теста, 1883 pass, 1 skip, 0 fail (`npm run inventory`: Node unit 1884 — не считал вручную);
- `npm run build` — успешно;
- `npm run bundle:sync` — no-op diff (три копии бандла уже синхронны в дереве коммита);
- `npm run bundle:budget` — initial View 292747 B (было 293163 B на `origin/dev` — **уменьшилось**, новые модули ушли в `lazy editor` +1281 B gzip); есть неснятое предупреждение о запасе бюджета — это долг #367, не создан этой задачей;
- `node scripts/check-docs.mjs` (diff трогает `src/**`) — «Documentation checks passed (7 files, 12 external links)».
5. `npm run invariants` не запускал: диф не пишет и не переносит геометрию стен, wall thickness records, `layout`, `marker.space` или `open_spans` — модуль `furniture-wall-surface.ts` только читает уже существующий `roomWallProfile`/`_rawPhysicalBodiesR()`, ничего не создаёт и не сериализует. Не тот тип изменения, для которого инварианты отвечают на свои три вопроса.
6. `node scripts/smoke-select.mjs --base origin/dev --head HEAD`: 43 «широких» символа на изменённых строках → «Прямое совпадение» (21 смок) и «Зарегистрированная связь» (2 смока), список и решение — в разделе «Гейты» ниже.
7. Прогнал `node demo/smoke_furniture.mjs` (назван в AC5/AC7 явно) на production-бандле — 100% ассершенов зелёные, включая все новые (`wallSurfaceCandidatesAreCached`, `thickWallPreviewAndCommitAreIdentical`, `sharedWallUsesRawPointerSide`, `exactAxisDragPreservesWallSide`).
8. Дисциплина «тест должен уметь падать» — проверена не декларативно: временно применил мутацию `furniture-wall-runtime-drops-raw-intent` (замена `intentPoint: [raw[0], raw[1]]` на `[snapped[0], snapped[1]]` в `houseplan-editor-runtime.ts`), пересобрал бандл и перезапустил `demo/smoke_furniture.mjs` — упали ровно `sharedWallUsesRawPointerSide` и `exactAxisDragPreservesWallSide`, остальное осталось зелёным. Откатил правку (`git status` после отката — дерево чистое), бандл пересобран на исходном коде.
9. Проверено чтением (не исполнением): `resolveFurniturePlacement`/`snapFurnitureToWall`/`furnitureWallSurfacesFor` вызываются только из `_resolveFurniturePlacement` (preview+place) и `_furnMoveUpdate` (drag) — ни одного вызова из пути рендера/загрузки уже сохранённой мебели, поэтому «уже сохранённая мебель не переписывается при load/render» (AC7, «Данные и совместимость») верно структурно.
10. Проверено чтением: `_cfgEpoch` — уже установленный в проекте сигнал инвалидации кэшей геометрии (используется `_decorSnapCache`, `_wallUnion` и другими кэшами тем же паттерном); `sourceCache` в `furniture-wall-surface.ts` следует тому же идиоматическому контракту, ключ также учитывает `space.id`, так что переключение этажа без смены `_cfgEpoch` тоже инвалидирует кэш.
11. Отдельным подпотоком проверено происхождение golden-сценария `furniture-placement-preview-light` (`demo/fixtures/visual-matrix.mjs`, `golden-geometry`, pointer `[0.35, 0.90]`): ближайшая стена там имеет `cm: 10` (не нулевая) — существующий canonical fixture действительно демонстрирует ненулевую толщину, как того требует AC8, и новый сценарий не понадобился.
## Находки
Находок уровня High или Medium в скоупе задачи не обнаружено.
Ниже — то, что специально проверялось как вероятные точки риска и не подтвердилось дефектом (не находки, а протокол проверки):
- **Побочный эффект на golden-снимок.** `furniture-placement-preview-light` идёт через реальный `_resolveFurniturePlacement` с геометрией `golden-geometry` (там есть стена `cm: 10` рядом с точкой предпросмотра), поэтому пиксели этого снимка почти наверняка изменятся по сравнению с текущим baseline (BACK теперь ложится на поверхность, а не на ось). `demo/golden/baselines/**` в этом коммите не менялся — и не должен: класс D, а приёмка golden — только через Linux CI перед бетой (AC8, `AGENTS.md` «Гейты»). Это ожидаемое поведение процесса, а не пропуск: называю явно, чтобы задача, готовящая следующую бету, не удивилась дифу.
- **Смещение области действия магнита у внешней стены снаружи дома.** У толстой внешней стены расстояние теперь меряется от внутренней поверхности, поэтому пользователь, наводящий курсор снаружи дома (за пределами наружного фасада), должен подойти ближе, чем раньше, чтобы попасть в шесть клеток. Это прямое и осознанное следствие принятого владельцем Q2-default («радиус от физической поверхности»), явно описанное в контракте и AC4 — не дефект.
## Что проверено и корректно
- **AC1** (толстая внешняя стена → внутренняя поверхность): `roomFurnitureWallSurfaces` строит один candidate на внешнее ребро комнаты со смещением `axis + inwardNormal*half`; тесты «the magnet presses…», «an outer wall always keeps…» проходят и арифметически совпадают с ожиданием ТЗ (BACK на `TOP_20`, центр `TOP_20+45`).
- **AC2** (общая стена, намеренная сторона): distance-to-surface естественно разносит два кандидата общей стены по сторонам (тест «a shared thick wall selects the intent side…»); точное равенство на оси разрешается `preferredNormal` (drag) либо стабильным `stableId` (новое размещение) — тест «new exact-axis placement is stable across room order, winding and surface order» проходит при перестановке комнат/winding/порядка массива.
- **AC3** (локальная атомарная толщина): `roomWallProfile`/`atomicPolyForRoom` уже делят периметр на атомы с собственным `offset`; тест «local atomic wall thickness owns the surface under the projection» проверяет разные offset на соседних участках 10/20 см без утечки в соседний атом.
- **AC4** (радиус от поверхности): distance считается до уже смещённого `surface.a/b`, а не до оси; тест «out of reach there is no magnet at all» проверяет порог по обе стороны от `TOP_20 ± 30`.
- **AC5** (единый resolver для preview/commit/move): и `_furniturePreviewPlacement`, и `_furnPlace` вызывают один и тот же `_resolveFurniturePlacement(raw, free, pointerType)` с одним и тем же `raw`; unit «preview and commit share one deterministic furniture placement resolver» и browser-смок (`thickWallPreviewAndCommitAreIdentical`) подтверждают побайтовое совпадение. Перемещение (`_furnMoveUpdate`) использует тот же `snapFurnitureToWall`, а не отдельную центролинейную ветку — код прочитан построчно, дублирования пути нет.
- **AC6** (нулевые стены/независимые тела): «zero walls keep the old centreline geometry» и «independent physical-body faces are not offset twice» проходят; malformed-фикстура подтверждает finite-фолбэк без падения остальных кандидатов.
- **AC7** (touch safety и данные): существующий набор touch-ассершенов смока (`touchCancelMoveAndSecondContactDoNotSave` и др.) зелёный на новом коде; вызовы resolver’а не достижимы из путей рендера/загрузки (см. «Как проверялось», п.9) — уже сохранённая мебель не переписывается.
- **AC8** (визуальная защита): единственный canonical furniture-placement фикстур (`furniture-placement-preview-light`, только light-тема — ровно как допускает ТЗ «одна тема — пиксельный witness, другая — geometry assertions») уже стоит на стене `cm:10`; geometry-assertions на стороне browser-смока покрывают вторую тему функционально. Приёмка изображения — по процессу, перед бетой.
- **AC9** (кэш/производительность): `furnitureWallSurfacesFor` кэширует список по `WeakMap` с ключом `space.id|cfgEpoch|cellCm|gridPitch|wallKeyPitch`; unit «runtime wall surfaces are built once per geometry epoch» доказывает ровно один вызов `_openCuts()` на эпоху и инвалидацию при её смене. Фактические размеры бандла (`dist/houseplan-assets.json`, `git diff`) подтверждают: initial View не вырос (293163→292747 B), новый код целиком в `lazy editor` (166061→167342 B) — new import не попал в initial-view граф.
- **AC10** (угловой tie-break, добавлен в SPEC-REVIEW-445-r2): unit «corner selection is nearest-first, intent-aware and invariant to input order» — вручную пересчитан для всех трёх кейсов (ближе/на грани по intent-стороне/полное равенство) и сходится с реализацией `sideScore`/`stableId.localeCompare`.
- Оба changelog обновлены в том же коммите, что и код (`04e3f5ee`, `User-Visible: yes`); `docs/FURNITURE.md` переписан под физический контракт без противоречий коду.
- Трейлеры `Issue:`/`User-Visible:` верны на обоих коммитах; класс файлов (A/B/C/D) не нарушен, никакой продуктовый код не тронут за пределами заявленной карты реализации.
- Persisted schema и backend не тронуты (`git diff` по `src/types.ts` и `custom_components/**/*.py` пуст) — соответствует «Данные и совместимость» ТЗ.
## Гейты — что прогнал, что нет и почему
Прогнал сам (зелёного Validate на этом SHA нет):
| Гейт | Результат |
|---|---|
| `npx tsc --noEmit` | чисто |
| `npm test` | 1883 pass / 1 skip / 0 fail из 1884 |
| `npm run build` | успешно |
| `npm run bundle:sync` | no-op, три копии синхронны |
| `npm run bundle:budget` | initial View 292747 B, в пределах потолка (существующий низкий запас — долг #367, не введён этой задачей) |
| `node scripts/check-docs.mjs` | passed (diff трогает `src/**`) |
| `node demo/smoke_furniture.mjs` (назван в AC5/AC7) | все ассершены зелёные; вручную подтверждено, что 2 из них краснеют под мутацией и снова зеленеют после отката |
| `node scripts/mutation-gate.mjs --list` (только парсинг/регистрация пяти новых мутантов #445) | зарегистрированы корректно, guard-команды валидны |
Не прогонял и почему:
- **`npm run invariants`** — диф не пишет геометрию/толщину/`open_spans`/`layout`, только читает существующие `roomWallProfile`/`_rawPhysicalBodiesR()`; вопросы, на которые отвечают инварианты, к этому дифу неприменимы.
- **19 из 21 «прямое совпадение» смоков** (`smoke_junction_holes`, `smoke_junction_patch_resilience`, `smoke_active_chain_ink`, `smoke_backdrop_guard`, `smoke_danger_confirmation`, `smoke_decor`, `smoke_drag_bounds`, `smoke_glow`, `smoke_grid_scale_invariance`, `smoke_grid_snap`, `smoke_help_affordance`, `smoke_infinite_canvas`, `smoke_junction_limits`, `smoke_multiwall_junction`, `smoke_opening_measure`, `smoke_optional_space_model`, `smoke_space_scale_defaults`, `smoke_wall_junctions`, `smoke_wall_key_roundtrip`, `smoke_wallthick_standalone`) — инструмент совпал по распространённым символам (`NORM_W`, `GRID_PITCH`, `cellCm`, `_spaceWalls`, `_openCuts`, `_rawPhysicalBodiesR`), которые новый код только читает как вход; их определения и поведение не менялись (`src/space-geometry.ts`, `src/wall-thickness.ts` — 0 изменений в дифе). Это ровно тот случай «слабой связи по распространённому имени», который процесс называет поводом посмотреть, а не обязанностью прогонять.
- **2 «зарегистрированная связь»** (`smoke_resize_pointer_real_plan.mjs`, `smoke_resize_wall_thickness.mjs`, оба по типу `WallEntry`) — `WallEntry` использован только как тип импорта без изменений в определении или семантике; resize-путь этой задачей не затронут.
- **`npm run golden:verify` / `golden:capture`** — canonical furniture fixture (см. «Что проверено», AC8) реально изменит пиксели, но по ТЗ и процессу принятие golden — задача перед бетой на точном Linux CI SHA, не этого раунда.
- **`python -m pytest tests_backend`** — ни один файл `custom_components/**/*.py` не менялся.
- **performance_smoke / перф-профили** — не названы в AC, чувствительные к перфу пути (рендер стен/junction) не тронуты; единственное перф-требование (AC9, кэш на эпоху) доказано unit-тестом и фактическими размерами бандла.
## Одно число — один источник
Диф вводит одну пользовательски видимую величину дважды по своей природе: положение мебели показывается один раз в hover-превью и один раз в сохранённой записи. Источник один — оба вызывают `resolveFurniturePlacement`/`_resolveFurniturePlacement` с одним и тем же `raw`; unit-тест и browser-смок (`thickWallPreviewAndCommitAreIdentical`) утверждают побайтовое равенство. Второй потенциальный дубль — угол между превью и drag — тоже проведён через один и тот же `snapFurnitureToWall`. Отдельного числового поля (подписи, лейбла) с независимым источником в этом дифе не добавлено.
## Вердикт
Все девять AC исходного ТЗ и AC10 из SPEC-REVIEW-445-r2 доказаны конкретными тестами, которые я перепроверил на способность падать (мутация + мутационные тесты в `scripts/mutation-gate.mjs`). Гейты, которые применимы к дифу, зелёные. Golden-приёмка сознательно отложена на пре-бета-гейт по процессу, а не пропущена.
Вердикт: зелёный · заход r1 · блокирующих циклов 0/4 · High: 0 · Medium: 0
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/445-furniture-wall-face`, коммит `081cdbc6f368` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `5f7ab4e16fe5d56b87d423ac19c563f6c7a579d3`
```
git log --all --format='%H %T' | grep 5f7ab4e16fe5
```
- ТЗ `docs/specs/445-furniture-wall-face-snap.md`, блоб `95d03a5425fc37f95095c2f53a49363e668edb40`
```
git log --all --find-object=95d03a5425fc37f95095c2f53a49363e668edb40 -- docs/specs/445-furniture-wall-face-snap.md
```