22 KiB
CODE-REVIEW-361-r1
Issue: #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 и подтверждаются здесь ещё раз по фактическому диффу.
Как проверялось
- Прочитан весь diff
src/furniture.ts/src/houseplan-card.ts, ТЗdocs/specs/361-furniture-stroke-zoom.md, issue и все комментарии (включая оба раунда SPEC-REVIEW). - Прочитан контекст вокруг изменений:
_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, как и в остальном коде. - Прогнаны дешёвые гейты сам (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нет).
- Дисциплина «тест должен уметь падать» применена к обоим новым тестовым файлам и к
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побайтово.
- Прогнан целевой браузерный смок из AC1/AC3/AC4/AC6 на факт-коде:
node demo/smoke_furniture.mjs→ OK, все 90 полейtrue, включая растровые assertions (furnitureFollowsPhysicalCameraZoom,designerAndPrimitiveMatchOrdinaryDecor,anisotropicResizeKeepsBothAxesEqual,rotatedArtworkKeepsTheSameThickness,viewportResizeRecalculatesTheSharedPhysicalStroke). Смок меряет реальные пиксели черезgetScreenCTM+canvas, а не только DOM-атрибуты — доказательство не тавтологично. node scripts/smoke-select.mjs --base origin/dev --head HEAD— вывод: 29 «прямых совпадений» по символам_baseVb/_viewOr/_stageElплюс 3 «зарегистрированные связи» по_stageEl, и 1 «неопределённость» (smoke_canvas_frame.mjs←_baseVb). Решение по каждой строке — ниже, отдельным разделом.- Инварианты модели (
npm run invariants) не прогонялись: diff не трогает рёбра комнат, записи толщины стен,layout,marker.spaceилиopen_spans— только decor/furniture render. Основание:git diff --statне содержит файлов геометрической модели, а изменённые функции (furniturePlanScreenScale,furnitureStrokePx) не пишут и не читают ничего изWallSegmentModel. python -m pytest tests_backendне прогонялся: diff не трогаетcustom_components/**/*.py(только сгенерированные frontend-бандлы внутриcustom_components/houseplan/frontend/).- Performance-профили не прогонялись: AC8 не называет конкретный профиль, а по коду видно,
что
furnitureScreenScaleвычисляется один раз на весь decor-слой (houseplan-card.ts:8096), не на предмет — подтверждено также тестомfurniture-stroke-contract.test.mjs(«the viewport scale is resolved once for the whole decor layer»). 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)(учитывает letterboxingxMidYMid 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/erasingUI-флагов, не влияющих на арифметику толщины); 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 вместе с остальным изменением.