Files
houseplan-card/docs/reviews/CODE-REVIEW-361-r1.md
T
2026-08-29 07:55:23 +00:00

22 KiB
Raw Blame History

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 и подтверждаются здесь ещё раз по фактическому диффу.

Как проверялось

  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 вместе с остальным изменением.