Files
houseplan-card/docs/reviews/CODE-REVIEW-582-r1.md
T
2026-09-15 12:17:27 +03:00

27 KiB
Raw Blame History

CODE-REVIEW-582-r1

Issue: #582 · этап: code · заход r1 · блокирующих циклов израсходовано 0 из 4 · материал: 0a6592f4112fd7ceef68cbd3be7c44eeb8a7ef3e (origin/dev = bae6afaae7bac2dd45bca52b6caf64a16d96f182, origin/dev..HEAD = b9bf3daa (документ ревью ТЗ) + 0a6592f4 (сама правка)).

Скоуп

ТЗ (в теле issue) чинит воспроизводимый в HA Companion дефект: во время pinch-zoom пространства с фоном «Следует за Солнцем» появляются фиксированные белые/прозрачные области, вызванные тем, что постоянный will-change: filter из #532 висит на внутренней группе .hp-paperg, чей локальный SVG-bbox при cell_cm: 1 может достигать тысяч единиц — Chromium промотирует слой пропорционально этому координатному размеру, а не экранному. Контракт (п.1–7) требует: сохранить визуальный контур и его перфоманс-выигрыш из #532, но привязать промотированный слой к экранному viewport независимо от cell_cm, и сделать то же самое для houseplan-space-card. AC1–AC9 в теле issue.

Это основная View-поверхность (J1) и touch-контракт View/kiosk (docs/TOUCH-SUPPORT.md, release-blocking), так что вопрос "работает ли оно на самом деле" здесь не формальность.

Материал и что сделано в коде

  • Новый общий рендерер src/render/paper-scene.ts (renderPaperShapes) — заменяет три дублированные ветки path/poly/rect в houseplan-card.ts и space-render.ts.
  • В houseplan-card.ts и space-render.ts добавлен ОТДЕЛЬНЫЙ <svg class="hp-paper-outline-svg"> сосед перед основным plan-svg/hp-static-plan-svg, содержащий копию бумажных фигур; он использует тот же viewBox/data-hp-live-viewbox, что и основной план, и тот же isoFloorMatrixCss() в изометрии.
  • В plan.styles.ts тройной drop-shadow и will-change: filter перенесены с .hp-paperg на .hp-paper-outline-svg (стейдж-размерный корневой <svg>, растянутый правилом .zoomwrap > svg { width:100%; height:100% }), тем самым слой теперь действительно ограничен экранными пикселями, а не локальными SVG-единицами.
  • space-card.ts получил такое же правило для .hp-static-stage .hp-paper-outline-svg.
  • Новый браузерный смок demo/smoke_daycycle_layer_budget.mjs (CDP LayerTree + screencast) — свидетель AC1/AC3. Новый мутант daycycle-outline-promoted-on-inner-paper (guard — этот смок) — свидетель AC2. smoke_daycycle_raster.mjs/smoke_bg_color.mjs обновлены под новые имена классов — свидетели AC4/AC5. Новый test/paper-scene-contract.test.mjs — свидетель AC7. scripts/smoke-links.mjs регистрирует связь renderPaperShapes → 4 смока. scripts/bundle-budget.mjs подвинул потолок на 200 Б с объяснением. Оба changelog и docs/ARCHITECTURE.md/docs/SUN.md/docs/TOUCH-SUPPORT.md/docs/TESTING.md обновлены согласованно с кодом. Трейлеры коммита Issue: #582 / User-Visible: yes на месте.

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

