Files
houseplan-card/docs/reviews/CODE-REVIEW-451-r1.md
T
2026-09-04 19:47:08 +00:00

31 KiB
Raw Blame History

CODE-REVIEW-451-r1

  • Issue: #451 «План подтормаживает: диагностика на каждый кадр, реактивная камера, отсутствие фильтра HA-тиков»
  • Этап: код-ревью, заход r1 (первый действительно проведённый — предыдущая попытка не читала код: ветка не ребейзилась на dev без конфликта, цикл не израсходован)
  • Материал: git log --oneline origin/dev..HEAD / git diff origin/dev...HEAD, SHA 1850eb18b5e8f0813f9d2efb50694dc4ca1fff26
  • ТЗ: docs/specs/451-render-performance.md (полный трек, SPEC-REVIEW-451-r2 зелёный)
  • Вердикт: см. итог в конце документа

Скоуп диффа

75 файлов, +3947/‑1166 (без учёта dist/**/custom_components/.../frontend/**, класс D). Новые модули: src/render-invalidation.ts, src/houseplan-render-lifecycle.ts, src/live-viewport.ts, src/live-hover.ts, src/live-editor.ts, src/live-interaction-runtime.ts, src/pointer-move-queue.ts, src/interaction-types.ts. Изменены src/houseplan-card.ts (overridden requestUpdate, diagnostics, terminal reconciliation), src/houseplan-editor-runtime.ts (pointer-move очереди для plan/decor/backdrop/opening/resize), src/render-device-snapshot.ts (entityIds), src/zigbee-topology-overlay-bridge.ts (live-layer маркер). Плюс performance harness (demo/benchmark_large_house.mjs, budgets-large-house-interaction.json, card-contract.mjs, evaluate.mjs, performance.yml) и точечные правки существующих demo/smoke_*.mjs под новую разметку data-hp-live-*. Геометрия/модель стен, layout, marker.space, open_spans не тронуты — гейт npm run invariants не требуется, что подтверждается и явным «Non-scope» ТЗ.

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

Гейт Команда Результат
typecheck npx tsc --noEmit зелёный
unit npm test 1956 tests, 1955 pass, 1 skip, 0 fail
build + бандл npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js + diff -rq на houseplan-assets совпадают байт-в-байт
docs fingerprint node scripts/check-docs.mjs (обязателен — diff трогает src/**) «Documentation checks passed (7 files, 12 external links)»
any-гейт не запускал отдельно — новые файлы не содержат any (проверено чтением), npm test/typecheck зелёные проверено чтением
smoke-select node scripts/smoke-select.mjs --base origin/dev --head HEAD 98 прямых совпадений + 36 слабых из 221 (см. smoke-select.txt)
smoke (почти полный набор) все 134 файла из прямых+слабых совпадений, node demo/smoke_<name>.mjs по одному 133 OK, 1 нестабильный (см. ниже, не регрессия)
golden npm run golden:verify 153/153 сценариев passed, 0 diff — доказывает AC9 (pixel-equivalence)
performance (структурные assert'ы) npm run benchmark:large-house-interaction -- --samples=7 --warmups=1 завершился без throw — структурный контракт (0 full render на hover/pan/editor move, 1 terminal, 0 diagnostics scans) подтверждён на этом SHA
performance (абсолютные потолки) npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-large-house-interaction.json --candidate=<report> 6 проверок красные — см. находку H1
backend не запускал — диф не трогает custom_components/**/*.py (проверено чтением: git diff --name-only не содержит .py) проверено чтением
invariants не запускал — диф не трогает геометрию/layout/marker.space/open_spans (проверено чтением) проверено чтением

Не прогонял полный demo/smoke_*.mjs (все ~221) и полный performance.yml (Full Performance, парный base/candidate на двух чекаутах) — первое избыточно (134 из 221 уже покрывают все «прямые» и «слабые» совпадения smoke-select, второе требует парного окружения, которое не гейт код-ревью (PROCESS.md §8: «полные наборы — предрелizный гейт»). Раздельный прогон --absolute-only (см. выше) компенсирует это для абсолютных потолков нового профиля — и именно он нашёл H1.

Находки

H1 (High, блокирует) — новый performance-профиль large-house-interaction-v1 не проходит собственный бюджет

AC10 требует: «новый interaction profile проходит все structural, absolute и base-relative checks §11». Структурные проверки (0 full render на hover/pan/camera/editor move, ровно 1 terminal, 0 diagnostics scans, стабильный heavy DOM, ошибка совмещения SVG/HTML ≤1px) — подтверждены, профиль не бросает исключение. Но абсолютные потолки, которые сам же диф вводит в demo/performance/budgets-large-house-interaction.json, не выполняются при фактическом прогоне.

Воспроизведение (детерминировано, не связано со скоростью машины — см. ниже):

npm run benchmark:large-house-interaction -- --samples=7 --warmups=1 --output=/tmp/interaction-review7.json
npm run benchmark:compare -- --absolute-only \
  --budgets=demo/performance/budgets-large-house-interaction.json \
  --candidate=/tmp/interaction-review7.json --output=/tmp/interaction-compare7.json

Результат — 6 красных строк:

❌ timing.interactionSeriesMs.median | 3501.5 | 3000
❌ timing.editorSeriesMs.median      | 1841.9 |  750
❌ longTask.editorSeries.maxSingleMs |    878 |  150
❌ longTask.editorSeries.countP95    |      4 |    3
❌ longTask.editorSeries.totalP95Ms  |   1633 |  300
❌ cache.entries.cleanFloor          |    140 |  100

cache.entries.cleanFloor — счётчик записей Map, не таймінг, машинно-независим. Проверено на двух независимых прогонах (--samples=3 и --samples=7), значение 140 во всех 10 сэмплах без единого отклонения. Для контроля прогнан немодифицированный large-house-v1 (существует и на dev, диф его не трогает):

npm run benchmark:large-house -- --samples=3 --warmups=1 --output=/tmp/base-profile-review.json
# cleanFloor: 100, 100, 100 — ровно документированный потолок (demo/performance/README.md:
# «the reviewed fixture warms exactly 100 deterministic room/physical-body entries»)

Значит переполнение специфично именно для новой editor series части large-house-interaction-v1 (последовательность _setMode('plan') → _tool='resize' → cancel → _setMode('decor') → _setMode('view')), которую этот же диф добавил в demo/benchmark_large_house.mjs. Разница ровно +40 записей (два «лишних» полных прохода флор-кэша сверх документированного одного) коррелирует с тем, что тайминговые метрики именно editorSeriesMs/longTask.editorSeries.* превышены в 2,4× и более — похоже на одну и ту же причину (лишний(е) пересчёт(ы) _cleanFloor/floorMinusBodies при переключении режима редактора), а не на независимый шум.

Попытка воспроизвести на упрощённой ручной фикстуре (page.evaluate с прямым вызовом _setMode без полного цикла реального pointer-move, который использует бенчмарк) рост _cleanFloorCache.size не показала — то есть причина не в самом факте смены режима, а где-то в фактической resize/decor-транзакции бенчмарка (drag-preview → cancel) или в стечении с реальными pointer-событиями. Дальнейшая локализация — задача автора; здесь важен сам факт: AC10 не выполнен по написанному самим автором для этой задачи бюджету, а не только гипотеза о причине.

Тайминговые превышения (interactionSeriesMs, editorSeriesMs, longTask.editorSeries.*) в отличие от cleanFloor теоретически могут быть частично усилены более медленной/разделяемой машиной ревьюера против выделенной машины владельца (demo/performance/README.md: «Local Windows checkout is the day-to-day environment... local report is diagnostic only»), но margin (1842 мс против потолка 750 мс, 878 мс одна long task против потолка 150 мс) для чистого шума великоват, и коррелирует с детерминированным cleanFloor-превышением. Автору стоит перепроверить оба числа на каноническом Linux CI парном прогоне (performance.yml), но независимо от результата cache-count уже доказан красным и машинно не объясним — это не «непрогнанный дорогой гейт», это прогнанный и красный.

Почему High, а не Medium. Задача, ценность 10/10 и P1, явно посвящена именно тому, чтобы новый перф-контракт «краснел сам по себе, а не только на фоне предыдущего коммита» (issue, раздел «Почему это не поймал перф-гейт»). Проверка, которую AC10 называет своим доказательством, при фактическом запуске красная. Это не вопрос «недостаточно полно проверили», а обнаруженный красный автотест по собственному, введённому этим же диффом контракту — ровно то, что код-ревью обязано поймать (PROCESS.md §2.7: «ревьюер отвечает за AC… либо доказан автотестом… либо разобран по коду»). Фикс — в скоупе (тот же demo/benchmark_large_house.mjs/бюджет), отдельный issue не заводится.

M1 (Medium, в скоупе) — обещанный в самом ТЗ «targeted production-bundle smoke» не создан

ТЗ §13.2 и §14 (план реализации, п. 5) явно называют отдельный артефакт: «Targeted demo/smoke_*.mjs — production-bundle render-count и interaction matrix», инструментирующий full-render count, lightweight paints, diagnostics scans, heavy node identity, writes на PRODUCTION-бандле (не на perf-фикстуре) — и именно это заявлено как доказательство AC5/AC6/AC7/AC8 (smoke в их графе доказательства).

git diff origin/dev...HEAD --diff-filter=A показывает: новых файлов demo/smoke_*.mjs нет вовсе. Изменения в существующих смоках (smoke_decor.mjs, smoke_furniture.mjs, smoke_decor_text.mjs, smoke_resize_pointer_real_plan.mjs, smoke_pan_any_zoom.mjs, smoke_room_fit.mjs, smoke_infinite_canvas.mjs, smoke_opening_measure.mjs, smoke_partition_openings.mjs, smoke_drag_bounds.mjs, smoke_room_tooltip_toggle.mjs, smoke_ux_fixes.mjs) — точечная подгонка под новую разметку data-hp-live-*/тайминг RAF, ни один не считает full-render/diagnostics-scan счётчики.

Единственное место, где эти структурные гарантии вообще проверяются — demo/benchmark_large_house.mjs --profile=large-house-interaction-v1, которое подключено только к job'е performance.yml → «Full Performance», запускаемой (см. demo/performance/README.md, «CI contracts») на промоушен в main, раз в неделю и по ручному диспатчу — не на каждый push в dev. Блокирующий performance_smoke в validate.yml меряет только large-house-glow-overlay-v1, этого профиля не касается. При принятой в проекте модели прямых коммитов в dev без PR (AGENTS.md) это означает: следующий обычный коммит, который случайно вернёт diagnostics-scan на каждый рендер или полный render на hover, не будет пойман никаким гейтом до следующего еженедельного/promotion-прогона — то есть тем самым классом слепого пятна, ради которого заведён #451 (issue, раздел «Почему это не поймал перф-гейт»: «гейт исправно подтверждал, что "не стало хуже", пока пользователь смотрел на 451-миллисекундные фризы»).

Фикс в скоупе: добавить обещанный demo/smoke_*.mjs (инструментирование _renderBody/ _bindingStatus аналогично тому, что уже сделано внутри benchmark_large_house.mjs, но на production-бандле через demo/serve.mjs, в постоянно запускаемом наборе).

M2 (Medium, в скоупе) — нет мутанта в scripts/mutation-gate.mjs для новых защитных механизмов

PROCESS.md §2.7: «Мутант в scripts/mutation-gate.mjs обязателен, когда защита живёт в продуктовом коде и проверяется дорогим гейтом (смок, бэкенд, golden): там ревьюер не воспроизведёт отрицательный прогон второй раз». git diff origin/dev...HEAD -- scripts/mutation-gate.mjs — пусто, ни одной новой записи.

Часть новых защит — чистые юниты, и для них я сам прогнал мутацию и увидел красный тест (см. «Таблица доказательств» ниже: classifyHassRenderChange, RenderLifecycle.diagnostics) — этого достаточно, отдельный мутант не нужен. Но интеграционная проводка в houseplan-card.ts (переопределённый requestUpdate, _flushHa/terminal reconciliation, RAF-coalesced camera/hover/editor paints) — юнитами не покрыта и проверяется только дорогим Playwright-бенчмарком (тем же, что в M1/H1). Без постоянного мутанта в следующем раунде (после того, как H1 будет исправлен и профиль перестанет краснеть) ревьюер снова не сможет дёшево доказать «тест умеет падать» — придётся заново гонять браузерный бенчмарк вручную, как это делал я.

Low — известная нестабильность smoke_smooth_zoom.mjs под нагрузкой (не регрессия, к сведению)

При параллельном прогоне 134 смоков (4 конкурентных пакета) demo/smoke_smooth_zoom.mjs дал один красный результат (wheelRetargetsRunningTween/wheelHasIntermediateFrames/wheelStreamPersistsOnce = false). Проверил отдельно: последовательно (без конкуренции за CPU) — 11/11 прогонов зелёные, как на этой ветке, так и на чистом origin/dev. Затем воспроизвёл красный результат на обеих ветках одинаково при явной конкуренции (4 параллельных smoke_smooth_zoom.mjs + 3 тяжёлых смока рядом: 2-3 из 4 падали и на dev, и на PR). Вывод: это существующая до #451 чувствительность теста к CPU-контеншну (reliance на реальные requestAnimationFrame тайминги), не регрессия этой задачи. Не блокирует, в скоуп #451 не входит, отдельный issue не заводится (не находка, а объяснённая флакиность).

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

  • AC1 (HA intake ≠ visual invalidation): requestUpdate('hass', …) в houseplan-card.ts делегирует решение LiveRuntime.hass() → classifyHassRenderChange(); при 'none'/deferred-'state' intake (intakeHass) всё равно выполняется синхронно через RenderLifecycle.observe. Доказано юнитом test/houseplan-render-lifecycle.test.mjs («HA intake runs once even when an unrelated visual update is skipped») + мутацией (см. ниже).
  • AC2 (dependency classifier): src/render-invalidation.ts. Структурные top-level поля (entities, devices, themes, locale.*, неизвестный новый ключ) — fail-open на 'structural'; только states сравнивается по dependency-scoped entityIds. Доказано test/render-invalidation.test.mjs (4/4) + мутацией: убрал ранний return 'none'→заменил на безусловный return 'state' в скомпилированном test-build/render-invalidation.js (не в src/**, генерируемый файл) — тест «an unrelated HA state row does not invalidate the plan frame» покраснел (expected 'none', actual 'state'), затем восстановил файл, тест снова зелёный.
  • AC3 (intake переживает пропущенный рендер): dependency projection в _captureRenderDeviceSnapshot (houseplan-card.ts:4563+) собирает entity/device/area из bindings, room temp/hum source, opening entity refs, live-text, sun.sun, vacuum source — доказано чтением и подтверждено проходом смоков smoke_linked_virtual_light, smoke_static_icon, smoke_cover_no_plate, smoke_cover_plate_precedence, smoke_entity_parent_dedup, smoke_yellow_principle (все зелёные).
  • AC4 (diagnostics cache): RenderLifecycle.diagnostics()/invalidate() в houseplan-render-lifecycle.ts. Единственный вызов houseplanDiagnostics()-подобной логики теперь и в _renderBody (через this._renderLife.diagnostics(...)), и в публичном houseplanDiagnostics() — один источник (см. «Одно число — один источник» ниже). Доказано юнитом («diagnostics scan is cached and invalidated by tracked state presence») + мутацией: заменил guard if (this.diagnosticsCache) на if (false && this.diagnosticsCache) в test-build/houseplan-render-lifecycle.js — тест покраснел (2 !== 1, ожидался один скан вместо двух), восстановил, снова зелёный.
  • AC5/AC6/AC7 (0 full render на hover/pan/pinch/camera/editor move, 1 terminal, deferred-relevant-tick last-wins): структурные assert'ы внутри demo/benchmark_large_house.mjs (throw при нарушении) подтверждены самостоятельным прогоном на этом SHA — все условия выполнены (irrelevantFullRenders:0, hoverFullRenders:0, panMoveFullRenders:[0,0,0,0]/terminalFullRenders:[1,1,1,1], cameraMoveFullRenders:0/cameraTerminalFullRenders:1, editorMoveFullRenders:[0,0,0], relevantDuringGestureFullRenders:0 при relevantDuringGestureIntakes:3, diagnosticsScans:0, heavyNodeStable:true, overlayErrorPx:0.01). Тайминги того же прогона см. H1.
  • AC8 (coalescing сохраняет конечную координату/commit/cancel): pointer-move-queue.ts (queueMicrotask, гарантированно опустошается до следующего браузерного события) + подключение во всех точках (_pointerMove/device, _physicalMove, _rszMove, _bdMove, _opPointerMove, _dtMove, _decorMoveUpdate, _markupMove) с явным flush на terminal (up) и cancel на отмене. Доказано юнитом test/live-editor.test.mjs («pointer move queue is event-turn coalesced, last-wins and flushable») + прогоном затронутых production-bundle смоков (smoke_furniture, smoke_decor, smoke_decor_text, smoke_drag_bounds, smoke_partition_openings, smoke_opening_measure, smoke_resize_pointer_real_plan — все зелёные, включая ровно те сценарии shift-snap/magnet/rotate, которые чувствительны к потере промежуточного состояния).
  • AC9 (pixel-equivalence): npm run golden:verify — 153/153 passed, 0 diff, включая large-house-zoom-040/250-dark, large-house-warm-remount-dark, junction-серию, hover-серию, dark/light/RU/EN варианты. Прямое доказательство отсутствия непреднамеренных визуальных изменений.
  • AC11 (implementation-loop gates): typecheck/test/build зелёные (таблица выше).
  • AC12 (schema/backend/i18n/dependencies не изменены): git diff --name-only не содержит .py/i18n/translations/manifest.json/hacs.json — проверено чтением. Новые кэши (RenderLifecycle.diagnosticsCache, LiveRuntime/live-viewport/live-hover/live-editor state) — все WeakMap<object,…>, ключ — сам инстанс карточки; disconnectedCallback вызывает _liveRt?.dispose() и _editorRuntime?._disposeLiveEditor() (cancel RAF, restore hidden/transparent DOM, cancelHouseplanPointerMove) — bounded per card, очищаются при disconnect, независимы между несколькими карточками на дашборде (WeakMap не даёт общий каталог).
  • AC13 (документация): demo/performance/README.md описывает новый профиль, сценарии, структурные assertions, абсолютные потолки, local diagnostics-команду; docs/CHANGELOG.md/docs/CHANGELOG.ru.md обновлены в том же коммите c0d61ca3 (User-Visible: yes), формулировка «внешний вид и результат действий не меняются» соответствует AC9/non-scope.
  • Одно число — один источник: houseplanDiagnostics() (публичный support-report) и внутренний render-path теперь читают один и тот же RenderLifecycle.diagnostics() — раньше было два независимых прохода по маркерам с одинаковой логикой (потенциальное расхождение при будущей правке одного из них), теперь физически один вызов на одну инвалидацию (доказано юнитом «scans === 1» выше). Другой видимый дважды параметр — zoom badge (Math.round(zoom*100)%) и _zoom/_view: и DOM-бейдж (live-viewport.ts:paintLiveViewport), и canonical-поле читают один и тот же frameOf(host) — host._zoom, без второго независимого округления.
  • Трейлеры: все 10 коммитов несут Issue: #451; ровно один User-Visible: yes (c0d61ca3, «fix: keep large-plan interactions off the full render path») с обоими changelog в том же коммите; остальные User-Visible: no (документация/фикс-ап/ребилд бандла) — корректно.
  • Touch/pen/kiosk/fixed-floor контракт (§9 ТЗ): не даёт отдельной ветки поведения — подтверждено прохождением smoke_isometric_live_touch, smoke_kiosk, smoke_kiosk_pan_lock, smoke_fixed_floor, smoke_touch_tips, smoke_grid_scale_invariance (все зелёные) плюс golden dark/light/RU/EN сценарии.

Чего не проверял

  • Полный demo/smoke_*.mjs (все ~221) — прогнал 134 (все «прямые» и «слабые» совпадения smoke-select.mjs), сознательно не прогонял оставшиеся ~87 без связи с диффом (полный набор — предрелизный гейт, PROCESS.md §8).
  • Парный performance.yml («Full Performance», base vs candidate на двух чекаутах, реальный Chromium CI-раннер владельца) — недоступен в этом окружении; вместо него прогнал --absolute-only (см. H1, нашёл реальную красноту) и структурные assert'ы бенчмарка напрямую. Относительное (base-vs-candidate) сравнение не проверено — вероятно, тоже покраснеет из-за той же причины, что и абсолютное, но это не проверено напрямую.
  • Backend (python -m pytest tests_backend) — не запускал, диф не касается custom_components/**/*.py (проверено чтением diff, не исполнением).
  • npm run invariants — не запускал, диф не касается геометрии/layout/marker.space/open_spans (проверено чтением diff и Non-scope раздела ТЗ).
  • Точная локализация причины лишних 40 записей cleanFloor (H1) — воспроизвёл и доказал факт, не нашёл точную строку кода; это оставлено автору при исправлении H1.
  • Ручная проверка в реальном браузере (вне headless Playwright) и на настоящей Home Assistant — вне доступного окружения; полагался на golden/smoke/unit/performance гейты.

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

  • Ветка: issue/451-render-performance
  • SHA материала: 1850eb18b5e8f0813f9d2efb50694dc4ca1fff26
  • Диапазон: origin/dev...HEAD (10 коммитов, 029aba6b..1850eb18)
  • ТЗ: docs/specs/451-render-performance.md, ревью — docs/reviews/SPEC-REVIEW-451-r2.md (зелёное)

Итог

Вердикт: красный. Одна High-находка (H1 — заявленный самим диффом перф-контракт AC10 красный при фактическом прогоне compare.mjs --absolute-only по собственному новому бюджету, доказано детерминированным счётчиком cache.entries.cleanFloor: 140 vs 100 плюс коррелирующими превышениями таймингов editorSeriesMs/longTask.editorSeries.*) блокирует переход. Плюс две Medium-находки в скоупе (M1 — не создан обещанный ТЗ demo/smoke_*.mjs для render-count на production-бандле, M2 — нет мутанта в scripts/mutation-gate.mjs для интеграционной проводки, проверяемой только дорогим бенчмарком). Сама архитектура решения (разделение intake/visual invalidation, dependency classifier, diagnostics cache, RAF-coalesced live-слои камеры/hover/редакторов, pointer-move очереди) сделана аккуратно и проверяемо: юнит-защиты действительно ловят мутацию, structural assertions бенчмарка действительно бросают исключение при нарушении контракта, golden/smoke/typecheck/build/docs — все зелёные. Возврат автору — на исправление конкретно H1 (и желательно M1/M2 в том же цикле, раз без High они и так были бы Medium-в-скоупе), без расширения скоупа задачи.


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

  • Ветка: issue/451-render-performance, коммит 1850eb18b5e8 — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
  • Дерево материала: 30dcff478a0287b209be4c3066972158d6e6b539
    git log --all --format='%H %T' | grep 30dcff478a02
    
  • ТЗ docs/specs/451-render-performance.md, блоб 7c323a29110974aae369077214b9e2a74d9387c1
    git log --all --find-object=7c323a29110974aae369077214b9e2a74d9387c1 -- docs/specs/451-render-performance.md