docs: review document for #361

Issue: #361
User-Visible: no
This commit is contained in:
claude[bot]
2026-08-29 07:55:23 +00:00
parent 953eda4f6e
commit e27396a5a8
+237
View File
@@ -0,0 +1,237 @@
# CODE-REVIEW-361-r1
Issue: [#361](https://github.com/Matysh/houseplan-card/issues/361) — «Мебель: физическая толщина линий не масштабируется при zoom»
Этап: code · заход r1 · блокирующих циклов израсходовано 0/4 до этого раунда
ТЗ: `docs/specs/361-furniture-stroke-zoom.md` (принято зелёным в SPEC-REVIEW-361-r2)
Ветка: `issue/361-furniture-stroke-zoom`, SHA после ребейза на dev: `0dd24b1e` (было `4b273e94`, поверх легло 1 dev-коммит; по §7.2 разбор полный)
Коммит реализации: `0dd24b1e` — `fix: scale furniture strokes with plan zoom` (trailers `Issue: #361`, `User-Visible: yes`)
## Скоуп проверки
Один продуктовый коммит `0dd24b1e`, диапазон `origin/dev...HEAD` (40 файлов, из них
продуктовый код — `src/furniture.ts` (+55/-4), `src/houseplan-card.ts` (+30/-6);
остальное — тестовые файлы, три копии бандла (`dist/`, `custom_components/houseplan/frontend/`,
`demo/srv/assets` пересобирается локально), документация, changelog и docs-скриншоты.
Зелёного Validate на `0dd24b1e` не найдено — все гейты ниже прогнаны мной локально.
Продуктовое рассуждение: задача точно закрывает Core user job «мебель ведёт себя как
физический элемент плана» (администратор дома размещает/просматривает мебель, физическая
толщина контура должна масштабироваться с планом, как остальной decor). Диагноз и контракт
не догадка — оба уже проверены по коду на этапе SPEC-REVIEW и подтверждаются здесь ещё раз
по фактическому диффу.
## Как проверялось
1. Прочитан весь diff `src/furniture.ts`/`src/houseplan-card.ts`, ТЗ
`docs/specs/361-furniture-stroke-zoom.md`, issue и все комментарии (включая оба раунда
SPEC-REVIEW).
2. Прочитан контекст вокруг изменений: `_stageEl`, `_baseVb`, `_viewOr`, `_floorView`,
существующий прецедент того же приёма (`perUnit = stage.clientWidth / view.w`,
houseplan-card.ts:10643) и CSS (`plan.styles.ts`: `.zoomwrap > svg { width:100%; height:100% }`,
`.stage { width:100% }`) — подтверждает, что `stage.clientWidth/clientHeight` корректно
прокси размеру фактического SVG-viewport, как и в остальном коде.
3. Прогнаны дешёвые гейты сам (Validate на этом SHA не найден):
- `npx tsc --noEmit` — **PASS**;
- `npm test` — **1521 pass, 1 skip, 0 fail** (полный набор, единичный запуск ~23s);
- `npm run build` + сверка трёх копий бандла (`git status --short` после `npm run build`
и отдельно после `npm run bundle:sync`) — **чисто, дифф пуст**, три копии синхронны;
- `node scripts/check-docs.mjs` (diff трогает `src/**`) — **PASS** (7 файлов, 10 внешних
ссылок; отпечаток скриншотов принят вместе с обновлёнными `docs/images/*.png` в этом
же коммите);
- `npm run bundle:budget` — **PASS** (272848 B / 282000 B, запас 9152 B);
- `node scripts/no-new-any.mjs --base origin/dev --head HEAD` — **PASS** (75 новых строк,
новых `any` нет).
4. Дисциплина «тест должен уметь падать» применена к обоим новым тестовым файлам и к
`demo/smoke_furniture.mjs`: временно откатил `src/furniture.ts` и `src/houseplan-card.ts`
к версии `origin/dev` (тестовые файлы оставил новыми), пересобрал бандл и прогнал:
- `node --test test/furniture-stroke-contract.test.mjs` → падает ровно на утверждении
про `stroke-width=${strokeWidth}` в preview (ожидаемо: pre-fix preview использует
`decorCmToUnits` напрямую, без `furnitureStrokePx`);
- `node demo/smoke_furniture.mjs` → падает на `furnitureFollowsPhysicalCameraZoom: false`
и `designerAndPrimitiveMatchOrdinaryDecor: false` — ровно тот дефект, который чинит
задача.
После проверки восстановил оба файла (`git diff HEAD` пуст) и пересобрал бандл заново —
рабочее дерево совпадает с `HEAD` побайтово.
5. Прогнан целевой браузерный смок из AC1/AC3/AC4/AC6 на факт-коде:
`node demo/smoke_furniture.mjs` → **OK**, все 90 полей `true`, включая растровые
assertions (`furnitureFollowsPhysicalCameraZoom`, `designerAndPrimitiveMatchOrdinaryDecor`,
`anisotropicResizeKeepsBothAxesEqual`, `rotatedArtworkKeepsTheSameThickness`,
`viewportResizeRecalculatesTheSharedPhysicalStroke`). Смок меряет реальные пиксели через
`getScreenCTM`+canvas, а не только DOM-атрибуты — доказательство не тавтологично.
6. `node scripts/smoke-select.mjs --base origin/dev --head HEAD` — вывод: 29 «прямых
совпадений» по символам `_baseVb`/`_viewOr`/`_stageEl` плюс 3 «зарегистрированные связи»
по `_stageEl`, и 1 «неопределённость» (`smoke_canvas_frame.mjs` ← `_baseVb`). Решение по
каждой строке — ниже, отдельным разделом.
7. Инварианты модели (`npm run invariants`) не прогонялись: diff не трогает рёбра комнат,
записи толщины стен, `layout`, `marker.space` или `open_spans` — только decor/furniture
render. Основание: `git diff --stat` не содержит файлов геометрической модели, а
изменённые функции (`furniturePlanScreenScale`, `furnitureStrokePx`) не пишут и не читают
ничего из `WallSegmentModel`.
8. `python -m pytest tests_backend` не прогонялся: diff не трогает `custom_components/**/*.py`
(только сгенерированные frontend-бандлы внутри `custom_components/houseplan/frontend/`).
9. Performance-профили не прогонялись: AC8 не называет конкретный профиль, а по коду видно,
что `furnitureScreenScale` вычисляется один раз на весь decor-слой
(`houseplan-card.ts:8096`), не на предмет — подтверждено также тестом
`furniture-stroke-contract.test.mjs` («the viewport scale is resolved once for the whole
decor layer»).
10. `npm run golden:verify` — diff меняет видимый render (AC1 в самой сути — экранная
толщина мебели), поэтому гейт обязателен. Полный прогон (182 сценария) не запускался
целиком по времени; вместо этого сделан целевой прогон `--mode=capture --scenario=<id>`
(единственный режим, допускающий фильтр — `verify` фильтр запрещает) по каждому
сценарию, где decor-слой рисует реальную мебель, и по контрольным сценариям без мебели.
Результат и его значение — в находке ниже.
## Находка (Medium, в скоупе)
**Golden-бейзлайны с реальной мебелью не обновлены под новый физический stroke; `npm run
golden:verify` в CI job `golden` (`.github/workflows/validate.yml:502`) упадёт на этом SHA.**
Четыре сценария `demo/golden/matrix.mjs` рисуют мебель (`kind: 'furniture'`) в основном
decor-слое, а не только в палитре: `furniture-plan-art-dark`, `furniture-placement-preview-light`,
`decor-over-opaque-hover-light`, `decor-over-glow-base-dark`. Задача правильно меняет
экранную толщину этой мебели (это и есть цель AC1/AC2), но ни один PNG в
`demo/golden/baselines/` не тронут этим коммитом (`git diff origin/dev...HEAD -- demo/golden/baselines`
— пусто), и `npm run golden:accept` в коммите не запускался.
**Воспроизведение** (после `npm run bundle:sync`, чтобы `demo/srv/assets` был собран из
факт-кода):
```
$ node demo/golden/run.mjs --mode=capture --scenario=furniture-plan-art-dark
different furniture-plan-art-dark
# report: differingPixels=2820, diffRatio≈0.0036 (порог 0.0005), maxObservedDelta=35
$ node demo/golden/run.mjs --mode=capture --scenario=furniture-placement-preview-light
different furniture-placement-preview-light
# differingPixels=995, diffRatio≈0.0014
$ node demo/golden/run.mjs --mode=capture --scenario=decor-over-opaque-hover-light
different
$ node demo/golden/run.mjs --mode=capture --scenario=decor-over-glow-base-dark
different
```
Контроль на исходном (`origin/dev`, без фикса) коде — тот же сценарий, тот же бейзлайн:
```
$ git checkout origin/dev -- src/furniture.ts src/houseplan-card.ts && npm run bundle:sync
$ node demo/golden/run.mjs --mode=capture --scenario=furniture-plan-art-dark
passed furniture-plan-art-dark
# actualSha256 == baselineSha256, differingPixels=0, diffRatio=0
```
т.е. на `dev` этот сценарий побитово совпадает с бейзлайном, а после фикса — нет. Диф не
шум окружения (тот же контроль на сценарии без мебели, `furniture-categories-light`,
даёт идентичный `actualSha256`/`diffRatio` что на факт-коде, что на `dev` — это
предсуществующий дрейф среды рендеринга, а не эффект задачи, и он не входит в находку).
**Почему это Medium, а не High:** сам код корректен — растровый смок и юнит-тесты доказывают
именно то поведение, которое требует ТЗ; расхождение с golden — ожидаемое следствие
исправленного дефекта, а не побочная порча. Обязательный ремонт («если меняется видимый
результат — принять новый бейзлайн через `npm run golden:accept -- --reviewed` из полного
Linux CI artifact») уже прямо прописан в разделе «Release-артефакты» самого ТЗ (§361) —
находка целиком в скоупе задачи и чинится без изменения кода: прогнать реальный CI
Validate на этой ветке, вооружить golden-artifact и принять новые бейзлайны для этих 4
сценариев (`--expect-change=furniture-plan-art-dark,furniture-placement-preview-light,decor-over-opaque-hover-light,decor-over-glow-base-dark`
или как назовёт их сам скрипт), затем закоммитить обновлённые PNG вместе с этим фиксом.
Без этого шага `dev` получит красный job `golden` сразу после мержа — тот же класс
проблемы, что #230/#234 оставили с job `docs`.
## Решение по smoke-select
- 29 «прямых совпадений» (`_baseVb`, `_viewOr`, `_stageEl`) — эти методы **не изменены**
диффом, только читаются в новой ветке рендера мебели (`_renderDecorLayer`,
`_renderFurniturePlacementPreview`). Ни pan/zoom, ни backdrop, ни kiosk-логика в них не
затронуты, поэтому прогон всех 29 избыточен для этой задачи — совпадение по имени
объясняется тем, что это стандартный способ узнать текущий camera viewport, используемый
по всему рендеру плана, а не признак задетой смежной функциональности.
- `demo/smoke_furniture.mjs` — прогнан (раздел «Как проверялось», п.5) как единственный
смок, названный в AC1/AC3/AC4/AC6, и как единственный, где действительно меняется
тестируемое поведение.
- `smoke_canvas_frame.mjs` (слабая связь, ← `_baseVb`) — просмотрен: сценарий про рамку
вида/aспект, мебели и decor-strokes не касается; не прогонялся.
- `smoke_edit_walk.mjs`, `smoke_editor_gestures.mjs`, `smoke_linked_virtual_light.mjs`
(← `_stageEl`) — просмотрены: жесты редактора и виртуальный свет, к furniture-stroke
отношения не имеют; не прогонялись.
## Проверено и корректно (по AC)
- **AC1 (физический zoom) и AC3 (rotate+zoom):** `furniturePlanScreenScale` — ровно формула
ТЗ, `min(viewportW/viewBoxW, viewportH/viewBoxH)` (учитывает letterboxing `xMidYMid meet`,
не только `_zoom`). Растровый смок подтверждает удвоение толщины при переходе zoom1→zoom2
для designer- и primitive-артворка, включая повёрнутый на 30°.
- **AC2 (анизотропный resize):** designerH/designerV сравниваются в одном фикстурном
предмете с намеренно вытянутым native artwork — толщина по обеим осям совпадает в пределах
2 CSS px. Юнит-тест `furniture-stroke-contract.test.mjs` формально гарантирует, что
`furnitureStrokePx` не видит `w`/`h`/`viewW`/`viewH` предмета вообще (принимает только
готовый `strokeUnits` + `planScreenScale`) — анизотропии неоткуда взяться.
- **AC4 (preview/commit parity):** `furnitureScreenScale` считается один раз в
`_renderDecorLayer` и передаётся как параметр в `_renderFurniturePlacementPreview` — один
источник числа на оба render-пути (проверено чтением: houseplan-card.ts:8095-8098,
8171); source-contract тест запрещает preview использовать `decorCmToUnits` напрямую в
обход `furnitureStrokePx`. Смок `previewAndCommitShareThePhysicalStroke: true`.
- **AC5 (совместимость):** полный `furniture.test.mjs` зелёный (включая перебор всех
56 symbols на «one finite path»); erase-hit и `data-hp`/`data-kind`/`data-symbol` не
затронуты диффом — второй тест в `furniture-stroke-contract.test.mjs` проверяет, что
`non-scaling-stroke` на erase-hit остался. Смок целиком (палитра, drag, resize, rotate,
wall magnet, erase) зелёный.
- **AC6 (layout safety):** `furniturePlanScreenScale`/`furnitureStrokePx` покрыты юнит-
тестами на нулевые/`NaN`/`Infinity` входы — всегда возвращают конечное число; смок
`viewportResizeRecalculatesTheSharedPhysicalStroke` проверяет пересчёт после ресайза
viewport.
- **AC7 (поддерживаемые поверхности), кроме golden — см. находку выше:** код рендера
общий для View/kiosk/Background editor (`_renderDecorLayer` не ветвится по `_mode`, кроме
`editing`/`erasing` UI-флагов, не влияющих на арифметику толщины); light/dark не входят в
расчёт (только `style.color`) — проверено чтением. Один нетипичный случай, разобранный
отдельно ниже.
- **AC8 (perf/bundle):** typecheck/test/build/bundle:sync/bundle:budget зелёные (раздел
«Как проверялось», п.3); `furnitureScreenScale` — один вызов на слой, не на предмет
(тест это же гарантирует мутационно).
- **AC9 (документация):** `docs/FURNITURE.md` больше не утверждает, что
`non-scaling-stroke` держит именно физическую толщину — переформулировано на «rejects only
that local width/depth distortion... renderer separately applies the outer plan viewBox
scale». Оба changelog обновлены в этом же коммите с ссылкой на #361 (проверено —
`User-Visible: yes` трейлер, дифф `docs/CHANGELOG.md`/`docs/CHANGELOG.ru.md` внутри
`origin/dev...HEAD` для единственного коммита). `docs/USER-GUIDE.ru.md` — добавлено ровно
одно предложение, терминология («физическая толщина», «приближении/отдалении плана»)
согласована с остальным разделом, не изобретена.
## Один разобранный нетиповой случай (не находка, риск явно вне калибровки по ТЗ)
В изометрическом режиме без структуры (`_baseVb()` → `!showBorders` ветка, «no-borders»
сцена) фактический `viewBox` слоя декора берётся из `floorView` (houseplan-card.ts:11087-11090:
`isoLayers?.structural ? view : floorView`), а `furnitureScreenScale` в `_renderDecorLayer`
считается от непроецированного `planView = this._viewOr(this._baseVb())`
(houseplan-card.ts:8095), то есть не от `floorView`. `_floorView` при `_renderProjection
!== 'iso'` возвращает `view` без изменений (проверено чтением, houseplan-card.ts:5660-5665),
поэтому расхождение существует только в этой конкретной iso-ветке. Оба входа
`furniturePlanScreenScale` там всё равно конечны и положительны (клиентские размеры DOM и
размеры сцены не бывают `NaN`/`Infinity`), так что итоговая толщина остаётся конечным
числом — `NaN`/пропажа мебели невозможны. Именно это и есть весь обязательный минимум для
iso по ТЗ («его текущая геометрия не должна падать или терять мебель, но отдельная
визуальная калибровка изометрического stroke не входит в #361»); численная точность
калибровки в этой ветке ТЗ прямо не гарантирует. Не заводится ни находкой, ни отдельным
issue — разобрано и оставлено как явно принятый остаточный риск.
## Чего не проверял
- Полный `npm run golden:verify` (182 сценария) целиком — прогнаны только сценарии с
мебелью в decor-слое плюс контрольные без неё (см. находку и её обоснование); остальные
180 сценариев не имеют мебели и не могут пострадать от этого diff — не тратил на них время.
- Полная browser-smoke матрица (200 файлов) — вне скоупа диффа; прогнан только целевой
`smoke_furniture.mjs` и разобраны все связи, названные `smoke-select.mjs` (раздел выше).
- `npm run invariants` — diff не геометрический (обоснование в п.7 «Как проверялось»).
- `python -m pytest tests_backend` — diff не трогает `custom_components/**/*.py` (п.8).
- Performance-профили/бенчмарки — не названы в AC, риск перфа закрыт чтением кода
(одноразовый расчёт на слой) и существующим `npm run bundle:budget`.
- Ручное тестирование в браузере человеком — не проводилось, весь цикл проверен автоматикой
и чтением кода, как предписано процессом для код-ревью.
## Вывод
Единственная находка — Medium, в скоупе задачи, не про корректность кода, а про
незавершённый релизный артефакт (golden baselines), явно предписанный собственным ТЗ этой
задачи. Верните на доработку: прогнать полный CI Validate на ветке, принять новые
golden-бейзлайны для 4 названных сценариев через `npm run golden:accept -- --reviewed`
из этого CI-прогона и закоммитить обновлённые PNG вместе с остальным изменением.