18 KiB
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, реальные темы) — вне доступного окружения ревью.