Дешёвые гейты подтверждены зелёным Validate на точном SHA (https://github.com/Matysh/houseplan-card/actions/runs/34940487324, workflow_dispatch, без heavy): предполёт, классификация, «Фронтенд: типы/юниты/бандл», 6 шардов «Мутанты по диффу» — всё success. Тяжёлые джобы (golden, smoke, performance_smoke, hacs, hassfest, geometry_parity, backend) в этом прогоне skipped — не PR, не Release:, не full=true, поэтому они не гейтили этот пуш. Именно эти пропущенные джобы оказались важны (см. находки).

Я лично прогнал (то, что Validate не покрыл, и что явно относится к диффу):

Гейт Результат Почему прогнан/не прогнан
npm run bundle:sync (typecheck+build+rollup+bundle-tree) зелёный, git status после — чисто дёшево, диф трогает src/**
node demo/smoke_daycycle_layer_budget.mjs (AC1/AC3) зелёный, 4 прогона подряд прямое совпадение с диффом
node demo/smoke_bg_color.mjs зелёный (все 47 проверок, включая изменённую staticCardLayersStayOrdered) зарегистрированная связь, CI её не гонял (heavy=false)
node demo/smoke_live_pan_coverage.mjs зелёный зарегистрированная связь, CI её не гонял
node demo/smoke_daycycle_raster.mjs (AC4, свидетель #532) красный, воспроизводимо 8/8 (ratio 2.16–2.50 против потолка 2.0); на origin/dev тот же смок даёт 1.01–1.14 в 7/7 запусках в ЭТОМ ЖЕ окружении прямое совпадение с диффом; CI отчитался зелёным на этом SHA, но локальный результат детерминирован и объяснён (см. Находка №1)
npm run golden:verify (AC6, полная матрица) 4 из 4 day-cycle сцен = different, остальные ~150 сцен passed diff меняет рендер; CI golden был skipped на этом пуше, так что это первый реальный прогон на данном SHA
npm run inventory не гонял не требуется для этого раунда
python -m pytest tests_backend -q, HACS, Hassfest, geometry parity не гонял диф не трогает custom_components/**/*.py, манифесты или геометрию решётки/толщины стен
performance_smoke (полный) не гонял AC4 уже красный на более дешёвом целевом свидетеле; полный перф-профиль не добавит новой информации до фикса

Плюс диагностика причины (CDP LayerTree + compositingReasons, одна и та же гарнитура demo/serve.mjs, один и тот же демо-фикстур 1400×900, сравнение origin/dev vs HEAD бок о бок в одном окружении) — вынесена в Находку №1.

Находки

Находка №1 (High) — AC4 не держится: день-цикл при pan рерастеризует весь план, не только контур

Файлы: src/houseplan-card.ts:11597-11603, src/styles/plan.styles.ts:97-105, src/space-render.ts (тот же паттерн для static card).

Механизм. Раньше will-change: filter стоял на .hp-paperg — <g> ВНУТРИ plan-svg, с малым собственным bbox. plan-svg целиком в свой отдельный compositor-слой не попадал (в baseline origin/dev слой plan-svg в CDP LayerTree вообще не значится: контент рисуется в общей поверхности). Патч кладёт контур в ОТДЕЛЬНЫЙ <svg class="hp-paper-outline-svg">-СОСЕД перед plan-svg в том же .zoomwrap. Chromium корректно ограничивает промотированный слой контура экранными пикселями (это и было целью, AC1/AC2/AC3 — подтверждены), но побочный эффект: теперь plan-svg (ВЕСЬ видимый план — стены, комнаты, устройства, декор) визуально перекрывает промотированный слой-соседа и по правилам Chromium сам получает отдельный compositor-слой с причиной "Overlaps other composited content" (проверено CDP LayerTree.compositingReasons на обоих деревьях, один и тот же демо-стенд, 1400×900):

origin/dev (до патча):     hp-paperg 920×720 (will-change: filter); НЕТ отдельного слоя plan-svg
0a6592f4 (после патча):    hp-paper-outline-svg 780×724 (will-change: filter);
                           plan-svg 780×724 ("Overlaps other composited content")  ← новый слой

Это ПОЛНЫЙ план как отдельная растеризуемая поверхность, которая теперь должна перерисовываться при любом визуальном изменении плана во время жеста — то есть именно тот класс стоимости, который #532 устранял для внешнего контура, теперь возвращается для всего плана.

Численное подтверждение (изолированный прогон, 20 шагов панорамы мышью, тот же демо-стенд, RasterTask по CDP Tracing):

origin/dev:  31.8 мс,  106 RasterTask
0a6592f4:    74.7 мс,  282 RasterTask   (≈2.35× по времени, ≈2.7× по числу тасков)

Свидетель AC4 (demo/smoke_daycycle_raster.mjs), 60-шаговая панорама, то же окружение, 8 прогонов на HEAD против 7 на origin/dev:

Дерево ratio (потолок 2.0)
origin/dev 1.09, 1.14, 1.03, 1.03, 1.04, 1.01 (все ≤ 1.14)
0a6592f4 2.19, 2.16, 2.23, 2.18, 2.50, 2.33 (все ≥ 2.16)

Разброс между двумя деревьями непересекается ни разу за 13 запусков — это не шум конкретной машины, а систематическая разница, воспроизводимая в контролируемом A/B внутри ОДНОГО окружения (обе половины смока меряются в одном процессе на одной странице, как и задумано автором смока).

Почему CI на этом SHA отчитался зелёным, а не потому что находки нет. Job «Мутанты по диффу» реально исполняет node demo/smoke_daycycle_raster.mjs (лог ok чистый прогон: node demo/smoke_daycycle_raster.mjs, 07:14:24) и код проверки (serve.mjs: finish()) действительно ставит process.exitCode=1 при ratio > 2.0 — то есть CI-раннер в моменте объективно получил ratio ≤ 2.0. Проблема в марже: порог 2.0 был откалиброван «примерно посередине по логарифму» между 7.9 (без подсказки вообще) и 0.26 (подсказка на тесной .hp-paperg, комментарий в самом смоке, строка 15). Новая реализация переехала с 0.26 на ~2.2–2.5 — то есть съела почти весь запас, оставленный именно на случай машинной вариативности, и на GitHub-раннере это на сегодня чуть-чуть укладывается, а на менее мощном/более шумном исполнителе — уже нет. Целевая платформа этой самой задачи — Android HA Companion WebView — как правило СЛАБЕЕ настольного CI-раннера, а не сильнее; то есть именно то устройство, ради которого чинится #582, статистически более рискованно по AC4, чем моя тестовая машина.

Последствие. AC4 сформулирован как "свидетель #532 остаётся зелёным" — де-факто он не остаётся зелёным как класс поведения (плана целиком, а не только контура), просто конкретный числовой порог на конкретном раннере пока не перешагнут. Это разваливает и собственное объяснение ТЗ в "Технические ограничения": "Фильтр/промоушен должен применяться на уровне экранной scene surface, а не координатно большой внутренней группы" — технически верно для самого контура, но не учитывает, что помещение этой scene surface СОСЕДОМ (а не потомком) плана заставляет браузер промотировать и сам план.

Находка №2 (High) — AC6 не проходит: все 4 day-cycle golden-сцены отличаются от эталона на каноничной платформе

Файлы: demo/golden/baselines/day-cycle-{dawn,day,dusk,night}-dark.png (не обновлены в этом диффе — git diff origin/dev...HEAD --stat -- demo/golden/ пуст).

npm run golden:verify (Linux, тот же движок, что CI/golden — каноничная платформа по AGENTS.md) на точном SHA даёт:

Сцена статус differing px diffRatio maxObservedDelta порог diffRatio
day-cycle-dawn-dark different 26 903 0.0349 81 0.0005
day-cycle-day-dark different 26 160 0.0339 81 0.0005
day-cycle-dusk-dark different 25 667 0.0333 81 0.0005
day-cycle-night-dark different 26 142 0.0339 94 0.0005

Это ~70× превышение допустимой доли различающихся пикселей и разница каналов до 94/255 при допуске 10/255 — не антиалиасинг-дребезг на границе допуска. Визуально (сравнение demo/golden/baselines/day-cycle-day-dark.png и artifacts/golden/actual/day-cycle-day-dark.png на глаз почти неотличимы; diff-маска в artifacts/golden/diff/day-cycle-day-dark.png подсвечивает штриховку буквально ВСЕХ стен по всему плану, не только внешний контур) похоже на тот же механизм, что и Находка №1: как только plan-svg стал отдельным композитным слоем, финальная сборка кадра проходит через дополнительный проход растеризации/блендинга этого слоя, слегка меняя суб-пиксельное позиционирование штриховки стен по всему плану — отсюда широкий, но мелкий по амплитуде на глаз разброс.

Оставшиеся ~150 сцен матрицы прошли (passed), включая не-day-cycle сцены — регрессия локализована именно в day-cycle рендере, как и предсказывает механизм Находки №1.

AC6 прямо требует: "четыре существующие day-cycle сцены... проходят... Любое необходимое обновление baseline допускается только после объяснённого визуального сравнения". Baseline не обновлён, объяснения в issue/коммите нет — сам факт диффа не скрывается (никто не подделывал числа), но и не заявлен: ни в диффе, ни в комментариях issue нет упоминания, что golden:verify был прогнан и дал результат. Раз diff явно трогает рендер (src/**: houseplan-card.ts, space-render.ts, paper-scene.ts, plan.styles.ts), golden:verify был обязателен перед S7-code-review (AGENTS.md, "проверь по diff и AC" + сам этот процесс-документ).

Обе находки объединяет один и тот же корень: помещение дневного контура СОСЕДНИМ <svg> перед plan-svg вместо ВНУТРИ него как раньше — технически верно решает координатно-огромный слой (AC1–AC3 в порядке), но по пути превращает весь план в отдельный композитный слой, что стоит и лишней растеризации (Находка №1), и лишнего прохода блендинга, видимого в пикселях (Находка №2). Возможные направления для автора (не предписание, только ориентир): не выносить контур отдельным сиблингом, а найти способ ограничить экранными размерами именно промотированный слой ВНУТРИ существующего дерева plan-svg (например, через clipPath/contain, привязанный к экранному viewBox, а не к локальному bbox фигуры), либо явно и всегда держать plan-svg на собственном стабильном слое ДО появления контура, чтобы контур не мог его "продавить" через overlap-эвристику при первом появлении.

Находка №3 (Low, не блокирует, оставлена без правки)

src/houseplan-card.ts:11623-11624 — комментарий "One <g> around ALL paper shapes: the external day-cycle outline (styles.ts) is composited once for the whole sheet" относится к .hp-paperg, но сам фильтр туда больше не прикреплён (перенесён на .hp-paper-outline-svg, отдельный узел). Смысл абзаца (одна группа = отсутствие швов между комнатами) остаётся верным, но фраза про "the external day-cycle outline... is composited" на этом самом <g> — уже не так. Мелкая правка комментария, не код.

Что проверено и корректно

  • AC1/AC2/AC3 — подтверждены и CI-логом мутанта (daycycle-outline-promoted-on-inner-paper, чистый прогон ok + красный на мутанте ok, 07:14:05–07:14:50 на точном SHA), и моим локальным повторным запуском smoke_daycycle_layer_budget.mjs (4/4 зелёных): контур теперь 826×736 (экранный порядок) вместо потенциальных 4700×4200; ни один content-слой

    4096; 9/9 отснятых кадров pinch без белых пикселей.

  • Единый живой viewport (#579, контракт п.3–4). data-hp-live-viewbox стоит на .hp-paper-outline-svg тем же значением (camera/floor), что на plan-svg; live-viewport.ts:paintLiveViewport обновляет оба через один и тот же querySelectorAll и одну и ту же проекцию — контур и план не могут разойтись по кадру или масштабу отдельно от терминального примирения. Проверено чтением, не исполнением отдельно от смоков выше (которые это же утверждение покрывают AC3).
  • Основная бумага не фильтруется (контракт «Технические ограничения»). renderPaperShapes(paperShapes) без groupClass даёт .hp-paperg без фильтра; test/paper-scene-contract.test.mjs проверяет отсутствие will-change: filter на .hp-paperg; смок smoke_daycycle_raster.mjs подтверждает dayCyclePaperStaysUnfiltered: true в моём прогоне.
  • Изометрия и static card не потеряли зеркальность. <g transform=${iso ? isoFloorMatrixCss() : nothing}> оборачивает renderPaperShapes в контурном SVG тем же способом, что в основном; hp-static-stage получил аналогичное правило z-index/ overflow/pointer-events; smoke_bg_color.mjs (staticCardLayersStayOrdered) зелёный.
  • plan-svg z-index стал безусловным (было class=${iso ? 'plan-svg' : nothing}, стало class="plan-svg") — раньше во flat-режиме класс не вешался вовсе и элемент зависел от порядка DOM для стэкинга; теперь это явное z-index: 1 против 0 у контура. Не регрессия: до этого диффа конкурирующего элемента с явным z-index не было, после — есть (сам контур), и явный класс корректно фиксирует стэкинг. test/isometric-contract.test.mjs обновлён синхронно.
  • Changelog/трейлеры/документация. Issue: #582 + User-Visible: yes на терминальном коммите; оба docs/CHANGELOG*.md правлены в нём же; docs/ARCHITECTURE.md, docs/SUN.md, docs/TOUCH-SUPPORT.md, docs/TESTING.md описывают именно то, что делает код (сверено построчно с диффом кода). scripts/bundle-budget.mjs — потолок подвинут на 200 Б с содержательным объяснением, общий бюджет не тронут.
  • Мутационная регистрация. daycycle-outline-promoted-on-inner-paper привязан к правильному guard-файлу и реально ловит возврат старого will-change (подтверждено и в CI, и локально).

Чего не проверял и почему

  • python -m pytest tests_backend, HACS, Hassfest, geometry-parity — diff не трогает custom_components/**/*.py, манифесты, layout/marker.space/толщину стен; эти гейты были в Validate skipped по классификации файлов, а не по heavy-флагу, что и ожидаемо для чисто фронтенд-диффа.
  • Полный performance_smoke/профили вне smoke_daycycle_raster.mjs — AC4 уже провалена на целевом, более дёшевом свидетеле; гонять более тяжёлый профиль до фикса не добавляет решающей информации.
  • npm run inventory — не требуется для вынесения вердикта этого раунда.
  • Полная browser-smoke матрица (250 файлов) — вне AC и вне выборки smoke-select.mjs (4 зарегистрированные связи, все 4 прогнаны явно, см. таблицу выше).
  • AC9 (полевая приёмка на реальном Android WebView владельцем в следующей бете) — вне этого этапа по определению самого AC.

Вывод

Реализация корректно решает координатно-зависимый размер промотированного слоя (AC1–AC3), сохраняет единый live-viewport и визуальный контракт контура на уровне кода, документация и трейлеры в порядке. Но выбранная архитектура (контур — СОСЕДНИЙ <svg> перед планом, а не элемент внутри него) имеет побочный эффект: plan-svg целиком продавливается в отдельный композитный слой, что воспроизводимо проваливает собственный AC4 (перф-свидетель #532) вне узкой марки конкретного CI-раннера и приводит к measurable расхождению во всех 4 golden-сценах AC6 на каноничной Linux-платформе, не сопровождённому обновлением baseline или объяснением. Обе находки — High, обе в скоупе задачи (эта же ветка чинит то, что сама же сформулировала как риск: "Исправление WebView может регрессировать Firefox-производительность #532"). Возврат автору.


Материал раунда

  • Ветка: issue/582-webview-large-filter-layers, коммит 0a6592f4112f — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
  • Дерево материала: 9f4d95168fdda9650fa7d35f17d80c7177a7d12b
    git log --all --format='%H %T' | grep 9f4d95168fdd
    
  • Тело issue: 4c1e1ecfe8ce205f3cfb3c44eb6028a35d0bb4358323e05ba60c6fd85b3b40ba
  • Вердикт конвейера: red · High 2