31 KiB
CODE-REVIEW-374-r1
Issue: #374 — Feature request: optional light pools and wall shadows in houseplan-space-card
ТЗ: docs/specs/374-space-card-light-pools.md (SPEC-REVIEW-374-r1: зелёный)
Ветка: issue/374-space-card-light-pools, HEAD ревью: f5a49718a0fac379ad8eab3de7f6cbbe8e464c68
Заход: r1 · блокирующих циклов израсходовано 0 из 4
Скоуп ревью
git log --oneline origin/dev..HEAD — 8 коммитов:
f5a49718 test: separate Glow performance contracts (User-Visible: no)
6e3d08ed docs: add static Glow smoke commands (User-Visible: no)
aa3ad429 docs: refresh screenshot provenance (User-Visible: no)
9e9e3284 refactor: type Glow scene boundaries (User-Visible: no)
6d838d77 docs: accept canonical screenshots (User-Visible: no)
0dfc7424 feat: add opt-in Glow to space card (User-Visible: yes)
8914190c docs: review document for #374 (User-Visible: no)
c4f37986 docs: specify opt-in Glow for space card (User-Visible: no)
git diff origin/dev...HEAD --stat: 68 файлов, +12763/−11232. Все трейлеры на
месте (Issue: #374 на каждом коммите); User-Visible: yes только на
0dfc7424, и именно там правятся оба changelog в том же коммите
(docs/CHANGELOG.md, docs/CHANGELOG.ru.md).
Продуктовый код (класс A): src/glow-scene.ts (новый, 623 строки),
src/houseplan-card.ts (−520/+165 в области Glow — извлечение в общий модуль),
src/space-render.ts (+193/−73 — opt-in слой в статическом рендере),
src/space-card.ts (+42 — конфиг/жизненный цикл рантайма), src/space-editor.ts
(+7 — boolean в форме), src/i18n/{en,ru,de,fr}.json (+1 ключ каждый).
Класс B (гейты/тулинг): test/glow-scene.test.mjs (новый), правки
test/golden-matrix.test.mjs, test/performance-contract.test.mjs,
test/performance-workflow.test.mjs, scripts/mutation-gate.mjs,
demo/benchmark_glow.mjs, demo/performance/card-contract.mjs,
demo/smoke_glow_blending.mjs, новые demo/performance/budgets-large-space-card-{default,glow}.json
и budgets-space-glow-smoke.json, .github/workflows/{performance,validate}.yml,
tsconfig.test.json.
Класс C (документация): docs/LIGHT.md, docs/ARCHITECTURE.md,
docs/TESTING.md, docs/USER-GUIDE{,.ru}.md, docs/CHANGELOG{,.ru}.md,
docs/images/** + docs/images/screenshots.json (canonical Docs screenshots).
Класс D (генерируемое): dist/**, custom_components/houseplan/frontend/** —
синхронный бандл, не редактировался вручную.
Трек — полный (не light), верно: SPEC-REVIEW-374-r1 уже подтвердил это по §5.
Как проверялось
- Прочитаны заново
docs/SCOPE.md,AGENTS.md,PROCESS.md(жизненный цикл, §1 классы файлов, гейты код-ревью, формат вердикта, лимит циклов). - Прочитано тело issue #374 целиком, включая финальный комментарий автора о передаче на код-ревью (реализация, проверки, решение по smoke-select).
- Прочитан ТЗ
docs/specs/374-space-card-light-pools.mdцеликом (контракт поведения §1–5, архитектурный контракт, AC1–AC10, план автотестов) и документSPEC-REVIEW-374-r1.md, чтобы не переизобретать то, что спек-ревью уже проверил. - Прочитан канонический
docs/LIGHT.md(обновлённый раздел «Which surfaces render pools») иdocs/USER-GUIDE.ru.md/.md(обновлённые разделы проhouseplan-space-card) — сверена терминология («Световые пулы и тени от стен», независимость отlive_states). - Прочитан новый общий модуль
src/glow-scene.tsцеликом (623 строки) — единственный источник истины по source projection, barrier scene, clip geometry, runtime lifecycle и SVG-рендеру. - Построчно сверен
git diffпоsrc/houseplan-card.ts,src/space-render.ts,src/space-card.ts,src/space-editor.tsс оригиналом (git show origin/dev:src/houseplan-card.tsчерез диффы) — извлечение алгоритма из полной карточки вglow-scene.tsсверено построчно на предмет того, что математика (probe,_cmToUnits, кеш-ключи, LRU-семантика, порядок occluders,enabledClip) не изменилась, а не просто переехала по имени. - Отдельно проверена LRU-семантика:
readGlowClip/writeGlowClipвglow-scene.ts:140-161сверены построчно с общейlruRead/lruWrite(src/houseplan-card.ts:409-425) — идентичны (move-to-end, лимит по умолчанию 256, тот же порядокdelete+set). - Проверены тесты, изменённые вместе с рефакторингом:
test/glow-scene.test.mjs(unit на revision/candidates/runtime lifecycle),test/golden-matrix.test.mjs(assertions на структуру перенесены наglow-scene.ts, а не ослаблены),test/performance-contract.test.mjs(проверяетresolveLightBarrierRevision/buildLightBarrierSceneв новом месте),test/performance-workflow.test.mjs(workflow отражает новые перф-профили). - Проверены новые мутанты в
scripts/mutation-gate.mjs— старые мутанты, указывавшие наsrc/houseplan-card.ts, перенесены наsrc/glow-scene.tsвместе с перемещённым кодом (не потеряны), плюс один новый мутант (opaqueBodies: number[][][] = [];) на общий источник физических тел. - Прочитан весь
docs/images/screenshots.json/CI-контекст: подтверждено, чтоsourceFingerprintобновлён (т.к.src/**менялся) и Docs screenshots workflow прошёл зелёным. - Самостоятельно запущены гейты (см. «Гейты» ниже) вместо того, чтобы принять отчёт автора на слово, включая независимый запуск ключевых Glow-смоков.
Гейты
Уже подтверждено на этом SHA (не перегонялось повторно):
npx tsc --noEmit, npm test, npm run build — Validate на точном
f5a49718 зелёный: https://github.com/Matysh/houseplan-card/actions/runs/33251556837
(job «Фронтенд: типы, юниты, мутанты, синхрон бандла» = success).
Перегнано мной самостоятельно (диф трогает src/**, рендер и Glow-геометрию):
npm run build && npm run bundle:sync— зелёный,git statusпосле сравнения трёх копий бандла (dist/**,custom_components/houseplan/frontend/**,demo/srv/assets/**) чист, т.е. закоммиченные бандлы уже соответствуют этому дереву.npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test test/glow-scene.test.mjs test/golden-matrix.test.mjs test/performance-contract.test.mjs test/performance-workflow.test.mjs— 56/56 зелёные (весь набор, затрагивающий сам рефакторинг и оба новых перф-профиля).node scripts/smoke-select.mjs --base origin/dev --head HEAD— воспроизвёл ровно тот же результат, что в отчёте автора: 50 direct, 13 weak, 3 registered. Не оспариваю выборку.- Из «direct» списка самостоятельно прогнаны браузерные смоки, напрямую
завязанные на изменённый Glow-код:
node demo/smoke_glow.mjs— OK (все 28 инвариантов true),node demo/smoke_glow_fail_dark.mjs— OK,node demo/smoke_glow_geometry_resilience.mjs— не запускал отдельно (уже покрыт полным CI-прогоном ниже),node demo/smoke_glow_blending.mjs— OK,{"ok":true,"blend":"screen","pools":60,"staticParity":true,"staticPools":60}— это единственный смок, напрямую проверяющий AC1/AC2/AC6 (default-off = 0 pools у выключенной карточки, opt-in = 60 pools, полное совпадение center/radius/lit-parts/clip первого источника между полной и статической карточкой,blend=screen,pointer-events:none). - Полный браузерный CI-прогон (все 3 шарда смоков +
golden) на6e3d08ed— зелёный: https://github.com/Matysh/houseplan-card/actions/runs/33251238199 (единственный красный job там —performance_smoke, из-за бага в самом перф-харнессе, что видно по джобам:gh api .../jobsподтверждает — «Смоки: все шарды зелёные» и «Golden-кадры» обаsuccess, красный только «Перф-смок»). Проверено, чтоgit diff 6e3d08ed..HEAD -- src/ demo/ test/ scripts/ .github/после этого прогона касается толькоdemo/benchmark_glow.mjsи трёх budgets-JSON (фикс харнесса),src/**не менялся ни строкой — то есть этот зелёный прогон валиден для продукта на текущем HEAD, а не устарел. node --test performance-contract/golden-matrix(см. выше) подтверждают, что оставленный сокращённый набор смоков — не единственное доказательство: структурные unit-проверки самого извлечения тоже зелёные.
Не перегонялось, и почему:
- Остальные 47 «direct» и все «weak»/«registered» смоки из smoke-select — не
прогонялись поштучно, потому что уже накрыты полным браузерным прогоном
(все 3 шарда = весь
demo/smoke_*.mjs, не подмножество — проверено по.github/workflows/validate.yml:455—for f in demo/smoke_*.mjs) на SHA, чейsrc/**/demo/**/test/**байт-в-байт совпадает с рассматриваемым HEAD (см. выше). Повторный прогон был бы тем самым «прогонять весь набор ради тщательности», от которого прямо предостерегает инструкция. - Полный
npm test(весь inventory) — не перегонял целиком, доверяю зафиксированному зелёному Validate на этом SHA; вместо этого прогнал целиком именно изменённые тестовые файлы (см. выше), это дешевле и целится точно в диф. npm run golden:verify— не прогонял локально (нет принятого golden-набора под рукой вне CI-контейнера); полагаюсь на зелёный jobgoldenв прогоне на6e3d08ed, чейsrc/**/demo/**идентичен HEAD.python -m pytest tests_backend -q— не прогонял: диф не трогаетcustom_components/**/*.py.node scripts/model-invariants.mjs/npm run invariants— не прогонял. Диф не меняет модель геометрии (рёбра комнат, записи толщины,layout,marker.space,open_spans):src/glow-scene.tsиspace-render.tsчитают существующую геометрию (стены, проёмы, комнаты) для построения временной сцены видимости света, но не пишут и не мигрируют ни один из перечисленных в задании инвариантных полей. Единственный новый персистентный атрибут — булев Lovelace-конфигlight_poolsконкретного card instance (явно не-скоуп бэкенд-схемы и model version по самому ТЗ, раздел «Модель данных»). Инварианты модели тут не применимы.- Полный
demo/performance/**Linux-артефакт (нужен только для утверждения численных бюджетов, PROCESS.md) — не прогонял; это предрелизный гейт, а автор уже приложил ссылку на прогон CI-профилейspace-default/space-glowна точном SHA в составе.github/workflows/performance.yml(запускаютсяfull-performance-*только по расписанию/вручную — не гейт этого ревью). Быстрые абсолютные perf-smoke бюджеты (budgets-space-glow-smoke.json) — часть обычного Validate, который зелёный на HEAD. - Мутационный гейт (
scripts/mutation-gate.mjs) целиком — НЕ прогонялся: это отдельный запланированный workflow (.github/workflows/mutation-gate.yml, шардированный на 4, groupmutation-gate), не входящий в перечень гейтов код-ревью по PROCESS.md §8. Ограничился чтением: подтверждено, что старые мутанты, целившиеся в перенесённый код, обновилиfile:-путь наsrc/glow-scene.tsвместе с кодом (не потеряны молча), и добавлен новый мутант на удалениеopaqueBodiesиз общего источника. (Замечание к инструменту, не к задаче: случайный запускnode scripts/mutation-gate.mjs --help | head -40в начале проверки интерпретировал--helpне как флаг, а начал прогон «чистого» шага по всему реестру мутантов, до того как канал оборвался черезhead; после проверкиgit worktree list/git statusподтверждено, что временных worktree не осталось и рабочее дерево осталось чистым — побочных эффектов на этот репозиторий нет.)
Находки
Блокирующих (High) и Medium-находок, ни в скоупе, ни вне скоупа, не найдено.
Рассмотренные, но не подтвердившиеся кандидаты (для прозрачности разбора):
renderGlowPools()добавляетaria-hidden="true"на<g class="glowlayer glow-pools-frame">теперь и для полнойhouseplan-card, хотя в старом коде полной карточки этого атрибута не было (сравнение диффа —src/houseplan-card.tsдо рефакторинга не устанавливалaria-hiddenна этот узел, толькоpointer-events:none). Формально это меняет вывод полной карточки, а архитектурный контракт ТЗ (п.4) требует «existing full-card output must be regression-equivalent», явно перечисляя geometry/identifiers/ blend/fade/budgets.aria-hiddenне входит в этот список, слой и так былpointer-events:none(уже вне tab order/hit-testing), а сам ТЗ отдельно требует именно этот атрибут для нового статического слоя (раздел «UX и доступность»). Полный CI-прогон (golden + все смок-шарды) на этом дереве зелёный, то есть ни один существующий тест не привязан к отсутствию атрибута. Не нахожу это регрессией — общий SVG-рендерер обязан быть идентичным по построению, и small accessibility-улучшение попутно для полной карточки не нарушает ни один продуктовый контракт. Отмечаю как наблюдение, не как находку.glowEnabledRoomsвspace-render.tsстроится изspace.rooms.filter(roomGlowOf)без предварительной фильтрации по наличию валидногоroomPoly(), тогда какrevision.polygons(используемый для сравненияallEnabled = glowEnabledRooms.length === revision.polygons.length) уже отфильтрован по валидной геометрии. У полной карточки оба массива (enabled,polys) заранее отфильтрованы одинаково. Разница затрагивает только оптимизационную ветку (пропуск построенияenabledClip, когда все комнаты и так включены) — при вырожденной комнате безpoly/x,y,w,hcódigo просто перейдёт на более медленную ветку с явным вычислением holes, результат на экране не меняется, потому чтоglowEnabledRooms.flatMapсам отбрасывает комнаты безpoly(if (!poly) return [];). Не нахожу видимого дефекта — при желании это Low-полировка, не требующая правки в этой задаче._cleanFloor()-эквивалент в статическом пути (space-render.ts:665) не делает bounding-box предфильтрextrasпередfloorMinusBodies, в отличие от_cleanFloor()полной карточки (houseplan-card.ts:9388-9393), которая сначала обрезает кандидатов по bbox floor∩body. Разница чисто в объёме работы буллевой операции (перф), не в результате:floorMinusBodiesгеометрически корректен и без предфильтра. AC7 отдельно требует perf-профиль opt-in пути и он приложен зелёным (см. Гейты); не блокирую.
Что проверено и корректно
- Единый алгоритмический источник истины (архитектурный контракт п.1–5 ТЗ).
src/glow-scene.tsне знает о классе карточки, не читает DOM/hass/приватные поля (принимает всё через параметры/GlowRuntimeHost), не владеет неограниченным module-level кешем (createGlowRuntimeState()— состояние per caller,writeGlowClipограничен лимитом по умолчанию 256). Обе карточки вызывают ровно один набор функций:resolveLightBarrierRevision→buildLightBarrierScene→resolveGlowCandidates/glowSourceInOpaqueBody→buildGlowClipGeometry/transitionGlowSource→renderGlowPools. Второй реализации aperture classification/visibility/falloff/SVG-поля нет — проверено чтением всего файла и обоих сайтов вызова (houseplan-card.ts:10255-10425,space-render.ts:548-701). - Математика извлечения не изменилась при переносе — построчно сверено:
probe = Math.max((10/cellCm)*gridPitch, gridPitch*0.5)идентично старомуMath.max(this._cmToUnits(10), this._gridPitch*0.5)(сама_cmToUnitsделает то же деление/умножение,houseplan-card.ts:7538-7540); порядок построенияoccluders(room-outline cuts → partition cuts → physical bodies → wall union recut/rebuild → zero-wall barriers →splitAtIntersections) идентичен строка в строку;readGlowClip/writeGlowClipвоспроизводят общуюlruRead/lruWriteкарты (move-to-end LRU, лимит по умолчанию 256). - AC1 (opt-in/обратная совместимость).
light_pools— новое опциональное полеSpaceCardConfig(space-card.ts:69), defaultfalseи вgetStubConfig(), и вsetConfig(). Приfalse/omittedspace-render.ts:548входит в веткуforgetGlowSpace(...)без единого вызова видимости/барьера — ниresolveLightBarrierRevision, ниbuildLightBarrierSceneне вызываются. Нормализация строго=== true(не «любое truthy») — и вspace-card.ts:109(lightPools: this._config.light_pools === true), и в тестеglow-scene.test.mjs:130. Ошибка неизвестногоspaceне тронута этой веткой кода (её обрабатывает_errorCard, вне диффа). Подтверждено смокомsmoke_glow_blending(0 pools при omitted, 60 приtrue). - AC2/AC3 (parity источника и окклюзии). Общий
resolveGlowCandidates(единственная логика владения marker↔source, приоритетglow_color/glow_radius_cmнад palette/global) и общийbuildLightBarrierSceneиспользуются обеими картами с идентичными входами (walls,zeroWalls,sharedWallGeometry/canonicalWallGeometryдля recut). Прогонsmoke_glow_blendingподтверждает побитовое совпадение center/cx/cy/radius/lit-parts/clip первого источника между full и static.smoke_glow_fail_darkподтверждает fail-dark источника в окне/непрозрачном теле независимо от карточки (через тот жеglowSourceInOpaqueBody). - AC4 (rooms/fills/layering). Порядок слоёв в
space-render.ts—data/tunnel fills → glow-base-layer → passageGlowTunnels → glowPools → wallUnion → …— совпадает с контрактом ТЗ п.4.1;glowPoolsвставлен междуpassageGlowTunnelsиwallUnion, то есть под стенами/маркерами/подписями, что соответствует «Pool не перекрывает стену, маркер или подпись». Мутантmutation-gate.mjs(перенос${glowPools}после room-labels) существует именно для проверки порядка.glowEnabledRooms/enabledClipреализуют «transport проходит через disabled room, но не красит» тем же алгоритмом, что полная карточка (islandsOf, holes на другие комнаты). - AC5 (жизненный цикл).
transitionGlowSource/forgetGlowSource/pruneGlowSources/disposeGlowRuntime— общие функции, идентичное поведение таймеров/RAF на обеих картах.space-card.tsвызываетdisposeGlowRuntimeи вdisconnectedCallback(), и вsetConfig()при переключенииlight_poolsвfalse— соответствует п.5.5 ТЗ («выключение флага очищает оставшиеся timers/caches»). Unitglow-scene.test.mjs(«shared Glow runtime is bounded and tears down every timer and source») проверяет это на fake timers/rafs. - AC6 (static remains static).
.hp-static-stageне менялся (не в диффе),renderGlowPoolsжёстко ставитpointer-events="none"иaria-hidden="true"на слой; кнопки/hover/tap не затронуты (диф не трогает обработчики событий static card).smoke_glow_blendingпроверяетgetComputedStyle(...).pointerEvents === 'none'на самой сцене. Screen-space feather пересчитывается из_stageWidth(существовавшая ResizeObserver-инфраструктура, переиспользована, не продублирована). - AC7 (перф/bounded memory). Новые профили
large-space-card-default-v1/large-space-card-glow-v1заведены симметрично существующим (тот жеbenchmark_glow.mjs, тот жеcard-contract.mjs), вvalidate.ymlиperformance.ymlподключены отдельными budgets JSON с ограничениемcacheEntries.glowClip: 0для default и128(bounded) для glow-профиля,cacheGrowth.glowClip: 0в обоих — то есть кэш не растёт на обычных HA tick. Численные абсолютные потолки (hardMaxMs) щедрые (750–2200 мс), измеренные автором значения (230–274 мс median) далеко внутри них. - AC8 (full card без регрессии). Golden + все 3 смок-шарда зелёные на
дереве, идентичном HEAD по
src/**(см. «Гейты»);test/golden-matrix.test.mjsявно требует, чтобыhouseplan-card.tsвызывалrenderGlowPools({...})общего модуля, а не держал копию рендера. - AC9 (документация/i18n/релиз).
docs/LIGHT.mdпереписан — больше не утверждает, что static card всегда без pools.docs/USER-GUIDE.md/.ru.mdописывают defaultfalse, независимость отlive_statesи то, что режим тяжелее дефолтного. i18n:editor.light_poolsдобавлен во все 4 словаря без английского fallback для de/fr (сверено переводом: «Lichtkegel und Wandschatten», «Halos lumineux et ombres des murs» — по смыслу корректны). Оба changelog содержат ссылку на #374 в одном коммите (0dfc7424). - AC10 (сборка и бюджеты). Никаких новых зависимостей (
package.json/package-lock.jsonне менялись). Три копии бандла байт-в-байт совпадают (проверено мной локальной пересборкой +git statusпуст). - Токены
Issue:/User-Visible:на всех 8 коммитах корректны;User-Visible: yesровно на коммите, где правятся оба changelog. - «Одно число — один источник»: диф не добавляет и не дублирует ни одной новой пользовательски видимой величины (переключатель — булев, без предпросмотра числа где-либо ещё); неприменимо.
Чего не проверял
См. подробный список с обоснованием в разделе «Гейты» → «Не перегонялось, и
почему»: полный npm test/golden:verify/perf-Linux-артефакт/pytest tests_backend/model-invariants/полный mutation-gate. Дополнительно не
проверял:
- Не воспроизводил вручную в браузере визуальную идентичность light/dark и
390/900 px матрицы из плана автотестов ТЗ (п.6) — полагаюсь на зелёный
golden-job (пиксельные снапшоты берутся именно в этой матрице по
demo/golden/README.md) вместо ручного повторения. - Не проверял способ, которым
staticLightBarrierCache/staticPhysicalBodiesCache(WeakMapпоServerConfig) освобождаются при полной смене конфигурации — доверяю существующему паттернуcachedStaticWallGeometry/cachedStaticPhysicalBodies, который используется тем же способом уже до этой задачи (не новый код, не трогался диффом кроме добавления третьей аналогичной кеш-структуры).
Вердикт
Изменение — качественное извлечение общей канонической Glow-модели в
src/glow-scene.ts с последующим тонким подключением в обеих картах, ровно
по архитектурному контракту ТЗ. Математика при переносе не изменилась
(построчно сверено), тесты перенесены вместе с кодом, а не ослаблены, кеш и
жизненный цикл рантайма — общие и bounded. Собственные независимые прогоны
(unit по изменённым файлам, ключевые Glow-смоки, пересборка бандла) зелёные и
совпадают с отчётом автора; полный браузерный CI-прогон подтверждён на дереве,
идентичном HEAD по продуктовому коду. Блокирующих и Medium-находок нет.
Вердикт: зелёный.