Files
houseplan-card/docs/reviews/CODE-REVIEW-583-r1.md
T
2026-09-16 06:01:12 +00:00

27 KiB
Raw Blame History

CODE-REVIEW-583-r1

Issue: #583 · Материал: e2801e4e8b1b7a38be88a6dea210caba0e756783 (заход r1, полный трек) Ветка: issue/583-isometric-visual-corrections Коммиты в диапазоне origin/dev...HEAD: 5cae9350 (fix), e2801e4e (test)

Скоуп проверки

ТЗ (см. тело issue #583, раздел ## ТЗ) описывает четыре независимых presentation-дефекта скрытого 2.5D режима, найденных на реальном стенде после #570:

  1. декоративные contact/leaf тени стен и дверных полотен — убрать;
  2. дверь между Hallway и Under-Stairs Closet визуально «вросла» в стену — починить геометрию/порядок отрисовки;
  3. иконки устройств/замков после проекции 2D→2.5D налегают друг на друга — нужен общий детерминированный collision-pass;
  4. серый/двойной контур дверных полотен — убрать нарисованный stroke.

14 AC, полный трек (не light: геометрия + новый алгоритм коллизий). Диапазон правок: src/iso-openings.ts, src/iso-overlays.ts, src/iso-scene-render.ts, src/styles/plan.styles.ts, соответствующие unit/contract тесты, demo/smoke_isometric_contract.mjs, demo/smoke_isometric_live_touch.mjs, docs/ISOMETRIC.md, docs/ARCHITECTURE.md, docs/STATUS.md. Никаких изменений вне класса A/B/C/D (backend, схема, layout, rooms/openings/devices не тронуты — совпадает с §8 ТЗ «модель данных и миграция»). Trailers на обоих коммитах: Issue: #583 / User-Visible: no; docs/CHANGELOG*.md не менялись — верно для скрытого экспериментального режима (§14 ТЗ).

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

Дешёвые гейты этого SHA уже подтверждены зелёным Validate (мутанты + push-Validate, ссылки в комментариях issue: run 35059679236 и run 35059894623) — tsc/test/build повторно не гонял ради самого факта зелёности, но:

  • npm test перегнал сам (не поверил на слово): 2719 passed, 0 failed, 1 skipped — совпадает с заявленным автором числом.
  • npm run bundle:sync (пересобирает и раскладывает бандл) — зелёный, cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js и cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js — три копии побайтово идентичны, git status после пересборки чист (закоммиченный бандл уже свежий).
  • npm run bundle:budget — зелёный: initial View 291127 B / потолок 291700 B (headroom 9939 B, тот же предупреждающий запас, что и до задачи — не регрессия этой задачи).
  • node scripts/no-new-any.mjs --base origin/dev --head HEAD — «Новых any нет» (382 добавленные строки в 4 файлах).
  • node scripts/check-docs.mjs — обязателен, т.к. диф трогает src/**. В строгом режиме (по умолчанию) — ERROR «screenshot source fingerprint is stale»; это ожидаемо и не находка: отпечаток документации считается по всему src/**, поэтому любая фронтенд-правка его обнуляет (PROCESS.md §8). Проверил, как это классифицирует сам CI: validate.yml считает свежесть скриншотов предупреждением на обычном пуше и ошибкой только на PR/кандидате беты/по кнопке (classify-changes.mjs/heavyGatesRequested). Прогнал --screenshots=warn (режим обычного пуша) — «Documentation checks passed (7 files, 12 external links)» с тем же warn. Остальное содержимое check-docs (гайды, ченджлоги, скриншот-индекс, внешние ссылки) — зелёное.
  • node scripts/smoke-select.mjs --base origin/dev --head HEAD — «Прямое совпадение» в основном по символу cellCm (широко используемое имя параметра в десятке несвязанных смоков — слабая связь, не гонял: аналог примера из инструкции с smoke_wall_junctions). Содержательные совпадения: demo/smoke_isometric_contract.mjs (прямое, символ isoFixedLightTransform, напрямую тронутый диффом) и demo/smoke_wall_thickness.mjs («зарегистрированная связь», символ openingSymbolOffset, тронутый в leafBasis). Прогнал оба — OK. Дополнительно прогнал demo/smoke_isometric_live_touch.mjs (сам тронут диффом, проверяет ровно снятые тени/структуру в touch-контексте) — OK. Остальные 247 смоков не гонял: тема (двери/тени/коллизии/иконки) в них не встречается, а полный прогон — предрелизный гейт (PROCESS §8).
  • npm run golden:verify (полный набор, ~172 сцены) — прогнал целиком, т.к. диф меняет видимый рендер (геометрия/стили/слои) и AC1-3/8/13 явно требуют golden-доказательство. Результат: 160 passed, 11 different, 1 error. Разбор ниже.
  • Perf: demo/benchmark_large_house.mjs --profile=large-house-isometric-v1 прогнал вручную с --samples=2 --warmups=0 как грубую сверку на порядок величины (не авторитетный гейт — методика калибровки требует 7 samples + warmup и сравнение с базовым SHA, которого у меня нет). Цифры (порядка 1-5 сек на разных метриках) не показали разрыва «в разы» вроде прежнего #160 (9870мс/3500мс потолок). Один замер resizePreviewMs=5038 при hardMax 2200 — но без warmup первый замер всегда шумный, вывод неавторитетен, не привожу как находку. Формальный performance_smoke/Full Performance — предрелизный гейт (PROCESS §8, heavyGatesRequested() не включает его на обычном пуше; выполняется на кандидате беты/PR/nightly). AC11 явно требует его как доказательство — это остаётся долгом перед бетой, не блокером ревью.
  • node --test test/single-source-numbers.test.mjs — зелёный. Диф не вводит новую пользовательскую величину, показываемую дважды (48 CSS px — внутренний геометрический параметр, не отображается пользователю) — правило неприменимо.
  • Инварианты модели (npm run invariants) — не гонял: диф не трогает layout, room edges, marker.space, open_spans или любую другую каноническую запись геометрии — только derived-presentation слой 2.5D (подтверждено чтением диффа: только src/iso-*.ts + стили + доки/тесты). §8 ТЗ прямо это утверждает.
  • python -m pytest tests_backend — не гонял, Python не тронут.

Условие честности: гейты, которые НЕ гонял — полный smoke-набор (250 файлов, кроме трёх целевых), калиброванный performance_smoke/Full Performance, pytest, model-invariants. Причина каждого — выше.

Разбор кода

Тени (AC1)

src/styles/plan.styles.ts убирает .iso-contact-shadow/.iso-leaf-shadow и их forced-colors/no-filter упоминания, оставляя только .iso-ambient-shadow. src/iso-scene-render.ts: renderIsoShadows() — теперь чистый stub (emptySvg()), renderIsoDefs() больше не принимает 'shadows' как root, фильтры hp-iso-contact-shadow/hp-iso-leaf-shadow удалены. Тест isometric-contract.test.mjs проверяет ровно один isoFixedLightTransform (было три) и отсутствие текстовых следов удалённых классов/фильтров в исходнике. Смок smoke_isometric_contract.mjs подтверждает в браузере: fixedLightVectorShared теперь shadowNodes.length === 1 и явно проверяет !root().querySelector('.iso-contact-shadow, .iso-leaf-shadow, #hp-iso-contact-shadow, #hp-iso-leaf-shadow'). Glow/SUN не тронуты (проверено чтением: единственные правки — в iso-openings.ts/iso-overlays.ts/iso-scene-render.ts/стилях, ни один из них не пересекается с sun/glow модулями). Соответствует AC1.

Дверь «вросла» в стену (AC2, AC3)

src/iso-openings.ts leafBasis(): для door/gate смещение (offset) теперь равно input.face (реальная выбранная физическая грань стены) вместо openingSymbolOffset(...) (центр стены). Сохранённые координаты и Flat-символ не меняются — офсет применяется только внутри производного Iso-базиса, что проверено алгебраически: origin = (x,y) + face.offset, а selectedStart/selectedEnd в buildIsoOpeningBasis вычисляются той же формулой (start/end + offset), т.е. leaf.hinge для leaf 0 у door/gate теперь буквально совпадает с face.selectedStart — ровно то, что проверяет новый тест 'door and gate face matrix keeps every live state on the selected physical hinge' (перебор angle×side×flipH для door/gate, плюс проверка конечности всех 5 surfaces на amount 0/0.5/1). Окна не тронуты (тот же openingSymbolOffset, тест isoWindowFlipStaysCentred подтверждает). Revision policy поднята 2→3 (инвалидация LRU кэша — правильно, т.к. геометрия меняется).

buildIsoWallDepthQueue(): локальное per-window упорядочивание слотов по cameraDepth расширено на любой opening layer (было только window), чтобы вращающийся door/gate-призм не инвертировал свои front/back грани. Новый тест 'shared painter queue keeps rotating door prism faces in physical camera order' строит реальный door-basis, реверсирует порядок входных surfaces и проверяет: (а) слоты одной двери сортируются строго по cameraDepth, (б) слоты чужих стен не двигаются. Читал алгоритм (openingSlots/nextOpeningSlot — переименованный, но структурно тот же механизм, что был для окон) — не нашёл способа, которым переупорядочивание одного opening могло бы задеть слот другой стены/проёма: индексация строго по ключу type\0id. Соответствует AC2/AC3.

Контур двери (AC2 §6.2 п.5)

.iso-opening-panel.iso-material-matte-leaf { stroke: none } во всех темах (light/dark/auto/forced-colors — прямая замена stroke: <цвет> → stroke: none, окна (iso-material-glass-*) не тронуты — их бордер остаётся). Проверено тестом (isometric-contract.test.mjs: stroke:\s*none в трёх тема-вариантах + iso-material-glass-side... stroke: #8fb4c7 для окна) и живьём браузерным смоком. Соответствует.

Взаимные столкновения устройств/замков (AC4-11)

Новый чистый резолвер resolveIsoOverlayCollisions() в src/iso-overlays.ts (строки после существующего resolveIsoOverlayPlacement, никакая существующая функция не изменена, только импортирован unprojectFloorPoint). Прочитал алгоритм построчно:

  • Один общий бюджет, не добавочный (AC7): IsoOverlayPlacement.raisedScene — это исходная непровязанная проекция floor-anchor (без wall-nudge). nudgeScene/nudgeCss в каждой промежуточной и финальной плейсменте — это смещение от raisedScene, не инкремент от предыдущего шага. Кандидатные офсеты (ISO_OVERLAY_GROUP_OFFSETS_CSS, решётка шагом 1 CSS px, предвычислена один раз на модуль, отсортирована по расстоянию) — абсолютные векторы от raisedScene, и та же константа ISO_OVERLAY_MAX_NUDGE_CSS_PX=48 режет и wall-safety, и group-pass. Проверил алгебраически, что placementAtGroupOffset/candidate() действительно считают visualScene = base.raisedScene + offsetScene, а не base.visualScene + offsetScene — бюджет не удваивается. Юнит-тест 'group collision reports a deterministic residual without exceeding the absolute cap' прямым числом подтверждает nudgeDistanceCss <= 48.
  • Приоритет (AC6): stableItems сортируются по возрастанию уже требуемого nudgeDistanceCss (кто меньше отклонился от исходной точки — тот раньше «застолбит» позицию), при равенстве — по isoOverlayCollisionKey(kind,id) (стабильный, не зависит от HA registry order). Тест 'group collision separates a solvable dense set independently of input order' прогоняет прямой и реверсный порядок входа и требует побитового совпадения итоговых центров — прошёл бы только при действительно детерминированном тай-брейке.
  • Room labels исключены (AC8): entries.flatMap((entry) => entry.kind === 'room-label' ? [] : [...]) в iso-scene-render.ts — labels физически не передаются в resolveIsoOverlayCollisions. Тест 'render scene separates device roots without moving labels...' проверяет label.placement. nudgeDistanceCss === 0 при налегающих устройствах вокруг.
  • Кандидат остаётся в комнате/вне стен (AC4/AC5): для не-базового офсета код заново проверяет pointStrictlyInRoom, segmentStrictlyInRoom (путь от текущей позиции к кандидату не выходит из комнаты) и footprintNearSilhouette — те же существующие геометрические примитивы, что использует wall-safety пасс (не новые, не продублированные).
  • Плотная нерешаемая группа (AC5): при отсутствии свободного кандидата — placementAtGroupOffset(..., residual=true) помечает status:'degraded', reason:'overlay-collision', но не меняет footprint/DOM-owner (только сдвигает позицию в пределах кэпа) — отдельные hit/focus остаются, т.к. рендер (houseplan-card.ts, не тронут этим диффом) по-прежнему рисует каждый entry как отдельный HTML-узел. Юнит-тест с комнатой 1×1 и screenHalfSize:[30,30] подтверждает residualPairs.length===1, status:'degraded', reason:'overlay-collision'.
  • Кэш/no-accumulation (AC10, AC11 частично): collisionSignature — чистая функция входных placement/footprint/unitsPerPixel, previous берётся из Map, keyed по input.wallSilhouettes (ссылка) и mode ('live'|'fit'). Тест '... caches permutations': реверс порядка устройств → Object.is (assert.strictEqual) тот же объект сцены — подтверждает, что памоизация действительно попадает в кэш, а не просто пересчитывает одинаковый результат.
  • Fit skips live search (AC "fit envelope"): mode==='fit' передаёт { placements: new Map(), residualPairs: [] } без вызова резолвера; тест 'fit probing deliberately skips live group displacement' подтверждает оба устройства остаются в одной точке (не разведены).
  • Производительность (AC11, частично): поиск кандидатов — предвычисленная решётка ограниченного радиуса (≈π·48²≈7238 точек при шаге 1px), с ранним выходом на первом бесконфликтном кандидате и хеш-сеткой (nearby(), ISO_OVERLAY_GROUP_CELL_CSS_PX=64) вместо честного all-pairs скана; дорогая ветка входит только для реально конфликтующих элементов (у остальных первый candidate(baseOffsetCss) сразу без конфликтов и возвращается). Статический разбор — не заменяет калиброванный perf-гейт (см. «как проверялось» выше).

Соответствует AC4-11 по прочитанному коду + прогнанным юнит-тестам, которые реально способны упасть (проверил, что тест на приоритет/кэш ловит регресс: реверс входного порядка/повторный вызов — не тривиальные ассерты).

Документация

docs/ISOMETRIC.md, docs/ARCHITECTURE.md, docs/STATUS.md — обновлены консистентно с кодом (проверил построчно): убраны упоминания contact/leaf-теней и per-window-only переупорядочивания, добавлено описание group-pass, host-face pivot для door/gate, revision 2→3. docs/STATUS.md корректно ссылается на #583 в описании Hidden Alpha Stage.

Находка (Medium, в скоупе)

Золотой (golden) семантический контракт для iso-дверей не обновлён под новое поведение и падает исключением, а не просто устаревшим пикселем.

Воспроизведение: npm run golden:verify (прогнал целиком) → isometric-opening-symbol-parity-dark заканчивается статусом error (не different), с сообщением:

semantic golden Iso centre failed for golden-iso-door: 12.5

Причина: demo/golden/run.mjs, функция assertOpeningSymbolContract(), ветка else (surface 'iso', ~строки 852-870) — для каждого opening с offset: 'center' в demo/golden/matrix.mjs (openingIsoContract, id golden-iso-door/golden-iso-gate-default/golden-iso-gate-flipped) жёстко требует, чтобы середина хорды hinge + closedVector/2 (для двери) или среднее hinge двух листов (для ворот) совпадала с канонической точкой cfg.x*1000, cfg.y*1000 с точностью 1e-5. Это было верно до этой задачи, когда leafBasis центрировал door/gate так же, как окна. После правки src/iso-openings.ts:238-240 (offset = input.face для door/gate) хинг сознательно уходит на выбранную физическую грань стены — контракт обязан был получить то же исключение для door/gate, которое уже получили test/iso-openings.test.mjs, test/isometric-contract.test.mjs и demo/smoke_isometric_contract.mjs (все три обновлены именно под это же изменение в этом же диффе), но demo/golden/run.mjs/matrix.mjs — нет.

Почему это не «просто ещё один different-пиксель, подождёт беты»: different означает, что PNG не совпал с эталоном и решается штатной пересъёмкой (golden:accept --reviewed) перед бетой — так по праву оставлены 11 других isometric-* сцен в этом диффе. error — это исключение внутри самого харнесса капчура: сцену isometric-opening-symbol-parity-dark невозможно даже переснять (golden:capture бросит то же исключение), пока не поправлен assertOpeningSymbolContract/конфиг openingIsoContract. Это заблокирует именно ту процедуру пересъёмки, которую этот же диф просит выполнить перед бетой для AC13, и будет молча висеть до следующей задачи, если её не исправить сейчас — тот же класс риска, что #230/#234 (docs) и #171/#207 (тихий пропуск).

Фикс — того же размера и в том же файле/соседнем конфиге, что и уже сделанные три обновления: разрешить door/gate «центр после проекции» смещённым на face.offset (или явно проверять leaf.hinge === face.selectedStart/selectedEnd как это уже делает test/iso-openings.test.mjs), оставив строгую центровку только для window. Находится строго в скоупе задачи (тот же контракт «дверь/ворота больше не центрированы в 2.5D», который задача сама вводит) — чинится в этом же раунде, отдельный issue не заводится (#202).

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

  • Тени, обводка, порядок граней двери/окна, host-face pivot, collision-pass, labels-exclusion, кэш/детерминизм, документация — см. разбор выше.
  • Trailers, changelog-обязательства, бандл (3 копии), no-new-any, bundle-budget — зелёные.
  • npm test (2719/2719, 1 skipped) и целевые browser-smokes — зелёные, перепроверено лично, не только со слов автора.
  • golden:verify: все 11 расхождений строго ограничены isometric-сценами (тени/дверь/коллизии/stroke — ровно то, что задача меняет); ни одна неизометрическая сцена (160 штук) не сдвинулась — сильное свидетельство отсутствия побочных регрессий за пределами заявленного скоупа.

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

  • Полный smoke-набор (247 из 250 файлов) — вне выборки diff/AC, дорого, предрелизный гейт.
  • Калиброванный performance_smoke/Full Performance на точном SHA — не запускается на обычном пуше (classify-changes.mjs), предрелизный гейт; сделал только некалиброванную ручную сверку на порядок величины, без выводов на её основе.
  • python -m pytest tests_backend — Python не тронут.
  • npm run invariants — геометрическая модель/layout не тронуты (только derived presentation).
  • Пересъёмка/приёмка 11 ожидаемо изменившихся golden-baseline — сознательно оставлена автором на предрелизный этап (PROCESS §8); это не переигрывается мной, но привязана к найденной находке (harness должен сначала научиться снимать isometric-opening-symbol-parity-dark без исключения).

Вердикт

Один Medium в скоупе задачи (сломанный семантический golden-контракт для двери/ворот в iso, воспроизводится, чинится в этой же ветке), High — нет. Все 14 AC подтверждены чтением кода + юнит/contract-тестами + целевыми браузерными смоками + полным прогоном golden (кроме калиброванного perf).

Вердикт: жёлтый.


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

  • Ветка: issue/583-isometric-visual-corrections, коммит e2801e4e8b1b — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
  • Дерево материала: 7640cc7b520c24eadf4a6ea2ddff6b58a3f03178
    git log --all --format='%H %T' | grep 7640cc7b520c
    
  • Тело issue: 5c6f59d62afacaf36715df788207cc8b3df775aab0991091d0d0ae322f9ac844
  • Вердикт конвейера: yellow · High 0