Files
houseplan-card/docs/reviews/CODE-REVIEW-239-r1.md
T
2026-08-22 11:00:12 +00:00

18 KiB
Raw Blame History

CODE-REVIEW-239-r1

  • Issue: #239 — масштаб сетки не должен менять внешний вид плана
  • Этап: код-ревью, заход r1 (первый заход код-ревью; ТЗ уже прошло свои r1/r2 отдельно и здесь не пересматривается)
  • Ветка/SHA: issue/239-grid-scale-invariance @ 0d91c1e18e60bf2fa3adafd6a57bbfb4df3751ba
  • ТЗ: docs/specs/239-grid-scale-invariance.md, ревью ТЗ зелёное (SPEC-REVIEW-239-r2.md)
  • Вердикт: жёлтый · High: 0 · Medium: 1 (в скоупе)

Скоуп

Диапазон origin/dev...HEAD, один продуктовый коммит 0d91c1e (Issue: #239, User-Visible: yes). 35 файлов, из них по классам:

  • A (продукт): src/grid-scale.ts (новый), src/render/opening-symbol.ts, src/houseplan-card.ts, src/styles.ts, src/space-card.ts, src/space-render.ts, src/space-geometry.ts, src/types.ts, src/i18n/{en,ru}.json;
  • B (гейты/инструменты): demo/smoke_grid_scale_invariance.mjs (новый), demo/smoke_space_scale_defaults.mjs (новый), scripts/mutation-gate.mjs (+12 мутантов), test/grid-scale.test.mjs (новый), правки test/canvas.test.mjs, test/opening-symbol.test.mjs, test/isometric-contract.test.mjs, test/open-passage-contract.test.mjs, tsconfig.test.json;
  • C (документация): docs/CHANGELOG.md/.ru.md, docs/USER-GUIDE.md/.ru.md, docs/CANVAS.md, docs/ARCHITECTURE.md, docs/TESTING.md, docs/specs/README.md, docs/specs/239-*.md, docs/reviews/SPEC-REVIEW-239-r{1,2}.md;
  • D (сгенерированное): три копии houseplan-card.js, docs/images/03-space-create.png, docs/images/screenshots.json.

Никаких файлов custom_components/**/*.py — backend вне скоупа, как и заявлено в ТЗ (§5.2, «миграция не нужна»).

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

Гейты

Гейт Команда Результат
typecheck npx tsc --noEmit green
unit npm test 1056/1056 pass, 0 fail, 0 skip (см. примечание ниже)
build + сверка бандлов npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js && cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js green, три копии побайтно идентичны; git status после сборки чист
docs fingerprint node scripts/check-docs.mjs green («7 files, 10 external links») — обязателен, diff трогает src/**
mutation gate node scripts/mutation-gate.mjs --check все 12 новых grid-scale-* мутантов и вся существующая таблица — ok
целевой smoke (AC2/3/4/6/7/8/13/14/15) node demo/smoke_grid_scale_invariance.mjs нестабилен, см. находку M1 ниже — 6 красных из 14 прогонов, всегда на одной и той же проверке
целевой smoke (AC9/10/11/12) node demo/smoke_space_scale_defaults.mjs green, 3/3 прогона подряд
именованные regression smokes (по grep изменённых символов gridVisualScale/gridVisualUnits/op-outline/op-hit/ISO_WALL_HEIGHT) smoke_opening_preview, smoke_opening_measure, smoke_inert_openings, smoke_open_passage, smoke_registryless_opening, smoke_styling_hooks, smoke_pan_any_zoom, smoke_plan_snap_overlay, smoke_free_walls, smoke_decor, smoke_space_card, smoke_isometric_contract, smoke_isometric_live_touch все green, по одному прогону каждый

Не прогонялось и почему: npm run golden:verify, полный набор из 163 браузерных smoke и performance_smoke — по §8/§11.4 это предрелизный гейт, не гейт код-ревью; python -m pytest tests_backend — diff не затрагивает custom_components/**/*.py, гейту нет предмета.

Примечание по unit-тестам: хендофф автора заявляет «1055 pass, 1 штатный skip», в этом прогоне — 1056 pass, 0 skip. Расхождение не влияет на вердикт (итог зелёный в обоих случаях), но фиксирую как замеченное, а не подтверждённое слово в слово.

AC — чем доказан каждый

Сверено с матрицей §13.1 ТЗ; ниже — что именно я проверил (тест либо чтение).

AC Доказательство Проверено
AC1 test/grid-scale.test.mjs unit зелёный, значения проверены вручную (5/1/2.54/10/25, invalid→1)
AC2 smoke_grid_scale_invariance.mjs (flat View) метрики и pixel diff совпадают кроме случайного провала на dark theme, см. M1
AC3 opening unit tests + тот же smoke test/opening-symbol.test.mjs новый тест jambHalf/outlineHalf/hitHalf ×5; смок кликает реальную внешнюю точку hitbox — openingEdgeHitAndActionMatch: true во всех 14 прогонах
AC4 тот же smoke, Plan с маской grid planPixelsMatchWithEquivalentGridHidden, snapPrecisionIsFiveTimesFiner, screenFixedPlanSnapStrokeIsNotDoubleScaled — все true во всех прогонах
AC5 unit negative controls + pair smoke + мутант grid-scale-physical-double-scaled мутант ловится (ok), iconUnit-тест в canvas.test.mjs подтверждает 5× NORM_W при cell_cm:1
AC6 pair smoke Devices/Background devicesCriticalMetricsMatch/PixelsMatch, backgroundPointerCancelIsMutationFree — true во всех прогонах
AC7 pair pixel/DOM houseplan-space-card staticPixelsMatch: true во всех прогонах; space-render.ts/space-card.ts читают тот же gridVisualScale/gridVisualUnits — прочитано в коде
AC8 iso unit (переиспользуемые iso-projection.test.mjs/iso-walls.test.mjs, не менялись — уже параметризованы по высоте) + pair pixel light/dark + isoRemainsLabsOnly все true во всех прогонах; source-regex в isometric-contract.test.mjs подтверждает, что wallHeight/floorEdgeHeight идут через gridVisualUnits
AC9 grid-scale.test.mjs + smoke_space_scale_defaults.mjs metricManualCreateStoresOneCm, imperialManualCreateStoresOneInch — green
AC10 тот же smoke, floors import metricFloorImportUsesOneCm, imperialFloorImportUsesOneInch — green; прочитано в _openNextImport() — использует тот же newSpaceCellCm()
AC11 unit + smoke open→save без правки поля existingFiveUntouchedIsLossless, existingFractionUntouchedIsLossless, missingCellUsesLegacyFiveAndMaterializesFive — green; прочитано: save-путь берёт d.cellCm (canonical), не d.cellCmInput
AC12 smoke, EN/RU × metric/imperial, language rerender metricRussianField, metricLanguageRerenderKeepsCanonical — green
AC13 проверено чтением, не исполнением + мутант grid-scale-visual-factor-constant/-inverted gridVisualScale(5) === 1 — явная ранняя ветка в коде без деления с плавающей точкой; смок подтверждает referenceFactorIsOne: true во всех 14 прогонах
AC14 targeted touch/opening smoke openingEdgeHitAndActionMatch, backgroundPointerCancelIsMutationFree — true во всех прогонах (реальный pointerdown/pointercancel с pointerType: 'touch', сверка _serverCfg до/после)
AC15 проверено чтением, не исполнением diff не добавляет обходов геометрии по всему плану; каждый gridVisualUnits()/gridVisualScale() — O(1) арифметика на уже вычисленных величинах; CSS-переменная считается один раз на stage/style-строку
AC16 check-docs + чтение обоих changelog/guide зелёный гейт; тексты RU/EN проверены построчно, согласованы с §9–10 ТЗ

Находки

Medium (в скоупе задачи, чинится в этом issue)

M1. demo/smoke_grid_scale_invariance.mjs нестабилен на проверке darkViewPixelsMatch — ~43% прогонов красные без изменений в коде.

Воспроизведение: 14 последовательных запусков node demo/smoke_grid_scale_invariance.mjs на неизменном коммите 0d91c1e, без каких-либо правок. Красные — 6 из 14 (#1, #6, #8, #11, #12, #13), все остальные проверки во всех 14 запусках неизменно true. Красная проверка всегда одна и та же — darkViewPixelsMatch, и диагностика идентична между падениями:

"darkViewDiff": { "sameSize": true, "changed": 114, "maxDelta": 41,
                   "meanDelta": 0.016858721655042284, "pixels": 593580 }

Порог в смоке — maxDelta <= 40 (pixelEquivalent(), строка ~375); падение идёт ровно на 1 единицу дельты сверх порога, всегда с одинаковым числом изменившихся пикселей (114 из 593580) и одинаковым meanDelta. Это не похоже на случайный antialiasing-шум (тогда числа плавали бы), а похоже на двух-модовую гонку — какое-то состояние либо успевает осесть в окне settle() (150 мс после 3 requestAnimationFrame), либо нет, и в неосевшем состоянии рендер стабильно отличается на 114 пикселей именно в паре View/dark theme. Ни одна другая пара (light View+static, Plan, Devices, Background, iso light/dark) не дала ни одного красного прогона за то же число попыток — нестабильность локализована именно в переключении темы в режиме View.

Я не свёл её к одной строке кода (проверил явные candidate-транзишны: .op-leaf/.op-arc — накрыты @media (prefers-reduced-motion: reduce) в src/styles.ts:875-877, day-cycle слои — тоже накрыты :255-261, .glow-spot — тоже :691-694; смок уже включает reducedMotion: 'reduce' через launch() и переустанавливает его в каждом setTheme()). Причина может быть глубже в тайминге асинхронного пересчёта устройств (_maybeRebuildDevices()) относительно окна settle(), но я не берусь называть точную строку без дальнейшей отладки автором — это его код и его конвейер таймингов.

Почему это находка, а не просто «иногда так бывает». AC2 и AC13 в матрице доказательств ТЗ (§13.1) называют именно этот смок единственным автоматическим доказательством попиксельной инвариантности View. Хендофф автора заявляет «demo/smoke_grid_scale_invariance.mjs — green» без указания числа прогонов; при вероятности ~43% реальный прогон вполне мог быть тем самым единственным зелёным, а не свидетельством стабильности. Нестабильный гейт на S8-merged/пре-релизе — ровно тот сценарий, который AGENTS.md описывает как причину лишнего цикла ревью (там же зафиксирована цена: пропуск гейта на #230/#234 стоил dev красным docs-job до следующей задачи). Здесь тот же класс риска, но для smoke job.

Это Medium, не High: сама физика инварианта доказана — 13/14 проверок, включая ту же пару в режиме light (со static-card), совпадают стабильно на всех 14 прогонах; проблема в допуске/таймінге смока, а не в продукте. Чинится в этом же issue (владелец 2026-08-19, #202): например, увеличить settle() перед capture() при смене темы, либо обосновать и слегка ослабить maxDelta, либо явно дождаться состояния, гонка с которым и даёт эти 114 пикселей.

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

  • Классификация размеров (Physical/Screen/Plan-relative/Visual unit/Grid, §6 ТЗ) выдержана в коде: wallCmToUnits-путь, decor/furniture, Glow, штриховка #230, snap-узлы 5/10 см — не проходят через gridVisualScale/gridVisualUnits (подтверждено grep + мутант grid-scale-physical-double-scaled, ловится).
  • gridVisualScale(5) === 1 — точное совпадение без накопления погрешности плавающей точки (явная ветка if (value === GRID_VISUAL_REFERENCE_CELL_CM) return 1).
  • Общий CSS-transform на весь SVG отсутствует; масштабирование — точечное, через calc(<const>px * var(--hp-cell-visual-scale, 1)) в styles.ts и через gridVisualUnits() в TS-геометрии — соответствует запрету §6/§7.2 ТЗ.
  • --hp-cell-visual-scale выставляется и в full card (houseplan-card.ts), и в static card (space-render.ts), с тем же helper'ом; space-card.ts дублирует только ту одну CSS-правку (wallbody stroke), которая не приходит через общий cardStyles.
  • Скрытая изометрия: wallHeight/floorEdgeHeight вычисляются один раз на сцену и передаются как параметры в уже существовавшие (не изменённые в этом диффе) buildIsoWallGeometry/buildIsoOpeningBasis/buildIsoFloorGeometry/ projectedFrame; попадают в isoGeometryFingerprint — тёплый remount не вернёт геометрию другого cell_cm (проверено чтением сигнатур и isometric-contract.test.mjs).
  • Default/compat: manual create и floors-import используют один newSpaceCellCm(this._imperial); legacy fallback для отсутствующего/ некорректного cell_cm остаётся 5 и во frontend (houseplan-card.ts, space-geometry.ts), и не тронут в backend/optimizer (вне диапазона диффа).
  • Lossless imperial edit: сохранение читает канонический d.cellCm, а не проекцию d.cellCmInput — round-trip через дюймы невозможен без реального input-события (мутант grid-scale-imperial-roundtrip-drift ловится).
  • i18n: новый ключ space.scale_unit_imperial заведён в EN и RU, подпись поля переключается по this._imperial; оба changelog и оба user-guide правлены в том же коммите, трейлеры (Issue: #239, User-Visible: yes) на месте.
  • Три поставляемых бандла побайтно идентичны после локальной пересборки; check-docs зелёный, значит скриншот-отпечаток учтён.
  • Mutation gate: все 12 новых мутантов grid-scale-* пойманы существующими guard'ами (unit-тест либо целевой smoke) — правило «тест умеет падать» выполнено для покрытых ими путей.

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

  • npm run golden:verify, полный набор 163 browser-smoke, performance_smoke — по процессу это предрелизный гейт, не гейт код-ревью, и в диффе нет сигналов, требующих его досрочного запуска.
  • Backend (python -m pytest tests_backend) — diff не касается custom_components/**/*.py.
  • Не устанавливал точную причину гонки в M1 (не отследил конкретную строку/ промис, ответственные за таймінг) — это для автора, у которого есть контекст реализации _maybeRebuildDevices()/settle().
  • Не проверял ручное поведение в реальном браузере вне demo-харнесса (Home Assistant, реальные темы) — вне доступного окружения ревью.