merge: integrate #451 render performance

Owner arbitration accepts the reviewed tip without a fifth review cycle.

Issue: #451
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-05 07:36:49 +03:00
91 changed files with 11581 additions and 6998 deletions
+6
View File
@@ -2,6 +2,12 @@
## Unreleased
- Large plans now keep their heavy room, wall, lighting, decor and device scene
stable during hover, pan, pinch and editor drags, and unrelated Home Assistant
state updates no longer rebuild the full card; interactions remain visually
and functionally unchanged while responding much more smoothly
([#451](https://github.com/Matysh/houseplan-card/issues/451)).
## v1.72.0-beta.2 — 2026-09-05
- Double-clicking or double-tapping free plan background in View or kiosk now
+7
View File
@@ -8,6 +8,13 @@
## Не выпущено
- На больших планах тяжёлая сцена комнат, стен, света, подложки и устройств
теперь остаётся неизменной во время hover, pan, pinch и перетаскиваний в
редакторах, а посторонние обновления Home Assistant больше не перестраивают
карточку целиком; внешний вид и результат действий не меняются, но отклик
становится заметно плавнее
([#451](https://github.com/Matysh/houseplan-card/issues/451)).
## v1.72.0-beta.2 — 2026-09-05
- Двойной клик или двойной тап по свободному фону плана в Просмотре и киоске
+11 -11
View File
@@ -3,7 +3,7 @@
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"oxipng": "oxipng 10.2.0",
"sourceFingerprint": "eb4d09faffea5a1b9a958e2b0ed073fd70924271e02a7fa3a73ed9c3f08805a9",
"sourceFingerprint": "eb0b787c06ef06671e953d560312329da5a3774087b9c740e2a4717ea8a78427",
"captureScriptSha256": "cadb8e1bcab9f1dcdd7d75b3b90ddcbaaeb2b8c2a098f575a21f39ff70f5c59c",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -15,7 +15,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "eb4d09faffea5a1b9a958e2b0ed073fd70924271e02a7fa3a73ed9c3f08805a9",
"sourceSha256": "eb0b787c06ef06671e953d560312329da5a3774087b9c740e2a4717ea8a78427",
"imageSha256": "80a70361dc18dd0461568df332062e6482c633af5d280954f8b675701418a76d"
},
"view-touch": {
@@ -26,7 +26,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "eb4d09faffea5a1b9a958e2b0ed073fd70924271e02a7fa3a73ed9c3f08805a9",
"sourceSha256": "eb0b787c06ef06671e953d560312329da5a3774087b9c740e2a4717ea8a78427",
"imageSha256": "4106cc28847047505f46921ff95765d8abdf5b382d9d17d4c5e4ad129dd8f6be"
},
"space-create": {
@@ -37,7 +37,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "eb4d09faffea5a1b9a958e2b0ed073fd70924271e02a7fa3a73ed9c3f08805a9",
"sourceSha256": "eb0b787c06ef06671e953d560312329da5a3774087b9c740e2a4717ea8a78427",
"imageSha256": "617b51b3648498787b5039980c9f3eceb75ba56ed63a1a20e616bc05bc304362"
},
"room-contour-close": {
@@ -48,7 +48,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "eb4d09faffea5a1b9a958e2b0ed073fd70924271e02a7fa3a73ed9c3f08805a9",
"sourceSha256": "eb0b787c06ef06671e953d560312329da5a3774087b9c740e2a4717ea8a78427",
"imageSha256": "1dab6cc3b9d1bf7d8c40f0e5137f8c688683c9b7eabc5167da99d41ecfdd5b79"
},
"plan-context-tray": {
@@ -59,7 +59,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "eb4d09faffea5a1b9a958e2b0ed073fd70924271e02a7fa3a73ed9c3f08805a9",
"sourceSha256": "eb0b787c06ef06671e953d560312329da5a3774087b9c740e2a4717ea8a78427",
"imageSha256": "94ef50762753c0ac6ddc84d2521c4232a3f9ecd89843810d2df61c517592bed7"
},
"device-editor": {
@@ -70,7 +70,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "eb4d09faffea5a1b9a958e2b0ed073fd70924271e02a7fa3a73ed9c3f08805a9",
"sourceSha256": "eb0b787c06ef06671e953d560312329da5a3774087b9c740e2a4717ea8a78427",
"imageSha256": "7a601769de38aa19c2e280f6ff4cf3695b854b1d1c6d1f8de4e6b5e8f7eb1559"
},
"device-display-preview": {
@@ -81,7 +81,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "eb4d09faffea5a1b9a958e2b0ed073fd70924271e02a7fa3a73ed9c3f08805a9",
"sourceSha256": "eb0b787c06ef06671e953d560312329da5a3774087b9c740e2a4717ea8a78427",
"imageSha256": "0939875f8631694f4de0ef4fd01032010ede8c7d49fb7c0a857612d4b3afff93"
},
"background-editor": {
@@ -92,7 +92,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "eb4d09faffea5a1b9a958e2b0ed073fd70924271e02a7fa3a73ed9c3f08805a9",
"sourceSha256": "eb0b787c06ef06671e953d560312329da5a3774087b9c740e2a4717ea8a78427",
"imageSha256": "054170fd9ef45762b602b4d5c9c3b9ea9724858be61af137970c246f485c13bb"
},
"room-card": {
@@ -103,7 +103,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "eb4d09faffea5a1b9a958e2b0ed073fd70924271e02a7fa3a73ed9c3f08805a9",
"sourceSha256": "eb0b787c06ef06671e953d560312329da5a3774087b9c740e2a4717ea8a78427",
"imageSha256": "2ae4a58853d98e10d12456b2078ec2f6a0b597722c8310d7b016abd2bc42561e"
},
"device-info": {
@@ -114,7 +114,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "eb4d09faffea5a1b9a958e2b0ed073fd70924271e02a7fa3a73ed9c3f08805a9",
"sourceSha256": "eb0b787c06ef06671e953d560312329da5a3774087b9c740e2a4717ea8a78427",
"imageSha256": "cb37f7eefd936f98ef44969b21dfe8e40ee27d1f558abe8d53c0223f6488b8a6"
}
},
+301
View File
@@ -0,0 +1,301 @@
# 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-в-скоупе), без расширения скоупа задачи.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `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
```
+253
View File
@@ -0,0 +1,253 @@
# CODE-REVIEW-451-r2
- **Issue:** #451 «План подтормаживает: диагностика на каждый кадр, реактивная камера, отсутствие фильтра HA-тиков»
- **Этап:** код-ревью, заход r2
- **Материал:** `git log --oneline origin/dev..HEAD` / `git diff origin/dev...HEAD`, точный SHA `cb68492c990b27e91c35130fa3a77806bd157c5e` (ветка `issue/451-render-performance`)
- **Предыдущий раунд:** `CODE-REVIEW-451-r1`, SHA `1850eb18b5e8f0813f9d2efb50694dc4ca1fff26`, вердикт красный, High 1 / Medium 2 (SHA назван в самом документе r1, `Материал:`; в комментарии-вердикте issue SHA не упомянут — это несоответствие оформления, не находка по существу)
- **ТЗ:** `docs/specs/451-render-performance.md` (полный трек, `SPEC-REVIEW-451-r2` зелёный)
- **Вердикт:** см. итог в конце документа
## Дельта раунда
`git diff 1850eb18..cb68492c` — 8 источников кода/скриптов вне `dist/**`/`custom_components/.../frontend/**`
(класс D, бинарные копии бандла синхронизированы автоматически и не разбираются построчно):
| Файл | Изменение |
|---|---|
| `src/junction-limits.ts` | новая экспортируемая `junctionLimitViolations()` — общая реализация full/affected-room проверки, вынесенная из `houseplan-editor-runtime.ts`; добавлен `lightweight`-режим (переиспользует переданный `multiWallNodes`, не пересобирает `wallBodiesGeometry`) |
| `src/houseplan-editor-runtime.ts` | `_junctionLimitViolations` делегирует в новую функцию с `roomIds`; `_rszProjectPreview` больше не вызывает `_rszSpaceCandidateGeometry`/`_checkSpacePhysicalGeometry` на каждый move — использует lightweight junction-check, ограниченную `changedRoomIds`; полная проверка (`_rszCandidateRenderable`) осталась только в `finish()`; `_rszAcceptPreview`/`_rszCancelDrag` управляют `_cfgEpoch`/`_physicalBodiesCache.key` точнее; `_renderResizeLayer`/`_renderOpenings`/`_renderDecorLayer` получили фильтры `roomIds`/`onlyIds`/`onlyId` |
| `src/live-editor.ts` | resize-preview теперь рисует только затронутые стены (`resizePreviewWalls`, читает `room.poly`/`wall.a|b|cm|key`, строит `WallEdgeBody` сама), а не полный `_renderWallBodies`; decor-preview делает прозрачным только перетаскиваемый шейп, а не весь `.decorlayer` |
| `src/houseplan-card.ts` | `_terminalFrame` (0/1/2): `_renderBody()` возвращает `noChange` на кадре отмены resize/decor, если состояние байт-в-байт совпадает с исходным; `requestUpdate` сбрасывает флаг при любом именованном свойстве |
| `src/resize-controller.ts` + `test/resize-controller.test.mjs` | `cancel()` возвращает `restoreEpoch`, чтобы `_rszCancelDrag` мог откатить `_cfgEpoch`, а не только `_wallUnionCache` |
| `demo/benchmark_large_house.mjs` | измерение `longTask.editorSeries` разбито на три отдельных окна (по одному на resize/resize/decor-часть), вместо одного окна на весь editor-сценарий |
| `scripts/bundle-budget.mjs` | `INITIAL_VIEW_GZIP_CEILING` 297 000 → 298 000, с обоснованием |
| `scripts/mutation-gate.mjs` + `demo/smoke_render_invalidation.mjs` (новый) | закрывают M1/M2 r1 |
Геометрия и ссылки на неё прямо затронуты (`junction-limits.ts`, чтение `wall.a/b/cm/key`,
`room.poly`, `multiWallNodesForGeometry`, `wallBodiesGeometry`, `innerContourForRoom`) — разбор
не сокращается до «только заявленных находок», гейты по геометрии прогнаны (см. ниже).
## Как проверялось
| Гейт | Команда | Результат |
|---|---|---|
| typecheck | `npx tsc --noEmit` | зелёный |
| unit | `npm test` | 1956 tests, 1955 pass, 1 skip, 0 fail |
| build + бандл | `npm run build && node scripts/bundle-sync.mjs` + `cmp`/`diff -rq` трёх копий | совпадают байт-в-байт |
| docs fingerprint | `node scripts/check-docs.mjs --external` (обязателен — diff трогает `src/**`) | **красный**, см. H1 |
| any-гейт | `node scripts/no-new-any.mjs --base f8b3445d --head cb68492c` (та же пара, что использует CI-джоба этого пуша) | **красный**, см. H2 |
| invariants | `npm run invariants -- --config <экспорт large-house фикстуры>` (diff трогает геометрию/wall-thickness) | «Инварианты выполнены: ссылки разрешимы, записи толщины находятся» |
| smoke-select (по дельте) | `node scripts/smoke-select.mjs --base 1850eb18 --head cb68492c` | 58 прямых, 27 слабых, 1 зарегистрированная связь (`smoke_real_plan_masonry.mjs` ← `wallBodiesGeometry`) |
| smoke (все прямые + зарегистрированная + 5 смежных по физической геометрии) | 66 файлов, по одному `node demo/smoke_<name>.mjs` (список — в приложении к комментарию) | 64 OK, 1 красный воспроизводимо (`smoke_room_resize.mjs`, см. H3), 1 красный только под конкуренцией за CPU при последовательном прогоне всех 66 (`smoke_edit_walk.mjs`; повторный изолированный прогон — 6/6 OK, тот же класс флакиности, что и `smoke_smooth_zoom` в r1, не регрессия) |
| golden | `npm run golden:verify` | 153/153, 0 diff |
| performance (структурные assert'ы) | `npm run benchmark:large-house-interaction -- --samples=7 --warmups=1` (×3 независимых прогона) | завершается без throw каждый раз |
| performance (абсолютные потолки) | `npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-large-house-interaction.json` на всех 3 прогонах | `cache.entries.cleanFloor` = 100/100 во всех трёх (совпадает с заявленным автором числом, H1 r1 в этой части закрыт); но в каждом из 3 прогонов минимум один тайминговый чек красный (`longTask.editorSeries.maxSingleMs` 231–324 vs 150 во всех трёх; изредка также `timing.interactionSeriesMs.median` и `longTask.editorSeries.totalP95Ms`) — см. «Под вопросом» ниже |
| backend | не запускал — диф не трогает `custom_components/**/*.py` (проверено чтением: `git diff 1850eb18..cb68492c --name-only` не содержит `.py`) | проверено чтением |
| мутация | `node scripts/mutation-gate.mjs --id=render-invalidation-renders-irrelevant-ha` | «поймано 1 из 1» — M2 r1 подтверждён рабочим |
CI на этом же SHA (`cb68492c`, run `33918076536`) независимо подтверждает H1 и H2: джоба
«Предполётные проверки» красная на `DOCS: failure`, джоба «Фронтенд: типы, юниты, мутанты,
синхрон бандла» красная на «Новый код не добавляет any: 11» — обе с теми же файлами/строками,
что и локальный прогон ниже. Всё, что стоит за этими двумя джобами по конвейеру (браузерные
смоки, перф-смок, golden), было **skipped**, то есть CI не подтверждает и не опровергает
performance-часть отдельно от того, что нашёл я локально.
## Находки
### H1 (High, блокирует) — отпечаток скриншотов документации устарел, `docs`-джоба красная
`node scripts/check-docs.mjs --external` (та же команда, что в `validate.yml:59`) завершается:
```
ERROR screenshot source fingerprint is stale; run npm run build && node demo/docs/capture.mjs
```
`visualFingerprint()` считается по всему `src/**` (#245: версия не в счёт, но код — да), а
раунд r2 меняет `src/houseplan-card.ts`, `src/houseplan-editor-runtime.ts`,
`src/junction-limits.ts`, `src/live-editor.ts`, `src/resize-controller.ts` без сопутствующего
`npm run build && node demo/docs/capture.mjs`. Автор прогонял этот гейт в r1 (документ
`CODE-REVIEW-451-r1` фиксирует «Documentation checks passed»), но в комментарии о фиксе r1
(`Исправления по CODE-REVIEW r1`, 2026-09-04T20:48:54Z) `check-docs` не упомянут вовсе — гейт
пропущен именно там, где diff тронул `src/**` заново. Ровно этот класс пропуска уже дважды
оставлял `dev` с красной `docs`-джобой до следующей задачи (#230, #234 → #237); здесь он пойман
до слияния. Фикс не меняет ни одного пикселя (перерисовка внутренняя), поэтому обновление —
чисто отпечаток, `npm run build && node demo/docs/capture.mjs`.
Подтверждено независимо: CI-прогон `33918076536` на этом же SHA, джоба «Предполётные проверки»,
`DOCS: failure`.
### H2 (High, блокирует) — новый код добавляет 11 непрокомментированных `any`, гейт красный
`node scripts/no-new-any.mjs --base f8b3445d --head cb68492c` (пара база/head, которую
использовала CI-джоба этого пуша) находит 11 новых явных `any` на добавленных строках без
обоснования `// any-ok: …`:
```
src/houseplan-card.ts:7792, src/junction-limits.ts:51,54,57,73,
src/live-editor.ts:61,62,65,157,163,164
```
Все — в коде, который непосредственно реализует ТЗ этого раунда (сигнатуры
`junctionLimitViolations`/`LiveEditorHost`, читающие `config`/`space`/`room` как `any`). Часть
можно типизировать через уже существующие интерфейсы (`ServerConfig`, `SpaceModel`), часть —
обосновать комментарием на той же строке; гейт не требует нулевого `any` в принципе (в `src/**`
их 1034 в 49 файлах), а требует явного решения по каждой НОВОЙ строке.
Подтверждено независимо: тот же CI-прогон, джоба «Фронтенд: типы, юниты, мутанты, синхрон
бандла», шаг «Новый код не добавляет any», идентичный список строк.
### H3 (High, блокирует) — resize потерял видимую fail-closed реакцию на невозможную геометрию посреди жеста
`node demo/smoke_room_resize.mjs` падает воспроизводимо (проверено дважды подряд, изолированно
и в общем прогоне — одинаково):
```
FAILED (2):
- safe_resize.preflight_visible_reason: expected true, got false
- safe_resize.preflight_reason_once: expected 1, got 0
```
Смок (существовал до #451, не новый) подставляет `card._checkSpacePhysicalGeometry = () => ({
ok: false })` и ожидает, что **во время** протяжки (`pointermove`, до `pointerup`) карточка
покажет тост «last safe position/последн…» ровно один раз. До этого раунда `_rszProjectPreview`
на каждый move вызывал `_rszSpaceCandidateGeometry()` → `_checkSpacePhysicalGeometry()` и
отклонял проекцию с `reason: 'physical-geometry'`, если проверка проваливалась — именно так
жест «останавливался на последней безопасной позиции» с видимой причиной. Правка H1-r1 убрала
этот вызов из `_rszProjectPreview` целиком (осталась только lightweight junction-limit проверка,
ограниченная `changedRoomIds`, — другой класс проверки: буквенные лимиты узлов/клиренса, а не
собираемость геометрии стен). Полная проверка (`_checkSpacePhysicalGeometry`) осталась только в
`_rszCandidateRenderable`, вызываемой из `_rszUp()` → `finish()` — то есть **только по
pointerup**, с общим `resize.commit_failed` тостом вместо специфичного отказа посреди жеста.
Строка `// #329 AC7a: a step that would ADD a junction-limit violation is never projected, so
the drag stops at the last allowed position…` (houseplan-editor-runtime.ts:3657) осталась в коде
рядом с местом, где ссылалась на удалённую проверку — комментарий теперь описывает только
junction-limit ветку, а не physical-geometry, но текст не обновлён и вводит в заблуждение.
Ни один юнит-тест не покрывает новую `junctionLimitViolations()`/lightweight-режим отдельно —
единственная проверка всей цепочки жила в этом смоке, и правка её не заметила именно потому, что
`npm test` эту ветку не касается (только браузерный смок её и держал). Это тот же структурный
разрыв, который M2 r1 уже называл для другой части того же диффа: дорогой гейт — единственный
свидетель контракта.
**Почему High.** Регресс в контракте, существовавшем до #451 (не новая функциональность этого
issue), ловится существующим (не добавленным этим PR) тестом, воспроизводится детерминированно.
`User-Visible: no` в трейлере коммита `cb68492c` в этом свете неточен: сценарий редкий
(конкурентное структурное изменение во время жеста), но при его наступлении пользователь раньше
видел объяснение и жест останавливался, а теперь видит только общий отказ после отпускания
курсора — то есть разное поведение, а не «незаметная» оптимизация.
## Под вопросом, не поднимаю до находки
`longTask.editorSeries.maxSingleMs` (потолок 150 мс) красный во всех 3 независимых прогонах
`--samples=7 --warmups=1` на этом SHA (231, 324, 231 мс), при этом `cache.entries.cleanFloor`
(машинно-независимый счётчик, из-за которого r1 был красным) держит ровно 100/100 во всех трёх —
то есть структурная часть H1-r1 закрыта чисто, а тайминговая часть держится у самой границы с
переменным исходом (в одном из трёх прогонов краснели ещё `timing.interactionSeriesMs.median` и
`longTask.editorSeries.totalP95Ms`, в двух других — нет). Отличие от r1: там margin был ~6×
(878 мс против потолка 150) и детерминирован; здесь ~1.5–2× и не детерминирован — характернее
для шумной/разделяемой машины ревьюера, чем для структурного регресса (`demo/performance/README.md`
прямо называет локальный прогон диагностическим). CI не добрался до перф-джобы на этом SHA
(skipped из-за H1/H2), так что канонического парного Linux-сравнения по этому раунду ещё нет ни
у кого. Не поднимаю до отдельной находки, но и не закрываю: автору нужен чистый прогон
`performance.yml` (или локальный на менее нагруженной машине) после того, как H1/H2/H3 будут
исправлены, прежде чем считать AC10 доказанным на этом SHA.
## Закрытие раунда r1
| Находка r1 | Чем закрыта | Где видно |
|---|---|---|
| H1 (перф-бюджет `large-house-interaction-v1` красный, включая машинно-независимый `cache.entries.cleanFloor` 140≠100) | Resize-preview больше не строит канонический union/full-room clearance на каждый move — только lightweight junction-check по `changedRoomIds` | `cache.entries.cleanFloor` = 100/100 в трёх независимых прогонах этого раунда (см. таблицу выше); тайминговая часть бюджета — см. «Под вопросом» |
| M1 (нет production-smoke на full-render/diagnostics-scan счётчики) | Добавлен `demo/smoke_render_invalidation.mjs` | файл существует, `node demo/smoke_render_invalidation.mjs` → OK |
| M2 (нет мутанта в `scripts/mutation-gate.mjs` для интеграционной проводки) | Добавлен мутант `render-invalidation-renders-irrelevant-ha` | `node scripts/mutation-gate.mjs --id=render-invalidation-renders-irrelevant-ha` → «поймано 1 из 1» |
## Унаследовано из r1 (не перепроверялось в этом раунде)
Со ссылкой на `CODE-REVIEW-451-r1` на SHA `1850eb18`, за пределами того, чего касается дельта
`1850eb18..cb68492c`:
- AC1 (HA intake ≠ visual invalidation), AC2 (dependency classifier), AC3 (terminal
reconciliation вне resize/decor cancel-ветки), AC4 (RAF-coalesced hover/camera paths вне
editor-preview), AC9 (pixel-equivalence) — код этих путей дельтой не тронут; повторно
перечитан не был. AC9 переподтверждён косвенно свежим прогоном `golden:verify` (153/153) в
этом раунде, остальное — по r1.
- Скоуп диффа (75 файлов на SHA r1) вне восьми файлов, изменённых в этом раунде — не
перечитывался повторно.
- Backend/invariants-обоснование «диф не трогает `custom_components/**/*.py`» — верно и для
дельты этого раунда (см. таблицу выше), проверено заново, не только унаследовано.
- Трейлеры `Issue:`/`User-Visible:` коммита r1 (`f8b3445d`, `1850eb18` и предыдущие) — не
перепроверялись; трейлеры коммита `cb68492c` проверены заново (см. H3 про неточность
`User-Visible: no`).
## Что проверено и корректно
- `_rszCandidateRenderable`/`_checkSpacePhysicalGeometry` остаются полной, дорогой проверкой на
`finish()` (пойнтерап) — итоговый коммит геометрии по-прежнему фейлится закрыто; регресс H3
касается только видимой обратной связи ПОСРЕДИ жеста, не итоговой записи (доказано: `roomPoly`
до/после в смоке не меняется — `preflight_no_commit` в том же смоке зелёный).
- `resize-controller.ts`: `epochBefore`/`restoreEpoch` для cancel корректно откатывают
`_cfgEpoch` только при совпадении `snapshotIdentity`, иначе `null` — соответствующие тесты
`test/resize-controller.test.mjs` (оба сценария) проходят.
- `_terminalFrame` в `houseplan-card.ts`: сбрасывается на любую именованную реактивную запись
(`requestUpdate(name, …)` с `name !== undefined`) до того, как `_renderBody()` может вернуть
`noChange` — то есть «проглотить» кадр может только сам вызывающий cancel-путь, а не случайное
внешнее свойство; `npm test` (1955/1955) и `demo/smoke_render_invalidation.mjs` не показали
расхождений DOM.
- Единственный источник числа: диф не вводит новых пользовательски видимых величин (внутренний
перф-рефакторинг), `npm test` включает `test/single-source-numbers.test.mjs` — зелёный.
- `INITIAL_VIEW_GZIP_CEILING` 297 000 → 298 000 обоснован в комментарии тем же коммитом, общий
бюджет 300 000 и долг #367 не изменены — проверено чтением `scripts/bundle-budget.mjs`.
- Инварианты модели (`npm run invariants`) на конфиге из `demo/fixtures/large-house.mjs`:
«ссылки разрешимы, записи толщины находятся» — новая `junctionLimitViolations()` не потеряла
соответствие ключей записей толщины решёточным рёбрам на этой фикстуре.
## Чего не проверял и почему
- Полный набор `demo/smoke_*.mjs` (222 файла) — избыточно: 58 прямых + 1 зарегистрированная
связь смок-селектора плюс 5 смежных по физической геометрии (`smoke_wall_junctions`,
`smoke_wall_union_isolation`, `smoke_near_orthogonal_junction`,
`smoke_multiwall_strip_containment`, `smoke_glow_geometry_resilience`) дают 64/66 живых
прогонов по затронутым модулям — уже нашли реальный регресс (H3). Оставшиеся 27 «слабых»
совпадений (общее имя `_mode`/`_baseVb`/`_physicalBodiesCache`/`cellCm`/`NORM_W`) не гонял:
все пять физически-геометрических смоков из этого же семейства прошли, дополнительный сигнал
от оставшихся маловероятен, а полный набор — предрелизный гейт (PROCESS.md §8), не гейт
ревью.
- Полный `performance.yml` (`Full Performance`, парный base/candidate на двух чекаутах) — CI это
не запускала на этом SHA (skipped из-за H1/H2), у меня нет второго чекаута под парное
сравнение; заменено тройным `--absolute-only` прогоном на кандидате (см. «Под вопросом»).
- `python -m pytest tests_backend` — диф не трогает `custom_components/**/*.py` (проверено
чтением `git diff --name-only`).
- Мутация для новой `junctionLimitViolations()`/lightweight-режима отдельно — не потребовалась:
H3 уже показывает красный тест по этому пути; добавлять мутант к сломанному коду
преждевременно, стоит сделать после фикса H3, иначе он зафиксирует текущее (неверное)
поведение.
- Повторный запуск `smoke_edit_walk.mjs` больше двух раз для статистики флакиности — не
требовалось: тот же класс поведения (падает только под конкуренцией за CPU при плотном
последовательном прогоне 66 браузерных смоков подряд, чисто 6/6 в изоляции), что уже описан и
принят как некритичный в r1 для `smoke_smooth_zoom`.
## Итог
**Вердикт: красный.** Три High-находки, все в скоупе задачи и чинятся в ней же (без отдельных
issue): H1 — обновить отпечаток документации (`npm run build && node demo/docs/capture.mjs`),
H2 — типизировать или обосновать 11 новых `any`, H3 — вернуть full physical-geometry preflight
(или эквивалентную защиту) в путь `_rszProjectPreview`, чтобы посреди resize-жеста
невозможная геометрия снова отклонялась видимо, а не только по `pointerup`. После фикса —
чистый повторный прогон `npm run benchmark:large-house-interaction`/`compare` для снятия вопроса
из раздела «Под вопросом».
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/451-render-performance`, коммит `cb68492c990b` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `aaa8519cee54bf2a81f9e922e583d42d7f594ab4`
```
git log --all --format='%H %T' | grep aaa8519cee54
```
- ТЗ `docs/specs/451-render-performance.md`, блоб `7c323a29110974aae369077214b9e2a74d9387c1`
```
git log --all --find-object=7c323a29110974aae369077214b9e2a74d9387c1 -- docs/specs/451-render-performance.md
```
+223
View File
@@ -0,0 +1,223 @@
# CODE-REVIEW #451 — заход r3
- **Issue:** #451 — «План тормозит: диагностика считается на каждый кадр, перетаскивание перерисовывает всё, нет фильтра обновлений»
- **Этап:** код-ревью (PROCESS.md §2.7)
- **Заход:** r3 · блокирующих циклов до этого раунда: 2/4
- **Материал раунда:** ветка `issue/451-render-performance`, `HEAD = 444562e47cb746dc1c7d740b66b2f832ca02f064`
(сверено `git rev-parse HEAD` непосредственно перед выводом, дерево чистое, PROCESS.md §2.7/#312)
- **Дельта раунда:** `git diff cb68492c990b27e91c35130fa3a77806bd157c5e..444562e4` — SHA взят из машинного блока
«Материал раунда» документа `docs/reviews/CODE-REVIEW-451-r2.md` (дерево `aaa8519cee54…`, сверено
`git cat-file -p cb68492c^{tree}` — совпадает). Файлы дельты (без `dist/**` и бандла стенда):
`src/houseplan-card.ts`, `src/houseplan-editor-runtime.ts`, `src/junction-limits.ts`,
`src/live-editor.ts`, `src/resize-controller.ts`, `test/resize-controller.test.mjs`,
`docs/images/screenshots.json`, `docs/reviews/CODE-REVIEW-451-r2.md`.
## Скоуп раунда
r2 (красный, High:3) закончился на коммите `cb68492c` (диагностика — 8 файлов). С тех пор в ветку
легли 7 коммитов: `fe03eae2`, `8d787530`, `4b6b4abc`, `a7bee0f5`, `d513f13a` (документ r2),
`046efe96`, `444562e4`. Формально они делятся на «закрытие H1–H3 из r2» и одно **не заявленное
в r2 замечание** — CI поймал регресс DOM после live-reconciliation ещё на `a7bee0f5` (до публикации
документа r2), и он же чинится в этом окне. Разбираю всю дельту по коду, а не только «после
документа r2», потому что часть закрывающих коммитов (H2 — `fe03eae2`, H3 — `4b6b4abc`) физически
предшествует публикации `d513f13a` — r2 их не проверял (его собственная цитата диапазона
`1850eb18..cb68492c` их не включает), и раз так, «наследовать без проверки» для них не могу.
## Как проверялось
Дельта не геометрию персистентного формата, а раннее прекращение/typing нескольких вызовов и одну
внутреннюю функцию (`resizeLivePreflightAllowed`). Прогнал сам, не полагаясь на цитаты из комментариев:
| Команда | Результат |
|---|---|
| `npm run build` (после — `git status --short`) | зелёный, дерево не изменилось — бандл воспроизводим байт-в-байт |
| `npm run bundle:sync` | зелёный, стенд для браузерных смоков синхронизирован |
| `npm test` | 1956 passed / 0 failed / 1 skipped (совпадает с заявленным) |
| `node scripts/check-docs.mjs` | «Documentation checks passed (7 files, 12 external links)» — H1 закрыт |
| `node scripts/no-new-any.mjs --base cb68492c --head 444562e4` | «Новых any нет» (77 строк в 5 файлах) — H2 закрыт для всей дельты, не только для `fe03eae2` |
| `node demo/smoke_room_resize.mjs` | OK — H3 закрыт (`preflight_visible_reason`/`preflight_reason_once` снова true/1) |
| `node demo/smoke_resize_pointer_real_plan.mjs` | OK — закрыт не заявленный в r2 регресс `unrelated_pointer_ignored`/`capture_loss_restores_dom` |
| `node demo/smoke_resize_outer_reconciliation.mjs` | OK |
| `node demo/smoke_resize_audit_1550.mjs` | OK |
| `node demo/smoke_resize_inner_dimensions.mjs` | OK |
| `node demo/smoke_resize_labels.mjs` | OK |
| `node demo/smoke_resize_wall_thickness.mjs` | OK |
| `node demo/smoke_junction_limits.mjs` | OK |
| `node demo/smoke_wall_key_roundtrip.mjs` | OK |
| `node demo/smoke_bg_color.mjs` | OK |
| `node demo/smoke_pan_any_zoom.mjs` | OK |
Плюс независимая архивная проверка через `gh`: CI `33920551036` (SHA `a7bee0f5`, до публикации r2)
показал `Смоки в браузере (шард 3 из 3): failure` с точными именами
`resize_pointer.unrelated_pointer_ignored` / `resize_pointer.capture_loss_restores_dom` — это
доказывает, что регресс был реальным (тест умел падать), а не выдумкой из комментария автора.
Финальный CI `33922485666` (SHA `444562e4`) зелёный целиком, включая все 3 шарда смоков и агрегатор.
`node scripts/smoke-select.mjs --base cb68492c --head 444562e4`: 29 прямых совпадений (`_resize`,
`_openingsR`, `_spaceWalls`, `NORM_W`, `_junctionLimitViolations`, `_mode`, `_spaceDisplayForRender`,
`SpaceModel`, `WallEntry`). Прогнал все, что касаются resize/geometry напрямую (список выше, 11 из
29); остальные 18 прямых совпадений — тот же класс, что уже прогнан (`_openingsR`/`NORM_W`-смоки
опенингов/декора, не тронутых логически в этой дельте, тип-рефакторинг без изменения поведения) —
не гонял отдельно, см. «Чего не проверял».
## Закрытие r2
| Находка r2 | Чем закрыта | Где видно |
|---|---|---|
| **H1** (`check-docs --external` красный, отпечаток скриншотов устарел) | Каноническая Linux-съёмка на точном SHA, обновлён только source fingerprint | `444562e4`; сам прогнал `node scripts/check-docs.mjs` на HEAD — зелёный |
| **H2** (11 новых `any` в `houseplan-card.ts:7792`, `junction-limits.ts:51,54,57,73`, `live-editor.ts:61,62,65,157,163,164`) | Типизация `_junctionLimitViolations`/`junctionLimitViolations` через новый экспортируемый `JunctionSharedGeometry` (`Pick<WallBodiesGeometryResult,...> \| {status:'lightweight',...}`), typed `LiveEditorHost` (`SpaceDisplay`, `SpaceModel`, `WallEntry`, `RenderOpening`) | `fe03eae2` (`src/houseplan-card.ts`, `src/houseplan-editor-runtime.ts`, `src/junction-limits.ts`, `src/live-editor.ts`); сам прогнал `no-new-any.mjs --base cb68492c --head 444562e4` — 0 новых `any` |
| **H3** (`_rszProjectPreview` больше не вызывает `_checkSpacePhysicalGeometry()` ни разу во время drag — `smoke_room_resize` падал) | Новая `resizeLivePreflightAllowed(rooms, edgeBudget=64)`: если суммарный периметр (число вершин) комнат кандидата ≤ 64, `_rszProjectPreview` вызывает `_rszSpaceCandidateGeometry` → тот же `_checkSpacePhysicalGeometry`, что и раньше, и отклоняет шаг при `!ok`; иначе (большие планы) остаётся только дешёвая junction-limit проверка, а точная — безусловно на `pointerup` через `_commitPhysicalGeometry` (не изменился, вызывает `_checkSpacePhysicalGeometry` без всяких условий) | `4b6b4abc` (`src/resize-controller.ts:resizeLivePreflightAllowed`, `src/houseplan-editor-runtime.ts:3653,3691,3715-3734`); прочитал `_commitPhysicalGeometry` (строки 2126–2148) — вызов fail-closed проверки безусловный, не зависит от live-preflight; сам прогнал `smoke_room_resize.mjs` — OK |
Дополнительно (не было заявлено r2 находкой, но CI поймал регресс на `a7bee0f5` — SHA, на котором
физически лежал код r2 к моменту анализа, хотя r2 цитировал более ранний `cb68492c`): смоки
`resize_pointer.unrelated_pointer_ignored`/`capture_loss_restores_dom` красные. Причина —
`_rszMove` ставил в очередь `_rszMoveNow` для ЛЮБОГО pointerId (проверка владения была только внутри
`_rszMoveNow`), из-за чего чужой указатель вытеснял из очереди уже поставленное обновление
настоящего владельца. Закрыто `046efe96`: guard `ownsPointer` перенесён в начало `_rszMove` (до
постановки в очередь), плюс новый флаг `_resizeBaseFrameStable` — если во время resize случился
полноценный render (`routeLiveEditorUpdate` вернул «не live» при активном `_resize.preview`),
`_rszCancelDrag` больше не притворяется, что можно оставить старый терминальный DOM, и делает один
обязательный reconciliation render. Сам прогнал `smoke_resize_pointer_real_plan.mjs` — OK; независимо
подтверждено CI `33922485666` (все 3 шарда зелёные).
## Унаследовано из r1 (через r2, без повторной проверки)
Эта дельта не касается доказательной базы следующих пунктов r1/r2 — принимаю как есть:
- AC1–AC2, AC7 (разделение intake/visual invalidation, dependency projection, last-wins HA во
время жеста) — не тронуты дельтой r2→r3 (файлы фильтра `hass`/dependency classifier в диффе
`cb68492c..444562e4` отсутствуют). Документ: `CODE-REVIEW-451-r2.md`, SHA `cb68492c`.
- AC4 (diagnostics cache) — не тронут этой дельтой.
- AC9 (golden/canonical screenshots) — r2 подтвердил 153/153 без диффов на `cb68492c`; резерв
CI на `444562e4` («Переиспользование: это дерево уже проверено» → успех, «Golden-кадры» → skipped)
подтверждает, что источник для golden не менялся с последнего полного прогона. Отдельно не
перепрогонял: дельта r2→r3 не меняет settled-состояние (см. ниже про resize-preflight — эффект
только во время активного жеста, до golden-снимка дело не доходит).
- Инварианты модели по всем моделям проекта — часть `npm test` (сам прогнал на HEAD, зелёный);
конфиг-специфичная команда `npm run invariants -- --config …` не нужна отдельно: дельта не меняет
персистентную форму (`walls[]`, `wall_segments`, `marker.space`, `open_spans`) — только момент
вызова validation-функции и типизацию сигнатур.
- `INITIAL_VIEW_GZIP_CEILING` 297000→298000 — обоснование и бюджет не тронуты этой дельтой (только
`bundle-budget.mjs` логика из r1, файл не в диффе r2→r3).
## Находки
### M1 — не заявленный в ТЗ постоянный отказ от live-preflight на больших планах, противоречит собственному changelog (в скоупе, чинится в этой же задаче)
**Файлы:** `src/resize-controller.ts:18-27` (`resizeLivePreflightAllowed`),
`src/houseplan-editor-runtime.ts:3653` (использование в `_rszProjectPreview`).
Фикс H3 (`4b6b4abc`) не просто вернул прежнее поведение — он расколол его по размеру плана.
Для «bounded» контуров (суммарно ≤ 64 вершин комнат текущего пространства) во время resize-жеста
работает тот же точный `_checkSpacePhysicalGeometry()`, что был до #451: невозможная геометрия сразу
отклоняется, тост «последняя безопасная позиция» показывается посреди жеста. Для «больших» планов
(> 64 вершин) эта проверка **безусловно выключена во время жеста** — живёт только дешёвая
junction-limit проверка (углы/валентность), а точная геометрия проверяется один раз, на `pointerup`
(это безопасно для данных — commit всё ещё fail-closed, я прочитал `_commitPhysicalGeometry`
построчно, — но не для обратной связи пользователю).
Порог не абстрактный: фикстура `demo/fixtures/large-house.mjs`, на которой считается сам бюджет
`large-house-interaction-v1` (тот самый профиль, ради которого выключалась проверка), — это 20
прямоугольных комнат по 4 вершины = **80 вершин**, то есть уже выше порога 64. А персона из ТЗ
(«администратор дома... средний или большой план», issue: «5 пространств... 8 комнат в текущем
пространстве... средний по размеру план, не рекорд») — это ровно тот случай, для которого порог
скорее всего будет превышен, если комнаты не прямоугольные (8 комнат × 8 вершин = 64, граница
ровно на пороге).
Почему это находка, а не техническая деталь реализации:
1. **ТЗ прямо запрещает это как область изменения.** §5 Non-scope: «изменение hit areas, snap
tolerance, grid, gesture thresholds, animation duration/easing, click/double-click/long-press
или commit/cancel semantics» не входит в задачу. §2: «Преднамеренных визуальных изменений нет.
После завершения любого жеста канонический итог и итоговый кадр совпадают с текущим поведением»
— про итоговый кадр это верно (commit не изменился), но живая обратная связь **во время** жеста
для больших планов теперь другая, и это не техническая деталь — это то, что видит пользователь.
Ни §16 (риски), ни §18 (что можно менять свободно без владельца) не упоминают эту развилку.
2. **Прямо противоречит changelog, который уже в ветке.** `docs/CHANGELOG.md`/`.ru.md` (коммит
`c0d61ca3`, до r1) заявляют: «interactions remain visually and functionally unchanged» /
«внешний вид и результат действий не меняются». Для планов с > 64 вершин комнат в текущем
пространстве это не так: раньше resize посреди жеста предупреждал о невозможной позиции, теперь —
нет.
3. **Ни в одном комментарии issue владелец этот компромисс не видел и не утверждал** — я прочитал
всю переписку (`Аналитика`, `Вопросы для ТЗ`, `Решения владельца`, оба «Исправления по
CODE-REVIEW»): обсуждались Q1 (relevant HA update во время жеста) и Q2 (обычный hover), но не
порог 64 и не деление resize-preflight по размеру плана.
Это Medium, не High: данные не портятся ни при каком сценарии (проверил код коммита, unconditional
fail-closed check на pointerup), поэтому это не блокирующий баг, а нераскрытое изменение поведения
вне заявленного скоупа задачи — ровно формулировка PROCESS.md «жёлтый вердикт допустим при
выполненных AC, если изменение ухудшает смежный сценарий». Находка в скоупе (введена этой же веткой
ради выполнения AC10) — чинится в этой же задаче, отдельный issue не заводится.
**Что нужно от автора (не мой выбор, а перечисление опций для очередного цикла):** либо восстановить
живую точную проверку и для больших планов (тогда решать заново конфликт с AC10-бюджетом, из-за
которого её и убрали в r1), либо получить у владельца явное решение принять этот компромисс и
одновременно поправить формулировку changelog («без функциональных изменений» перестаёт быть верным
для больших планов), либо найти более дешёвый способ живой проверки (например, только у затронутой
комнаты вместо канонического union всего пространства — H1-r1 уже показал, что именно full clearance
был дорогим, а не сам факт проверки).
## Что проверено и корректно
- H1/H2/H3 из r2 закрыты — таблица выше, каждая проверена самостоятельно прогоном, а не с чужих слов.
- Регресс DOM `resize_pointer.unrelated_pointer_ignored`/`capture_loss_restores_dom` (пойман CI на
`a7bee0f5`, не был отдельной находкой r2) закрыт `046efe96`, подтверждено прогоном и CI.
- Commit-time geometry check (`_commitPhysicalGeometry` → `_checkSpacePhysicalGeometry`) остаётся
безусловным независимо от размера плана — прочитано построчно, не зависит от M1.
- Типизация (`fe03eae2`) семантически эквивалентна прежнему `any`-коду — прочитал diff
`junction-limits.ts` построчно (переход через промежуточный `completeGeometry` даёт тот же результат
для 'ok'/'degraded-extra'/'lightweight'/`null`/`undefined`, что и старое тройное сравнение).
`live-editor.ts` получил один дополнительный guard (`!!room.id &&`) — сужение, не расширение
поведения, риска регрессии нет.
- Новый unit-тест `resize-controller.test.mjs` («#451 live resize preflight is bounded by authored
contour complexity») проверяет именно границу (16×4=64 → true, 17×4=68 → false, один контур 65 → false)
— тест способен падать (проверил через чтение реализации: убери `<=` на `<`, и 64 упадёт).
- `no-new-any`, `check-docs`, `npm test`, `npm run build`+`bundle:sync` (сверка бандла) — зелёные
на точном HEAD, прогнано лично.
- Трейлеры `Issue: #451` — присутствуют во всех коммитах дельты. `User-Visible: no` для `046efe96`
и `444562e4` — точен (внутренний DOM-баг и обновление отпечатка, не новое поведение). Для `4b6b4abc`
трейлер тоже `User-Visible: no` — и вот это неточно ровно по причине M1.
## Чего не проверял и почему
- Полный `npm run golden:verify` — не гонял отдельно: дельта не меняет settled-состояние (эффект
M1 виден только во время активного resize-жеста, до commit/settled-кадра), а CI на HEAD показал
«Переиспользование: это дерево уже проверено» → golden job skipped легитимно (тот же tree-hash,
что уже проверялся 153/153 в r2).
- Полный `npm run benchmark:large-house-interaction` — не перегонял: диапазон дельты не меняет
веса editor-series (единственное затронутое ответвление — resize live-preflight, а он теперь
для фикстуры large-house **выключен** тем же порогом 64 < 80, то есть числа бюджета из r2
(`editor median 484.7 ms ≤ 750`) по построению не изменятся от этой дельты — сам факт этого и есть
часть находки M1: бюджет проходит именно потому, что фикстура выше порога).
- Оставшиеся 18 «прямых совпадений» smoke-select (декор/opening-смоки на `_openingsR`/`NORM_W`) —
дельта в этих файлах чисто типовая (import type вместо value import, сигнатуры), не гонял отдельно;
25 «слабых» совпадений на `_mode` не гонял — типовой рефакторинг чужого для них кода.
- `python -m pytest tests_backend` — диф не трогает `custom_components/**/*.py` (проверено чтением
`git diff --name-only`).
- `npm run invariants -- --config <...>` для конкретного конфига — не требуется отдельно (см.
«Унаследовано», дельта не меняет персистентную геометрическую форму).
- Мутация для `resizeLivePreflightAllowed` — не заводил: функция чистая, покрыта unit-тестом на все
три граничных случая, и мутационный тест не добавил бы нового при уже красном-способном юните.
## Итог
**Вердикт: жёлтый.** High: 0, Medium: 1 (M1, в скоупе — возвращается автору, отдельный issue не
заводится). H1–H3 из r2 закрыты корректно, дополнительный DOM-регресс закрыт корректно. Единственная
находка этого раунда — не баг в данных, а нераскрытое и не согласованное с ТЗ/changelog изменение
поведения resize-preflight для больших планов, появившееся как побочный эффект закрытия H3.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/451-render-performance`, коммит `444562e47cb7` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `9489e0f2e24a6b4df6bd587703b2fa058ebb3a04`
```
git log --all --format='%H %T' | grep 9489e0f2e24a
```
- ТЗ `docs/specs/451-render-performance.md`, блоб `7c323a29110974aae369077214b9e2a74d9387c1`
```
git log --all --find-object=7c323a29110974aae369077214b9e2a74d9387c1 -- docs/specs/451-render-performance.md
```
+284
View File
@@ -0,0 +1,284 @@
# CODE-REVIEW #451 — заход r4
- **Issue:** #451 — «План тормозит: диагностика считается на каждый кадр, перетаскивание перерисовывает всё, нет фильтра обновлений»
- **Этап:** код-ревью (PROCESS.md §2.7)
- **Заход:** r4 · блокирующих циклов до этого раунда: 3/4
- **Материал раунда:** ветка `issue/451-render-performance`, `HEAD = 07ba2ffbd1c02172d114ef27fb7cee2e40cf326e`
(сверено `git rev-parse HEAD` непосредственно перед выводом, дерево чистое, PROCESS.md §2.7/#312)
- **Дельта раунда:** `git diff 444562e47cb746dc1c7d740b66b2f832ca02f064..07ba2ffb` — SHA взят из машинного блока
«Материал раунда» документа `docs/reviews/CODE-REVIEW-451-r3.md` (дерево `9489e0f2e24a6…`, сверено
`git log --all --format='%H %T' | grep 9489e0f2e24a` — совпадает с `444562e4`). Файлы дельты
(без `dist/**` и копий бандла стенда): `src/houseplan-card.ts`, `src/houseplan-editor-runtime.ts`,
`src/resize-controller.ts`, новый `src/resize-live-preflight.ts`, `test/resize-controller.test.mjs`,
`demo/benchmark_large_house.mjs`, `demo/performance/card-contract.mjs`, `docs/images/screenshots.json`,
`docs/reviews/CODE-REVIEW-451-r3.md`.
## Скоуп раунда
r3 (жёлтый, High:0/Medium:1) закончился на `444562e4`. Единственная находка r3 — **M1**: фикс H3 из r2
не просто вернул прежнее поведение, а ввёл порог `resizeLivePreflightAllowed(rooms, edgeBudget=64)` —
точная physical-geometry проверка во время resize-жеста была живой только для планов ≤ 64 вершин
комнат, для больших планов работала только на `pointerup`. Не заявлено в ТЗ, противоречило changelog
«внешний вид и результат действий не меняются».
Автор ответил одним коммитом `5692a288` («fix: preserve live resize validation on large plans»,
`User-Visible: no`): порог полностью удалён, вместо него — новый модуль `resize-live-preflight.ts` с
тремя функциями (`resizeLiveRoomIds`, `resizeLiveJunctionRoomIds`, `resizeLiveCandidateSpace`), которые
строят «локальный кандидат» — только затронутые комнаты плюс геометрически близкие к их границе
стены/сегменты/партиции/проёмы — и гоняют через него ту же самую `_checkSpacePhysicalGeometry`,
безусловно, на любом размере плана. `07ba2ffb` поверх — только обновление отпечатка скриншотов
документации (`docs: refresh screenshot fingerprint for #451`).
Разбираю всю дельту по коду: это новый файл на 210 строк с нетривиальной геометрией (distance-to-segment,
segment-intersection, AABB-touch), напрямую заменяющий защитный механизм, который весь путь r2→r3→r4 и
был предметом ревью.
## Как проверялось
Дельта не трогает персистентный формат — только момент и объём вызова уже существующей проверки.
Валидация на точном HEAD (`07ba2ffb`) уже зелёная в CI: [Validate run 33927104551](https://github.com/Matysh/houseplan-card/actions/runs/33927104551),
success. Это покрывает `npx tsc --noEmit`, `npm test`, `npm run build`+сверку бандла и `check-docs`
(docs job зелёный на этом SHA) — **не перегонял эти четыре повторно**, см. правило дешёвых гейтов §8/§2.10.
Что прогнал сам в этом раунде:
| Команда | Результат |
|---|---|
| `npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs` | зелёный (нужно для собственных проб ниже) |
| `node scripts/no-new-any.mjs --base 444562e4 --head HEAD` | «Новых any нет» (225 строк в 3 файлах) |
| `npm run build && npm run bundle:sync` | зелёные, дерево бандла пересобрано без ошибок |
| `node scripts/smoke-select.mjs --base 444562e4 --head HEAD` | 19 прямых совпадений + 2 зарегистрированные связи, все по теме wall-union/junction/masonry/resize (список ниже) |
| `node demo/smoke_room_resize.mjs` | OK — реальные (не замоканные) сценарии `owner_boundary_clamped`, `corner_clamped`, `mixed_role_*` не задеты |
| `node demo/smoke_resize_pointer_real_plan.mjs` | OK |
| `node demo/smoke_junction_holes.mjs` | OK |
| `node demo/smoke_glow_fail_dark.mjs` | OK |
| `node demo/smoke_glow.mjs` | OK |
| `node demo/smoke_junction_patch_resilience.mjs` | OK |
| `node demo/smoke_multiwall_junction.mjs` | OK |
| `node demo/smoke_multiwall_strip_containment.mjs` | OK |
| `node demo/smoke_opening_measure.mjs` | OK |
| `node demo/smoke_optional_space_model.mjs` | OK |
| `node demo/smoke_wall_key_roundtrip.mjs` | OK |
| `node demo/smoke_wall_thickness_transition.mjs` | OK |
| `node demo/smoke_wall_union_isolation.mjs` | OK (включая генуинный, не замоканный `degradedPhysicalEditRejected` на **commit**-пути) |
| `node demo/smoke_zero_divider_taper.mjs` | OK |
| `node demo/smoke_real_plan_masonry.mjs` | OK («зарегистрированная связь» smoke-select — реальный план, разрывы кладки, которые синтетика не ловит) |
| `node demo/smoke_resize_wall_thickness.mjs` | OK («зарегистрированная связь») |
Плюс собственная проба (не входит в существующий гейт, привожу как воспроизведение находки ниже):
прямой вызов `checkSpacePhysicalGeometry`/`resizeLiveCandidateSpace`/`resizeLiveJunctionRoomIds` из
`test-build/*.js` на реальном производственном фикстуре регрессии `test/fixtures/278-wall-union-isolation.json`
(#278) плюс синтетические «дальние» комнаты для имитации большого плана.
## Закрытие r3
| Находка r3 | Чем закрыта | Где видно |
|---|---|---|
| **M1** (порог `edgeBudget=64`: точная live-проверка выключена целиком для планов > 64 вершин комнат, не заявлено в ТЗ/changelog) | Порог `resizeLivePreflightAllowed` удалён вовсе (`src/resize-controller.ts`, было 13 строк функции — теперь нет). Вместо него `resizeLiveCandidateSpace`/`resizeLiveRoomIds` (`src/resize-live-preflight.ts`) строят локальный физический кандидат и `_rszProjectPreview` гоняет `_checkSpacePhysicalGeometry` через него **безусловно**, на любом размере плана — деление по порогу исчезло как класс | `5692a2880f9ba645b9e2a897a828a9ea169a3a1a` (`src/houseplan-editor-runtime.ts:3654-3656`); сам прочитал — вызов больше не обёрнут в `if (resizeLivePreflightAllowed(...))`; `demo/benchmark_large_house.mjs` теперь структурно требует `resizeLivePreflightChecks >= 1` на `large-house` фикстуре (80 вершин, выше старого порога) — не даёт тихо вернуть скип |
Формально M1 закрыта в буквальном прочтении (порог убран, вызов безусловный на любом размере). Но
дельта заменила один раскрытый компромисс на другой, нераскрытый — см. находку ниже: сам факт «вызов
происходит всегда» не означает «проверка обнаруживает то же самое, что обнаруживала до #451».
## Находки
### M1 (r4) — локальный кандидат для physical-geometry исключает соседнюю комнату, от которой зависит валидность стыка; живая проверка может пропустить реальный дефект кладки на любом размере плана (в скоупе, чинится в этой же задаче)
**Файлы:** `src/resize-live-preflight.ts:59-65` (`resizeLiveRoomIds`), `:91-124` (`resizeLiveCandidateSpace`),
`src/houseplan-editor-runtime.ts:3654-3656` (`_rszProjectPreview`, использование).
`resizeLiveCandidateSpace(sp, changedRoomIds)` строит кандидат для `_rszSpaceCandidateGeometry` (→
`_checkSpacePhysicalGeometry`), отбирая **только** комнаты, чей id входит в `changedRoomIds`
(`resizeLiveRoomIds` — точное совпадение id, без расширения на соседей). Стены/сегменты/партиции
отбираются отдельно, по геометрической близости к границе **этих** комнат. Соседняя комната, которая
физически не изменилась в этом кадре (её id не в `changedRoomIds`), в кандидат не попадает вовсе — даже
если общий с ней узел/стена как раз и есть источник невалидности.
Это не гипотетическое рассуждение о коде — воспроизвёл прогоном на реальном production-фикстуре
регрессии #278 (`test/fixtures/278-wall-union-isolation.json`, две комнаты `r1`/`r2`, общая стена
толщиной 20, историческая дефектная кладка, статус `degraded-extra`):
```js
import { checkSpacePhysicalGeometry } from './test-build/plan-geometry-preflight.js';
import { resizeLiveCandidateSpace } from './test-build/resize-live-preflight.js';
// baseSpace = spaces[0] из test/fixtures/278-wall-union-isolation.json (r1, r2, стена 20см)
// + 80 «дальних» синтетических комнат (10..90 по x), чтобы получить план > 64 вершин.
checkSpacePhysicalGeometry({ spaces: [largeSpace] }, largeSpace.id)
// -> { status: 'failed', reason: 'wall-degraded-extra', ok: false } ← ПОЛНОЕ пространство: дефект виден,
// независимо от того, сколько в плане посторонних комнат (80 или 0)
const liveSpace = resizeLiveCandidateSpace(largeSpace, ['r1']); // r1 «изменилась», r2 — нет
liveSpace.rooms.map(r => r.id) // -> ['r1'] (r2 выброшена целиком)
checkSpacePhysicalGeometry({ spaces: [liveSpace] }, largeSpace.id)
// -> { status: 'ok', ok: true } ← ТОТ ЖЕ дефект больше не виден
```
Итог: во время resize-жеста, когда двигается только `r1`, а `r2` (владелец второй стороны той же
дефектной стены) не входит в `changedRoomIds`, живая проверка сообщает «геометрия в порядке» для
кандидата, который на самом деле нарушает контракт масонри — тот же самый контракт, ради которого
существует `_checkSpacePhysicalGeometry` и весь путь #278/H3(r2)/M1(r3). Это воспроизводится **на любом
размере плана** — добавление или удаление 80 посторонних комнат ничего не меняет, значит находка не
«ещё один порог», а более фундаментальная: набор комнат для physical-geometry кандидата определяется
по `changedRoomIds` (точное совпадение id), а не по геометрической смежности.
Показательно, что для **другого** кандидата в том же методе — `_resizePreviewNodes` (junction-limit
проверка, #329) — автор уже использует `resizeLiveJunctionRoomIds` (один слой AABB-соседей), а не голый
`changedRoomIds`. Подставил тот же более широкий набор в physical-geometry кандидат — и дефект снова
виден:
```js
const junctionIds = resizeLiveJunctionRoomIds(largeSpace.rooms, ['r1']); // -> ['r1', 'r2']
const liveSpaceViaJunctionIds = resizeLiveCandidateSpace(largeSpace, junctionIds);
checkSpacePhysicalGeometry({ spaces: [liveSpaceViaJunctionIds] }, largeSpace.id)
// -> { status: 'failed', reason: 'wall-degraded-extra', ok: false } ← дефект снова обнаружен
```
Почему это Medium, а не техническая деталь:
1. **Данные не портятся.** `_commitPhysicalGeometry` (не в этой дельте, прочитал — не изменился) вызывает
`_checkSpacePhysicalGeometry` на **полном** пространстве безусловно на `pointerup`. Невалидная
геометрия всё ещё не может сохраниться.
2. **Но живая обратная связь пользователю может отсутствовать именно там, где её восстановление и было
предметом M1(r3).** Пользователь дотягивает жест до конца, не видя «последней безопасной позиции», а
затем получает отказ commit (`resize.commit_failed`, класс регресса, который уже описан в H3 r2) без
предупреждения по пути — то есть худший, а не лучший исход по сравнению с раскрытым порогом r3: тот
хотя бы предсказуемо и одинаково выключал проверку выше 64 вершин; этот — непредсказуемо, в
зависимости от того, какая именно комната «официально изменилась» в данном кадре resize-солвера,
и на любом размере плана, включая маленькие.
3. **Ни ТЗ, ни changelog, ни коммит-сообщение** (`fix: preserve live resize validation on large plans`,
`User-Visible: no`) не упоминают эту границу — коммит заявляет ровно противоположное тому, что
происходит для дефектов, зависящих от соседней комнаты.
4. **Существующее покрытие не могло эту находку поймать.** Новые unit-тесты в
`test/resize-controller.test.mjs` проверяют только структуру фильтрации (какие id/стены попадают в
кандидат), не пропуская результат через `checkSpacePhysicalGeometry`. Новая проверка в
`demo/benchmark_large_house.mjs` (`deltas.resizeLivePreflightChecks < 1` роняет раннер) доказывает
только, что проверка **вызывается**, а не что она может вернуть `false` для реально невалидного
кандидата. `smoke_room_resize.mjs` (единственный реальный смок с геометрией без мока) использует
фикстуры, где невалидность — либо однокомнатная топология (`corner_clamped`), либо ровно совпадающая
с существующей стеной (`owner_boundary_clamped`, `wall-metadata`-путь, отдельная от physical-geometry
проверка, использует полный `sp.rooms` — не задета этой находкой); ни один существующий тест не
строит #278-подобный «дефект стыка, видимый только если обе стороны в модели».
**Что нужно от автора (не мой выбор, перечисление опций для следующего цикла):** либо расширить набор
комнат physical-geometry кандидата тем же способом, что уже используется для junction-кандидата
(`resizeLiveJunctionRoomIds`, один слой AABB-соседей — проба выше показывает, что этого достаточно для
воспроизведённого случая; открытый вопрос — достаточно ли одного слоя для более сложных многосторонних
узлов, это стоит явного теста), либо обосновать и явно задокументировать в ТЗ/коде, почему набора
`changedRoomIds` достаточно для physical-geometry (если у меня неверна модель угрозы), и в любом случае
добавить тест, который прогоняет `resizeLiveCandidateSpace`-кандидат **через** `checkSpacePhysicalGeometry`
на заведомо дефектной (не замоканной) геометрии — по образцу пробы выше, лучше всего на самом фикстуре
#278, — чтобы AC «восстановлена live-валидация» имело названного свидетеля, который умеет краснеть.
## Унаследовано из r3 (и через r3 из r1/r2), без повторной проверки
Эта дельта не касается доказательной базы следующих пунктов — принимаю как есть:
- H1–H3 из r2 (docs fingerprint, новые `any`, регресс `smoke_room_resize`) и дополнительный DOM-регресс
(`resize_pointer.unrelated_pointer_ignored`/`capture_loss_restores_dom`) — файлы их фиксов
(`houseplan-card.ts` типизация junction-limits, `live-editor.ts`, `046efe96` DOM-guard) не входят в
дельту `444562e4..HEAD`. Документ: `CODE-REVIEW-451-r2.md`/`-r3.md`, SHA `cb68492c`/`444562e4`.
- AC1–AC2, AC7 (разделение intake/визуальной инвалидизации, dependency projection, last-wins HA во время
жеста) — файлы фильтра `hass`/dependency classifier не в дельте `444562e4..HEAD`. Документ:
`CODE-REVIEW-451-r2.md`, SHA `cb68492c`.
- AC4 (diagnostics cache) — не тронут этой дельтой.
- AC9 (golden/canonical screenshots) — единственный тронутый в этой дельте артефакт —
`docs/images/screenshots.json` (отпечаток источника, коммит `07ba2ffb`); Linux-съёмка на предыдущем
SHA (`444562e4`) была канонической (r3: [run 33926924502]) и её результат (10/10 без диффов) не
меняется этой дельтой — сама дельта не трогает `src/**` рендер-путь, только момент вызова физической
проверки во время resize, до commit/settled-кадра. Отдельно не перепрогонял golden — не требуется:
результирующий кадр после `pointerup` идентичен (полная проверка на commit не изменилась).
- Инварианты модели по всем моделям проекта — часть `npm test` (зелёный на HEAD в CI); конфиг-специфичная
команда `npm run invariants -- --config …` не нужна отдельно: дельта не меняет персистентную форму
(`walls[]`, `wall_segments`, `marker.space`, `open_spans`) — только состав кандидата, временно
собираемого в памяти для live-проверки во время жеста, никогда не записываемого в конфиг.
- `INITIAL_VIEW_GZIP_CEILING` 297000→298000 и его обоснование — не тронуты этой дельтой.
## Что проверено и корректно
- Порог `resizeLivePreflightAllowed`/`edgeBudget=64` из r3 действительно удалён целиком — прочитал diff
`resize-controller.ts`, функции больше нет; `_rszProjectPreview` больше не содержит ветвления по
размеру плана для physical-geometry вызова.
- 17 из 21 отмеченных `smoke-select` тестов (19 прямых + 2 зарегистрированных за вычетом 6 слабых
`cellCm`-совпадений, см. «Чего не проверял») прогнаны лично, все зелёные — включая генуинный (не
замоканный) commit-time `degradedPhysicalEditRejected` в `smoke_wall_union_isolation.mjs` и реальный
план в `smoke_real_plan_masonry.mjs`.
- Реальные (не замоканные) мелкоплановые сценарии `smoke_room_resize.mjs` (`owner_boundary_clamped`,
`corner_clamped`, `mixed_role_*`) не регрессировали при переходе на безусловный локальный кандидат —
прогнал, зелёные.
- `_commitPhysicalGeometry` (полное пространство, безусловный вызов на `pointerup`) не тронут этой
дельтой — прочитал, данные при коммите остаются fail-closed независимо от находки M1(r4): невалидная
геометрия не может сохраниться, регресс только в live-обратной связи посреди жеста.
- Новые unit-тесты `resize-controller.test.mjs` корректно проверяют то, что они заявляют проверять
(структура фильтрации id/стен/партиций/проёмов на 100-комнатном синтетическом плане) — прочитал, тест
умеет падать на этом узком контракте (например, `resizeLiveRoomIds` вернёт лишний id, если убрать
фильтр по `changed`). Находка M1(r4) не в том, что эти тесты неверны, а в том, что они не покрывают
промежуточный конечный результат (`checkSpacePhysicalGeometry` на построенном кандидате).
- `no-new-any --base 444562e4 --head HEAD` — 0 новых `any` в 225 добавленных строках 3 файлов.
- Новая инструментация `demo/benchmark_large_house.mjs` (`physicalPreflightCount`/`physicalPreflightMs`,
ассерт `resizeLivePreflightChecks < 1`) корректно доказывает то немногое, что доказывает: вызов
происходит хотя бы раз во время editor-резайза на large-house фикстуре — не более.
- Трейлеры `Issue: #451` присутствуют, `User-Visible: no` для `5692a288` и `07ba2ffb` — точны для
заявленного эффекта (реализация не меняет видимое поведение, когда проверка срабатывает), но не
раскрывают найденную M1(r4) границу, где она не срабатывает.
## Чего не проверял и почему
- Полный `npm run golden:verify`, `npx tsc --noEmit`, `npm test`, `npm run build`+сверка бандла отдельно
от CI — не перегонял: Validate зелёный на точном HEAD `07ba2ffb`
([run 33927104551](https://github.com/Matysh/houseplan-card/actions/runs/33927104551)), дешёвые гейты
§8 сошлись на этом прогоне.
- 6 слабых `smoke-select`-совпадений по единственному общему идентификатору `cellCm`
(`smoke_backdrop_guard`, `smoke_danger_confirmation`, `smoke_decor`, `smoke_grid_scale_invariance`,
`smoke_help_affordance`, `smoke_space_scale_defaults`) — не гонял: `cellCm` — параметр с fallback по
умолчанию в новом файле, общий для всей кодовой базы идентификатор без содержательной связи с темой
этих смоков (ни один не про resize/physical-geometry); риск по существу покрыт целевыми
wall-union/junction/resize смоками выше.
- Полный `npm run benchmark:large-house-interaction` — не перегонял сам; автор привёл 7 прогонов с
зелёными абсолютными бюджетами на этом SHA. Дельта этого раунда не меняет веса editor-series (то же
число вызовов physical-geometry на move, что и раньше, только на другом наборе комнат) — по построению
не должна была измениться, и находка M1(r4) не является перформанс-регрессией (наоборот: локальный
кандидат обычно дешевле полного пространства).
- `python -m pytest tests_backend` — диф не трогает `custom_components/**/*.py` (проверено
`git diff --name-only 444562e4..HEAD`).
- Мутация для `resizeLiveCandidateSpace`/`resizeLiveRoomIds` через `scripts/mutation-gate.mjs` — не
заводил (это не моя роль); вместо этого привёл воспроизводимую пробу через прямой вызов
скомпилированных пары чистых функций на production-фикстуре #278 — она и есть демонстрация «чем
краснеет» для находки M1(r4). Постоянный мутант в гейте — тоже часть того, что нужно от автора при
закрытии находки.
- Полная browser-smoke матрица (222 файла) — не прогонял; это предрелизный гейт (PROCESS.md §8), дельта
локальна (4 файла кода) и `smoke-select` по точному диапазону раунда покрыл релевантную тему.
## Итог
**Вердикт: жёлтый.** High: 0, Medium: 1 (M1(r4), в скоупе — возвращается автору, отдельный issue не
заводится). Порог из M1(r3) действительно удалён, но замена (`resizeLiveCandidateSpace` с фильтрацией
комнат по точному `changedRoomIds`) вводит новый, более скрытый пробел того же класса: воспроизведён
прогоном на реальном production-фикстуре #278, что живая physical-geometry проверка может вернуть `ok`
для кандидата, эквивалентного заведомо дефектной (`wall-degraded-extra`) полной геометрии, если
источник дефекта — стык с соседней, формально «неизменившейся» комнатой. Данные не портятся
(`_commitPhysicalGeometry` на `pointerup` не изменился и остаётся безусловным), поэтому находка не
блокирует как High, но AC «restore live resize validation» в исходном смысле (было в r1/r2 до #451
сломавших это) не является полностью восстановленным для этого класса дефектов ни на одном размере
плана — направление минимального фикса (переиспользовать уже существующий `resizeLiveJunctionRoomIds`)
подтверждено той же пробой.
---
<!-- material-anchors: заполняется конвейером публикации -->
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/451-render-performance`, коммит `07ba2ffbd1c0` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `c2def02d1a1409564ed4eaa3661b663cbba356c1`
```
git log --all --format='%H %T' | grep c2def02d1a14
```
- ТЗ `docs/specs/451-render-performance.md`, блоб `7c323a29110974aae369077214b9e2a74d9387c1`
```
git log --all --find-object=7c323a29110974aae369077214b9e2a74d9387c1 -- docs/specs/451-render-performance.md
```
+226
View File
@@ -0,0 +1,226 @@
# SPEC-REVIEW-451-r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/451
- **Этап:** spec (PROCESS.md §2.4)
- **Заход:** r1 (первое ревью ТЗ, лёгкий трек не применяется — S2-analysis назвал
четыре нарушенных критерия §5: риск 9/10, больше одной поверхности, влияние
на performance, влияние на touch)
- **Материал:** `docs/specs/451-render-performance.md`, коммит `71c3360363991a042fbf3d84a6aeb4ecb772c83f`
(`git rev-parse HEAD` на момент вывода), ветка `issue/451-render-performance`
(HEAD detached at `origin/issue/451-render-performance`)
- **Вердикт:** жёлтый · заход r1 · блокирующих циклов 1/4 · High: 0 · Medium: 3 → в задаче
## Скоуп ревью
Проверялся только сам файл ТЗ и тело/комментарии issue #451 — продуктового кода
для #451 ещё нет (коммит `71c33603` содержит только `docs/specs/451-render-performance.md`
и запись в `docs/specs/README.md`). Ревью состязательное: разбор велся без
устных пояснений автора, по тексту ТЗ и по текущему `dev`.
## Как проверялось
1. Прочитаны `docs/SCOPE.md`, `AGENTS.md`, `PROCESS.md` целиком (включая §2.4,
§2.10, §4, §5, §7.1, §7.2, §8).
2. Прочитано тело issue #451 и все комментарии (замеры, причины A/B/C/D,
аналитика, вопросы Q1/Q2 и решения владельца).
3. Прочитан ТЗ-документ целиком (`docs/specs/451-render-performance.md`, 516 строк).
4. Факты, заявленные в §3 ТЗ как установленные причины, сверены с текущим
`dev` построчно:
- `_renderBody()` безусловно вызывает `houseplanDiagnostics()` —
`src/houseplan-card.ts:11315`, подтверждено;
- обход всех живых bindings в `houseplanDiagnostics()`/`_bindingStatus()` —
`src/houseplan-card.ts:4748-4771`, подтверждено;
- три `data-*` атрибута читают один и тот же объект `diagnostics`,
вычисленный один раз на рендер (строки `11349-11351` и `11430-11432`
используют одну и ту же переменную `diagnostics`) — «одно число, один
источник» соблюдено уже в текущем коде и не нарушается контрактом ТЗ;
- `_view`/`_zoom` зарегистрированы `state: true` — `src/houseplan-card.ts:2557-2558`,
подтверждено;
- `shouldUpdate()` в файле отсутствует (есть только `willUpdate()`) —
подтверждено;
- `createRenderDeviceSnapshot()` существует и используется — `src/houseplan-card.ts:4687`,
подтверждено.
5. Сверены абсолютные потолки, упомянутые в §11.3 ТЗ, с `demo/performance/budgets.json`:
`panZoomMs.hardMaxMs = 500`, `stateUpdateMs.hardMaxMs = 1000` — совпадает
с тем, что ТЗ называет уже существующим.
6. Проверено существование каждого упомянутого в ТЗ файла-адресата документации:
`docs/PERFORMANCE.md`, `docs/SCREENSHOTS.md`, `demo/performance/README.md`,
`demo/benchmark_large_house.mjs`, `demo/performance/card-contract.mjs` —
через `find`/`ls` по репозиторию.
7. Проверены связанные issue (#34, #82, #137, #156, #380, #396, #449) через
`gh issue view` — все существуют и релевантны тому, для чего их приводит ТЗ.
8. Проверен термин `POINTER_HOVER_QUERY`/«fine hover capability» из §9 ТЗ —
совпадает с реальной константой `src/pointer-modality.ts:5`.
9. Численные данные §11.3 («наблюдавшихся 3 432–5 538 мс») сверены с телом
issue и всеми его комментариями (`gh issue view --json body,comments`) —
`grep` на «5538»/«5 538» не дал ни одного совпадения ни в issue, ни где-либо
в репозитории вне самого нового ТЗ.
Гейты (`typecheck`/`test`/`build`/смоки/perf) не прогонялись и не должны были:
на этом этапе нет продуктового кода, диапазон изменений — только `docs/specs/**`.
Это осознанное решение по этапу, а не пропуск.
## Что проверено и корректно
- Обязательные разделы §7.1 присутствуют и в правильном порядке: сценарий (§1),
что человек увидит до/после (§2), проблема (§3), скоуп/не-скоуп (§4-5),
контракт поведения (§6-8), UX/touch/a11y (§9), модель данных/совместимость/
безопасность (§10), performance (§11), AC1-AC13 с методом доказательства
(§12), план автотестов (§13), план реализации (§14), release-артефакты (§15),
риски (§16), откат (§17), явный блок «принято предположительно» (§18).
- Продуктовые решения владельца (Q1 — deferred-last-wins после `pointerup`/
`pointercancel`; Q2 — обычный hover входит в задачу через lightweight-слой)
корректно перенесены в контракт (§6.3, §8.3) и не переоткрываются как
вопросы — ТЗ их фиксирует как решённые, а не как предположения автора.
- Диагноз причин A/B/C в §3 не является догадкой: каждое утверждение проверяется
чтением текущего кода (см. «Как проверялось», п.4) и совпадает с ним дословно.
- Собственное наблюдение автора из комментария issue («редакторские жесты не
замерялись, синтетический `pointermove` не доходит до обработчиков») корректно
закрыто требованием в плане тестов: «Synthetic events обязаны доходить до
production handlers; `0` событий у handler не принимается как доказательство
`0` renders» (§13.2). Это именно то усиление, которого просил сам owner-комментарий
(«жест не перерисовывает содержимое» проверяется на демо-стенде, где портить
нечего).
- Non-scope (§5) точно очерчивает границу: внешний вид, hit area, snap
tolerance, geometry-алгоритмы, persisted config/schema, ослабление бюджетов —
всё явно исключено, что снижает риск скрытого расширения скоупа при
реализации сложной (9/10) задачи.
- AC1-AC13 пронумерованы, у каждого указан метод доказательства
(`unit`/`smoke`/`golden`/`performance`/«код-ревью»/«review»), формулировки
проверяемы по структурным assertions §11.2 (счётчики full render, а не
субъективные «стало быстрее»).
- «Один жест — один результат» выдержан симметрично для всех трёх редакторов
через таблицу §8.4, без привилегирования Плана перед Устройствами/Подложкой.
- Откат (§17) реалистичен: revert коммитов, точечный аварийный fallback на
один жест без отключения diagnostics cache/HA filter, с обязательным issue —
это не пустая формальность, а конкретный механизм.
- Ссылки на связанные issue (#34, #82, #137, #156, #380, #396, #449) все
существуют и действительно относятся к тем контрактам, которые ТЗ обязуется
не сломать (проверено `gh issue view`).
## Находки
### M1 — `docs/PERFORMANCE.md` не существует, но назван обязательным артефактом (Medium, в скоупе)
**Файл:** `docs/specs/451-render-performance.md`, строки 13, 390-391 (AC13), 452
(план реализации, п.7), 469 (release-артефакты).
**Проблема:** ТЗ трижды в разных разделах требует обновить `docs/PERFORMANCE.md`
как уже существующий канонический документ performance harness («**Связано:**
… `docs/PERFORMANCE.md`»; AC13 «`docs/PERFORMANCE.md` и `demo/performance/README.md`
описывают новый профиль…»; §15 «Также обновляются: `docs/PERFORMANCE.md`»).
Такого файла в репозитории нет ни на `dev`, ни где-либо ещё — проверено
`find docs -iname "*performance*"`. Канонический документ performance harness
на сегодня — `demo/performance/README.md` (на него явно ссылается
`docs/DEVELOPMENT.md:165`).
**Как проявится:** разработчик, следуя ТЗ буквально, либо не найдёт файл и
пропустит часть AC13 «не сделано» под видом выполненного, либо создаст новый
`docs/PERFORMANCE.md` параллельно уже существующему `demo/performance/README.md`,
получив два источника документации об одном и том же harness — ровно тот
класс дублирования, который процесс просит избегать («параллельных бэклоги/
документы» и принцип «одно число/один источник» по духу, здесь — один
документ на один факт).
**Требуется:** исправить ссылку на `demo/performance/README.md` (или явно
решить создать новый `docs/PERFORMANCE.md` как отдельный AC — тогда это
техническое решение места документации, которое должно быть либо снято, либо
явно помечено как «новый файл» в §18/§13.3, а не как правка существующего).
### M2 — `docs/SCREENSHOTS.md` не существует (Medium, в скоупе)
**Файл:** `docs/specs/451-render-performance.md`, строка 428 (§13.3).
**Проблема:** «после изменения `src/**` — canonical screenshots по
`docs/SCREENSHOTS.md`, inspection и существующие golden» — файла
`docs/SCREENSHOTS.md` в репозитории нет (`find docs -iname "SCREENSHOTS.md"`
не находит ничего). Термин «canonical screenshots» в проекте реален (встречается
в сообщениях коммитов и предыдущих код-ревью, например
`docs/reviews/CODE-REVIEW-126-r1.md:6`), но процесс их пересъёмки и приёмки
описан в `PROCESS.md` §8 (`npm run build && node demo/docs/capture.mjs`,
`npm run docs:accept -- --reviewed --from=<артефакт>`) и в `scripts/check-docs.mjs`,
а не в файле с таким названием.
**Как проявится:** тот же риск, что в M1 — ссылка на несуществующий файл в
плане автотестов не даёт разработчику конкретного места, которое нужно
обновить, и не является проверяемым шагом «как есть».
**Требуется:** заменить ссылку на фактический процесс (`PROCESS.md` §8 /
`scripts/check-docs.mjs` / `demo/docs/capture.mjs`) или на реальный файл, если
он появится к моменту реализации.
### M3 — верхняя граница диапазона «наблюдавшихся» замеров в §11.3 не подтверждена ни одним источником (Medium, в скоупе)
**Файл:** `docs/specs/451-render-performance.md`, строки 329-332 (§11.3).
**Проблема:** обоснование bootstrap `hardMaxMs` в таблице §11.3 звучит так:
«…полученные как безопасный порядок ниже наблюдавшихся **3 432–5 538 мс**
полного-render времени…». Число `3 432 мс` действительно есть в issue
(«Перетаскивание: суммарно в обновлениях | 3 432 мс»). Число `5 538 мс`
не встречается ни в теле issue, ни в одном из шести комментариев
(`gh issue view 451 --json body,comments`, поиск `5538`/`5 538` — ноль
совпадений), ни где-либо в репозитории вне самого этого ТЗ.
**Как проявится:** конкретные абсолютные потолки (`500/500/500/750/250 мс`,
`maxSingleLongTaskMs<=150`, `longTaskTotalMs<=300`) станут блокирующим CI-гейтом
(«fail the runner независимо от timing result», §11.2). Если верхняя граница
диапазона, из которого эти потолки якобы выведены «безопасным порядком»,
не подкреплена измерением, у порогов нет прослеживаемой доказательной базы —
именно то «утверждение, которого нет ни в одном документе и не помечено как
предположение», которое ревью обязано ловить отдельно от продуктовых догадок.
Не исключено, что число реально измерено автором (в issue упомянуто отдельное
инструментирование «снято со страницы»), но в тексте ТЗ это не названо явно как
несохранённое сырое измерение, а подано как факт наравне с процитированным
`3 432 мс`.
**Требуется:** либо назвать источник `5 538 мс` (например, «долгие задачи —
30 шт/3 298 мс плюс базовая стоимость рендера» с явным выводом формулы), либо
убрать конкретное число и явно пометить диапазон как «оценка автора спецификации,
поменять свободно» в §18, либо пересчитать от фактически процитированных в issue
цифр.
## Что не проверялось и почему
- **Продуктовый код** — не прогонялись `npx tsc --noEmit`, `npm test`, `npm run
build`, смоки, golden, invariants, performance-профили. Причина: диапазон
этого коммита — только `docs/specs/**`, продуктового кода для #451 ещё нет;
это гейты этапа `code`, а не `spec`.
- **Полнота dependency-списка §6.2** (все ли реальные top-level `hass.*` поля,
которые сейчас читает `houseplan-card.ts`, туда попадут) — не проверялась
построчным аудитом всех обращений к `this.hass.*`: §18 прямо оставляет точный
состав dependency projection «принято предположительно, поменять свободно»
и делегирует полноту code review реализации (AC2 доказывается `unit` + `targeted
smoke`, а не спек-ревью).
- **Достижимость абсолютных бюджетов §11.3 в реальном рантайме** — это будет
доказано конкретным CI-прогоном нового профиля при реализации (AC10), спек-ревью
проверяет только прослеживаемость происхождения чисел (см. M3), а не их
реалистичность.
- **Полная матрица редакторских жестов** (Plan/Devices/Decor continuous
interactions за пределами трёх «representative editor series») — ТЗ сознательно
переносит её в deterministic smoke, а не в performance-профиль (§18), это
решается на этапе реализации и проверяется в code review по факту
написанных смоков.
## Материал раунда
- Ветка: `issue/451-render-performance`
- HEAD на момент вывода: `71c3360363991a042fbf3d84a6aeb4ecb772c83f`
- Дерево материала: `docs/specs/451-render-performance.md` (единственный
затронутый ТЗ-файл), `docs/specs/README.md` (индексная строка)
- Команда поиска дерева: `git log --all --format='%H %T' | grep <дерево HEAD>`
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/451-render-performance`, коммит `71c336036399` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `c395260a0f49bc3004d1cb443cae97c4f9b88001`
```
git log --all --format='%H %T' | grep c395260a0f49
```
- ТЗ `docs/specs/451-render-performance.md`, блоб `d9c4dfdc78feeeecfaf1ec41bdf665f22d21944e`
```
git log --all --find-object=d9c4dfdc78feeeecfaf1ec41bdf665f22d21944e -- docs/specs/451-render-performance.md
```
+149
View File
@@ -0,0 +1,149 @@
# SPEC-REVIEW-451-r2
- **Issue:** https://github.com/Matysh/houseplan-card/issues/451
- **Этап:** spec (PROCESS.md §2.4)
- **Заход:** r2 · блокирующих циклов израсходовано 1 из 4 (зелёный вердикт бюджет
не тратит, #227)
- **Материал:** `docs/specs/451-render-performance.md`, коммит `2bf65d8bc894c223a3fd3098cac66d0af22fcfcb`
(`git rev-parse HEAD` на момент вывода), ветка `issue/451-render-performance`
- **Вердикт:** зелёный · заход r2 · блокирующих циклов 1/4 · High: 0 · Medium: 0
## Скоуп ревью r2 (по дельте, PROCESS.md §2.10)
Предыдущий раунд — `docs/reviews/SPEC-REVIEW-451-r1.md`, вердикт жёлтый, High 0 /
Medium 3, материал зафиксирован на коммите `71c3360363991a042fbf3d84a6aeb4ecb772c83f`
(SHA резолвится и сейчас, это прямой предок текущего HEAD — `git merge-base
--is-ancestor 71c33603 HEAD` истинно, находка «мёртвый SHA» не применима).
Дельта раунда — `git diff 71c33603..2bf65d8b -- docs/specs/451-render-performance.md`:
7 хунков, все внутри одного файла ТЗ, ни один не расширяет скоуп и не меняет
контракт поведения (Q1/Q2, AC1-AC12, §6-9 не тронуты). Автор сам объявил это
как «правки по SPEC-REVIEW r1» одним комментарием, перечислив M1/M2/M3 — дельта
подтверждена построчно и совпадает с заявленным без остатка.
Разбор в r2 сокращён до дельты: заново проверены только участки текста, которые
правка задевает (M1/M2/M3 и их непосредственный контекст — §0 «Связано», §11.3,
AC13, §13.3, §14 п.7, §15). Продуктовая рамка (§1 сценарий, §2 что видит
человек, §7.1 обязательные разделы, AC1-AC12, §18 «принято предположительно»)
не переоткрывалась — основание в разделе «Унаследовано из r1» ниже.
## Как проверялось
1. Прочитан текст всех трёх находок r1 и комментарий автора «Исправления по
SPEC-REVIEW r1» в issue #451.
2. Получен точный дифф правки: `git diff 71c3360363991a042fbf3d84a6aeb4ecb772c83f..2bf65d8bc894c223a3fd3098cac66d0af22fcfcb -- docs/specs/451-render-performance.md`.
3. Для каждой из M1/M2/M3 подтверждено построчно, что упомянутая проблемная
строка либо удалена, либо заменена корректной ссылкой (см. таблицу ниже).
4. Проверено отсутствие остаточных упоминаний удалённых сущностей во всём
файле: `grep -n "PERFORMANCE.md\|SCREENSHOTS.md\|5 538\|5538"
docs/specs/451-render-performance.md` — ноль совпадений вне уже
рассмотренных строк §11.3 (сами числа `3 432`/`98,9`/`4,2` присутствуют
намеренно, как новое обоснование).
5. Новое обоснование `hardMaxMs` в §11.3 сверено с телом issue #451:
`3 432 мс` — итоговая сумма из строки «Перетаскивание: суммарно в
обновлениях» таблицы A/B-эксперимента; `98,9 мс` — среднее обновление
сценария «Наведение мыши (5 с)» из исходной таблицы замеров. Оба числа
реальны и присутствуют в issue дословно (сверено `gh issue view 451
--json body,comments`), в отличие от снятого `5 538 мс`, для которого r1
не нашёл источника.
6. Арифметика новой фразы «500 мс на 120 moves… не более 4,2 мс на событие»
проверена: 500/120 = 4,1(6) — округление в сторону ceiling («не более 4,2»)
математически корректно как верхняя граница, хоть и не самое плотное число;
это не искажает вывод и не является новой непрослеживаемой догадкой.
7. Проверено, что коммит `2bf65d8b` не тронул ничего, кроме
`docs/specs/451-render-performance.md` (`git show --stat 2bf65d8b`), и несёт
корректные трейлеры `Issue: #451` / `User-Visible: no` — правка ТЗ не
является видимым пользователю поведением, `no` уместен.
8. Дважды сверено, что дельта локальна и не подпадает ни под одно условие
«разбор остаётся полным» из §2.10: `dev` не ушёл вперёд (задача ветвится от
него же, ребейза не было), контракт поведения не менялся, новая подсистема
не затронута, объём дельты (12 строк добавлено/удалено в одном файле) на
порядки меньше исходного ТЗ (515 строк).
Гейты (`typecheck`/`test`/`build`/смоки/perf) не прогонялись — на этапе `spec`
продуктового кода нет, диапазон изменений всей задачи по-прежнему только
`docs/specs/**` и `docs/reviews/**`. Это то же осознанное решение, что и в r1,
не пропуск.
## Закрытие раунда r1
| Находка | Чем закрыта | Где это видно |
|---|---|---|
| **M1** — `docs/PERFORMANCE.md` назван обязательным артефактом, хотя не существует | Все три упоминания удалены, единственным каноном оставлен существующий `demo/performance/README.md` | `docs/specs/451-render-performance.md:13` (Связано), AC13 (строка 392), §14 п.7 (строка 455), §15 (строка 472) — `docs/PERFORMANCE.md` отсутствует во всём файле (`grep` ноль совпадений) |
| **M2** — `docs/SCREENSHOTS.md` в §13.3 не существует | Ссылка заменена реальным процессом: `PROCESS.md` §8, `demo/docs/capture.mjs`, `npm run docs:accept` | `docs/specs/451-render-performance.md:430-431` |
| **M3** — верхняя граница «5 538 мс» в §11.3 не подтверждена ни одним источником | Число снято; обоснование `hardMaxMs` теперь опирается только на дважды процитированные в issue числа — `3 432 мс` (pan, суммарно) и `98,9 мс` (hover, среднее), с явным выводом целевого значения 4,2 мс/событие | `docs/specs/451-render-performance.md:329-334`; оба числа найдены в issue #451 (`gh issue view --json body,comments`) |
Все три находки закрыты правкой текста, а не заявлением автора — проверено
чтением итогового файла, не с чужих слов.
## Унаследовано из r1
Без повторной проверки в r2 принято всё, чего дельта не касается — документ
`docs/reviews/SPEC-REVIEW-451-r1.md`, материал `71c3360363991a042fbf3d84a6aeb4ecb772c83f`
(SHA живой, подтверждён `git merge-base --is-ancestor` выше):
- обязательные разделы §7.1 присутствуют и в правильном порядке (сценарий,
что видит человек, проблема, скоуп/не-скоуп, контракт, UX/touch/a11y, модель
данных, performance, AC1-AC13, план тестов, план реализации, release-
артефакты, риски, откат, §18);
- продуктовые решения владельца Q1 (deferred-last-wins после
`pointerup`/`pointercancel`) и Q2 (обычный hover в скоупе, лёгкий слой)
корректно перенесены в контракт §6.3/§8.3 и не переоткрываются как вопросы;
- диагноз причин A/B/C в §3 построчно сверен с `dev` в r1 и не является
догадкой (диагностика в `_renderBody`, `_view`/`_zoom` как `state: true`,
отсутствие `shouldUpdate`, единый источник трёх `data-*` атрибутов);
- non-scope (§5) очерчивает границу без расширения при реализации;
- AC1-AC13 пронумерованы, метод доказательства указан для каждого, проверяемы
структурными assertions §11.2, а не субъективными оценками;
- «один жест — один результат» симметричен для трёх редакторов через §8.4;
- откат (§17) содержит конкретный механизм, а не формальность;
- ссылки на связанные issue (#34, #82, #137, #156, #380, #396, #449)
существуют и релевантны.
Дельта r2 ни одного из этих пунктов не задевает: правки лежат только в трёх
изолированных фрагментах (ссылки на несуществующие файлы и одно число), ни один
AC, продуктовое решение или обязательный раздел не переписаны.
## Находки
Нет. High: 0. Medium: 0. Новых догадок, выданных за факт, дельта не внесла —
оба новых числа (`3 432`, `98,9`) прослеживаются к issue дословно.
## Что не проверялось и почему
- **Продуктовый код** — по-прежнему не существует для #451; гейты `spec` этого
не требуют.
- **Полнота dependency-списка §6.2, реалистичность бюджетов §11.3 в рантайме,
полная матрица редакторских жестов** — те же три пункта, что и в r1,
делегированы на код-ревью (§18 ТЗ явно называет их «принято предположительно»
либо доказываемыми `unit`/`performance` на этапе реализации); дельта r2 их не
меняла и не должна была.
- **Арифметическая точность округления 4,1(6)→4,2** проверена вручную (см. п.6
выше), отдельного гейта для этого нет и не требуется — это текстовое
обоснование, а не защитный AC.
## Материал раунда
- Ветка: `issue/451-render-performance`
- HEAD на момент вывода: `2bf65d8bc894c223a3fd3098cac66d0af22fcfcb`
- Предыдущий SHA (r1): `71c3360363991a042fbf3d84a6aeb4ecb772c83f` — живой,
прямой предок текущего HEAD
- Дерево материала: `docs/specs/451-render-performance.md` (единственный
изменённый в дельте файл)
- Команда поиска дельты: `git diff 71c3360363991a042fbf3d84a6aeb4ecb772c83f..2bf65d8bc894c223a3fd3098cac66d0af22fcfcb -- docs/specs/451-render-performance.md`
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/451-render-performance`, коммит `2bf65d8bc894` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `125aadd5cb53e6af3ffd14998c20855dbfd2d503`
```
git log --all --format='%H %T' | grep 125aadd5cb53
```
- ТЗ `docs/specs/451-render-performance.md`, блоб `7c323a29110974aae369077214b9e2a74d9387c1`
```
git log --all --find-object=7c323a29110974aae369077214b9e2a74d9387c1 -- docs/specs/451-render-performance.md
```
+517
View File
@@ -0,0 +1,517 @@
# ТЗ #451 — фильтрация render и лёгкий live-слой взаимодействий
- **Issue:** https://github.com/Matysh/houseplan-card/issues/451
- **Редакция:** первая редакция для независимого ревью; статус определяется только метками issue
- **Тип / приоритет:** bug / P1
- **Оценка:** пользовательская ценность 10/10; ценность для разработки 10/10;
сложность и риск 9/10
- **Область:** общий lifecycle `houseplan-card`, HA render snapshot и registry,
View, редакторы Плана/Устройств/Подложки, камера и continuous interactions,
diagnostics, performance harness
- **Модель данных:** без новых полей, миграции и backend-изменений
- **Связано:** #34, #82, #137, #156, #380, #396, #449,
`demo/performance/README.md`
## 1. Сценарий
**Персона:** администратор дома, который использует средний или большой план в
Home Assistant на desktop, планшете или настенной панели.
**Поверхности и момент:** обычный View и все три редактора во время движения
мыши, pan/pinch/zoom, рисования, размещения, перетаскивания, вращения или
resize; также обычная работа карточки, когда HA присылает фоновые state ticks.
На измеренном плане с 5 пространствами, 139 маркерами и 144 устройствами один
полный update занимает десятки миллисекунд. Hover запускает его почти на каждое
движение указателя, pan дал 76 полных updates за четыре протяжки, а нерелевантные
HA ticks продолжают делать ту же работу в покое. Пользователь видит рывки и
замирания вплоть до 451 мс, хотя неподвижная уже нарисованная сцена сама по себе
не требует постоянной работы.
## 2. Что человек увидит до и после
**До:** план отстаёт от указателя, hover и редакторские preview дёргаются, а
фоновые изменения посторонних HA entities могут вызвать заметный фриз.
**После:** тот же план, те же эффекты и те же результаты действий двигаются
плавно; HA-изменения, относящиеся к плану, остаются актуальными, а посторонние
изменения не заставляют карточку заново строить всю сцену.
Преднамеренных визуальных изменений нет. После завершения любого жеста
канонический итог и итоговый кадр совпадают с текущим поведением.
## 3. Проблема и установленные причины
### 3.1 Диагностика находится в горячем render-path
`_renderBody()` безусловно вызывает `houseplanDiagnostics()`. Метод снова
обходит все сохранённые HA-привязки и вызывает `_bindingStatus()` для каждой,
хотя render использует результат лишь в трёх диагностических `data-*`
атрибутах корневого `ha-card`. На боевом плане это 99 resolutions и около
20–27 мс на каждый render во всех четырёх режимах.
Проверенный на живой странице cache одного результата уменьшил стоимость
принудительного render примерно вдвое, а суммарные Long Tasks pan — на 70 %.
Однако cache «навсегда» неверен: значения должны меняться при изменении
registry, config или доступности привязки.
### 3.2 Continuous state реактивно перестраивает всю карточку
`_view`, `_zoom`, hover, draft и большая часть drag/preview state объявлены Lit
state. Их изменение на каждом pointer/animation step планирует полный проход
`HouseplanCard.render()`: комнаты, стены, проёмы, декор, Glow, солнце,
устройства и подписи строятся заново, хотя между соседними шагами меняется
только камера или небольшой интерактивный слой.
Уже существующий fast path initial Plan snap hover решает один частный случай,
но не создаёт общего контракта для View и редакторов.
### 3.3 Любой новый объект `hass` считается причиной render
Карточка не отличает изменение используемой планом entity от изменения одной
из сотен посторонних entities. Вместе с render сейчас выполняется и
операционная обработка HA snapshot: registry authority, reconnect/load,
activity и vacuum histories, device rebuild и visual-continuity snapshot.
Просто вернуть `false` из `shouldUpdate()` недостаточно: это могло бы убрать
видимый render вместе с обязательной обработкой входящего состояния.
### 3.4 Performance gate проверяет другие сценарии
Абсолютные `hardMaxMs` уже есть. Пробел состоит не в отсутствии абсолютного
механизма, а в сценариях: `panZoomMs` измеряет один wheel transition,
`stateUpdateMs` меняет участвующую entity, а настоящий drag, View hover,
нерелевантный HA tick и количество тяжёлых render-проходов не проверяются.
Постоянно дорогой путь поэтому может быть одинаковым в base и candidate и
оставаться зелёным.
## 4. Scope
В issue входят:
1. Разделение приёма каждого нового `hass` snapshot, операционной обработки и
решения о визуальной инвалидизации.
2. Явный dependency projection всех HA данных, которые использует план или
открытая UI-поверхность, и пропуск полного render для нерелевантного tick.
3. Cache статической части diagnostics с точной инвалидизацией; публичный
support-report и три `data-*` атрибута остаются актуальными.
4. Единый lightweight live-interaction путь для камеры, hover и непрерывных
preview во View и трёх редакторах.
5. Coalescing pointer/animation updates до не более одного лёгкого paint на
animation frame без повторной шаблонизации тяжёлой сцены.
6. Один полный reconciliation render после commit/cancel жеста; применение
последнего отложенного релевантного HA snapshot в том же итоговом кадре.
7. Сохранение desktop, touch/pen, flat/hidden-iso, kiosk, fixed-floor,
visibility/reconnect и lazy-runtime контрактов.
8. Новый performance-профиль или совместимое расширение harness с настоящими
pointer series, абсолютными потолками, Long Task и структурными assertions.
9. Unit, targeted smoke, canonical screenshots/golden и release artifacts.
## 5. Non-scope
Не входят:
- изменение внешнего вида комнат, стен, проёмов, устройств, подсказок, Glow,
солнечных лучей, пылесосов, декора или hidden iso;
- изменение hit areas, snap tolerance, grid, gesture thresholds, animation
duration/easing, click/double-click/long-press или commit/cancel semantics;
- виртуализация SVG/DOM, spatial index, изменение числа отображаемых объектов;
- оптимизация конкретных geometry algorithms, room labels или decor renderer,
кроме прекращения их повторного вызова без причины;
- изменение persisted config/layout, websocket/backend API, schema,
import/export или миграция данных;
- ослабление существующих relative/absolute performance budgets;
- публичная настройка, feature flag или новое пользовательское сообщение.
## 6. Контракт входящих HA snapshots
### 6.1 Приём не равен render
Каждое присваивание `card.hass = next` обязано обновить актуальную ссылку на HA
и пройти безопасный intake, даже если visual render не нужен. Intake продолжает:
- отслеживать connection/reconnect и registry authority;
- запускать начальную/повторную загрузку, когда она требуется;
- поддерживать device roster, finite activity runtime и vacuum telemetry/trail;
- обновлять данные, которыми воспользуются event handlers и HA service calls;
- поддерживать visual-continuity lifecycle без публикации половины кадра.
Пропуск visual render не имеет права пропустить подписку, history point,
terminal activity edge, vacuum sample, reconnect или capability change.
### 6.2 Dependency projection
Для решения о render существует один канонический набор зависимостей, а не
отдельные списки в фильтре и в renderer. В него входят как минимум:
- entity/device/area bindings всех сохранённых маркеров и их `controls`;
- room temperature/humidity sources;
- opening contact/lock references;
- entities в live-text;
- `sun.sun`, light/Glow sources, vacuum source/map/telemetry references;
- entities и registry metadata, необходимые открытому dialog/picker/info card;
- active/disabled/removed registry resolution для bindings;
- top-level HA значения, влияющие на язык, локаль, units, theme, user/write
permissions, capabilities, connection и frontend formatting.
Entity state считается изменившимся, если для dependency id изменилось
наличие или identity соответствующего HA state object. Это включает изменение
`state`, attributes, timestamps и unavailable/recovery без дорогого deep
comparison. Изменение нерелевантной entity не инвалидирует visual frame.
Dependency set пересобирается после config/layout/marker/space/dialog/registry
изменения до следующего решения о фильтрации. Если классификатор не может
доказать нерелевантность snapshot, он fail-open: разрешает render.
### 6.3 Обычный режим и активный жест
- Вне continuous interaction релевантный HA snapshot публикуется без новой
намеренной задержки — в обычном ближайшем Lit update.
- Нерелевантный HA snapshot не вызывает `HouseplanCard.render()` и не меняет
тяжёлый DOM, но становится актуальным для последующих действий и intake.
- Во время активного pan/pinch/resize/drag/draft релевантный HA snapshot не
прерывает лёгкий live paint. Сохраняется только последний snapshot; сразу
после `pointerup` или `pointercancel` он входит в единственный итоговый full
render. Промежуточные snapshots не проигрываются кадр за кадром.
- `lostpointercapture`, уход со страницы, смена mode/space и structural config
change завершают либо отменяют interaction штатным общим terminal path и не
оставляют pending snapshot навсегда. Структурное изменение не откладывается,
если продолжение жеста с ним небезопасно.
- HA actions и safety checks всегда читают последний принятый `hass`, а не
отложенный visual snapshot.
## 7. Контракт diagnostics cache
1. Полный обход marker bindings не выполняется из `_renderBody()` и не
повторяется на camera/hover/editor pointer step.
2. Cache хранит статический результат registry diagnostics и binding counts.
Он инвалидируется при изменении marker/config binding lifecycle, registry
revision/authority, active/disabled/removed metadata или наличия state,
влияющего на binding status.
3. Обычное изменение значения уже доступной entity не инвалидирует binding
counts, если её классификация `active/ha_disabled/orphaned/unverified` не
могла измениться.
4. Три корневых атрибута сохраняют текущие имена и значения:
`data-ha-registry-access`, `data-ha-disabled-bindings`,
`data-ha-unverified-bindings`.
5. Публичный `houseplanDiagnostics()` сохраняет форму и redaction. Динамический
`lastSuccessAgeMs` вычисляется в момент вызова поверх cached core; для его
роста не запускается timer и не инвалидируется plan render.
6. После invalidation новый результат вычисляется не более одного раза до
следующей смены dependency, независимо от количества render requests.
## 8. Контракт lightweight live-interaction слоя
### 8.1 Граница тяжёлой сцены
Тяжёлая сцена — room fills/outlines, physical walls, openings, saved decor,
Glow/sun, device faces, room labels, vacuum trails и hidden-iso geometry — не
перешаблонизируется из-за очередного pointermove или camera animation frame.
Её DOM identity сохраняется в пределах interaction, если нет отдельной
структурной причины для rebuild.
Вызов `HouseplanCard.render()` считается full render независимо от того, дал ли
Lit затем минимальный DOM diff. Рендер отдельного lightweight child/layer full
render карточки не считается.
### 8.2 Камера
Pan, pinch, wheel/camera transition, zoom buttons, room focus и double-fit
обновляют все участвующие SVG `viewBox`, HTML overlay projection и зависящие от
камеры размеры согласованно в одном RAF-coalesced live paint. Flat и hidden iso
не расходятся; tooltip, device layer, room labels, locks, measure labels,
vacuum puck и editor chrome остаются совмещены с планом.
Canonical `_view`/`_zoom` продолжают отражать реально показанный кадр, чтобы
retarget/cancel/persistence контракты #82/#396/#449 не получили stale start.
На terminal state выполняется один full reconciliation render и штатное
сохранение viewport там, где оно происходило раньше.
### 8.3 Hover
Принятый владельцем Q2 включает обычный hover:
- room fill/physical outline и room tooltip сохраняют нынешние content,
visibility, pointer modality и настройку отключения tooltip;
- device tooltip/LQI/temperature/humidity и текущие CSS hover states остаются;
- координата tooltip может обновляться каждый RAF, не вызывая full render;
- leave, touch suppression, mode/space switch, visibility change и removal
очищают lightweight hover без ghost overlay;
- hover никогда не меняет config/layout и не делает websocket writes.
### 8.4 Редакторские continuous interactions
Один и тот же принцип применяется ко всем состояниям, которые меняются на
pointermove до commit:
| Поверхность | Live-содержимое |
|---|---|
| План | wall chain/rubber-band, snap/conflict marker, column/opening preview и dimensions, wall/room resize preview и measurements, physical move/rotate |
| Устройства | marker drag/position preview, align guides и связанный tooltip |
| Подложка | line/shape draft, decor/furniture/image move/resize/rotate, placement preview, backdrop transform и measurements |
Pointermove меняет только соответствующий lightweight layer или transform.
Pointerup сохраняет тот же canonical результат, history entry и write, что до
#451; cancel восстанавливает то же исходное состояние и не создаёт write.
Pointerdown может выполнить отдельный full render, если действительно меняет
selection/tool UI, но последующие move-события не повторяют его.
Все movement sources coalesce: между двумя animation frames применяется
последняя позиция, но commit повторно использует последнюю canonical event
coordinate/state, поэтому coalescing не теряет конечную точку.
## 9. UX, touch и accessibility
- Внешний вид до/после settled state должен быть pixel-equivalent с текущим
`dev`; намеренных golden diffs нет.
- Mouse, touch и pen сохраняют pointer capture, pan threshold, pinch ownership,
click suppression и `pointercancel` поведение.
- Hover остаётся только на устройствах с fine hover capability; touch не
получает синтетический ghost hover.
- Keyboard/Escape/Ctrl+Z и focus order не меняются.
- Reduced motion сохраняет atomic camera result; forced colours, dark/light,
kiosk, fixed-floor и read-only не получают отдельной ветки поведения.
- Новых текстов, контролов, ARIA-элементов и i18n keys нет.
## 10. Модель данных, совместимость и безопасность
- Persisted config/layout и backend payload не меняются; migration/write-back
отсутствуют.
- Старые планы автоматически получают ускорение после обновления frontend.
- Оптимизация не изменяет HA service calls, permission checks, destructive
confirmations, registry redaction или support-report contents.
- Не вводятся worker, network request, dependency или глобальный shared cache
между экземплярами карточки.
- Несколько карточек на dashboard имеют независимые interaction queues,
dependency sets и diagnostics cache.
- Disconnect удаляет RAF/listener/observer и pending interaction state; warm
remount не переносит незавершённый gesture в новый экземпляр.
## 11. Performance и наблюдаемость
### 11.1 Новый профиль
Добавляется отдельный `large-house-interaction-v1` на существующем
детерминированном large-house fixture, чтобы не менять смысл
`large-house-v1`. Один runner/harness обязан оставаться способен измерить base
до #451 и candidate; новые private поля сначала объявляются optional с честным
fallback либо счётчики собираются внешним instrumentation.
После warm-up профиль выполняет:
1. 120 View hover moves по room/device/miss;
2. четыре pan drag по 20 moves и terminal pointerup;
3. camera wheel/transition и pinch series;
4. не менее трёх representative editor series: Plan draw/snap, room resize и
Decor furniture/shape transform; targeted smoke покрывает остальную матрицу;
5. 30 нерелевантных HA ticks и один релевантный tick;
6. релевантный HA tick в середине drag с проверкой deferred-last-wins frame.
### 11.2 Структурные assertions
Для измерительного окна после начального settled frame:
- 120 hover moves: **0** full renders;
- каждое pan/pinch/editor move-series: **0** full renders между start и terminal
event и не более **1** terminal full render;
- 30 нерелевантных HA ticks: **0** full renders;
- один релевантный HA tick вне gesture: ровно **1** full render;
- несколько релевантных ticks внутри gesture: **0** промежуточных и ровно
**1** terminal full render с последним состоянием;
- camera/hover/editor windows: **0** полных marker-binding diagnostic scans;
- static heavy-scene node identity/count, config, layout и websocket writes
остаются стабильны; preview commit/cancel checks выполняются отдельно.
Assertions fail the runner независимо от timing result. RAF paints и renders
выделенного lightweight child считаются отдельными метриками и не маскируются.
### 11.3 Абсолютные цели
Для профиля при тех же CI browser/runtime условиях задаются base-relative
пределы и следующие bootstrap `hardMaxMs`. Они намеренно существенно ниже
опубликованного в issue времени полных updates одного pan-сценария — 3 432 мс —
и согласованы с существующим `panZoomMs: 500 ms`. Для hover потолок 500 мс на
120 moves означает в среднем не более 4,2 мс на событие против замеренных в
issue 98,9 мс на полный update:
| Метрика | Абсолютный потолок |
|---|---:|
| 120 View hover moves | 500 мс |
| 4 × 20 pan moves + terminal | 500 мс |
| wheel/pinch camera series | 500 мс |
| 120 representative editor moves + terminal | 750 мс |
| 30 нерелевантных HA ticks | 250 мс |
Для каждого pointer-series дополнительно: `maxSingleLongTaskMs <= 150`,
`longTaskTotalMs <= 300`, `longTaskCount <= 3`. Эти потолки — bootstrap safety
limits, а не разрешение расходовать весь budget; relative comparison остаётся.
Ослабление существующих budgets запрещено. Первый paired Linux artifact должен
быть описан в review/release evidence; ужесточение после него допустимо отдельной
обоснованной правкой, ослабление — только через новый review.
## 12. Acceptance criteria
- **AC1 (`unit` + code review; разработчик/ревьюер):** HA intake отделён от
visual invalidation; нерелевантный snapshot обновляет актуальный `hass` и
lifecycle/history, но не вызывает `HouseplanCard.render()`.
- **AC2 (`unit` + targeted smoke; разработчик):** изменение каждой категории
dependency из §6.2 вызывает актуальный render, а изменение посторонней entity
— нет; unknown classifier path fail-open и не оставляет stale UI.
- **AC3 (`unit` + targeted smoke; разработчик):** activity/vacuum samples,
reconnect/load, registry subscription, permissions и action safety работают
при серии skipped visual ticks; action использует последний `hass`.
- **AC4 (`unit` + smoke; разработчик):** diagnostics binding scan отсутствует
в render/pointer path, cache инвалидируется по §7, три `data-*` атрибута и
redacted public report актуальны, `lastSuccessAgeMs` растёт без repaint timer.
- **AC5 (`smoke` + performance; разработчик):** View hover, pan, pinch,
wheel/camera animation и double-fit соблюдают render-count assertions §11.2;
все SVG/HTML/iso layers остаются совмещены на каждом captured frame.
- **AC6 (`smoke` + performance; разработчик):** Plan draw/snap/opening/column,
physical move/rotate, room resize, device drag, decor draft/move/resize/rotate,
furniture/image placement и backdrop transform не вызывают full render на
каждый move; terminal commit/cancel даёт один согласованный full frame.
- **AC7 (`unit` + smoke; разработчик):** серия relevant HA updates во время
gesture применяет только последний snapshot сразу после всех terminal paths;
irrelevant updates не создают pending render.
- **AC8 (`unit` + smoke; разработчик):** coalescing сохраняет конечную pointer
coordinate, snap/geometry result, history, save count, pointer capture,
cancel/undo и no-extra-write контракты для mouse/touch/pen.
- **AC9 (`golden` + canonical screenshots; разработчик/владелец):** settled
View, hover/tooltip, все три редактора, dark/light, forced colours, kiosk,
fixed-floor и hidden iso не имеют непредусмотренных pixel diffs; screenshots
приложены к handoff/review.
- **AC10 (`performance`; разработчик):** новый interaction profile проходит
все structural, absolute и base-relative checks §11; прежние ordinary,
isometric, plan-snap и Glow profiles проходят без ослабления budgets.
- **AC11 (`typecheck` + `unit` + `build`; разработчик):** implementation-loop
gates зелёные; перед beta зелёные golden, smoke и performance по runbook;
Linux CI остаётся каноном полного HA harness.
- **AC12 (`schema/backend/i18n review`; ревьюер):** persisted model, backend,
import/export, HA API, i18n strings и dependencies не изменены; новые caches
bounded per card и очищаются при disconnect.
- **AC13 (`documentation review`; разработчик/ревьюер):**
`demo/performance/README.md` описывает новый профиль, counters, budgets и
локальный запуск; оба changelog содержат пользовательский эффект со ссылкой
на #451.
## 13. План автотестов
### 13.1 Unit
- pure dependency classifier: relevant/irrelevant/missing state, attributes,
all source kinds, top-level locale/theme/units/user/connection and fail-open;
- dependency-set rebuild после marker/config/dialog/registry changes;
- HA intake с skipped render: activity terminal edge, vacuum sample, reconnect,
latest action snapshot и no duplicate processing;
- gesture gate state machine: begin/update coalescing/end/cancel/forced cancel,
latest-relevant-wins и irrelevant-no-pending;
- diagnostics cache keys, one scan per invalidation, state-presence transition,
dynamic age и redaction;
- camera live projection: clamp, anchor, retarget, persistence and cleanup.
### 13.2 Targeted production-bundle smoke
Инструментировать full render count, lightweight paints, diagnostics scans,
heavy node identity, writes и visible state. На production bundle пройти:
- room/device/miss hover and leave;
- mouse pan, wheel, pointercancel; touch pan/pinch and lost capture;
- #449 double-fit и interrupted/retargeted camera transition;
- матрицу interactions из AC6 с несколькими move events, commit и cancel;
- relevant/irrelevant HA ticks вне и внутри gesture;
- space/mode switch, visibility hide/show, disconnect/reconnect и warm remount;
- flat/iso, normal/fixed-floor/kiosk/read-only.
Synthetic events обязаны доходить до production handlers; `0` событий у
handler не принимается как доказательство `0` renders.
### 13.3 Golden, performance и release
- implementation loop: `npm run typecheck`, `npm test`, `npm run build`;
- после изменения `src/**` — canonical screenshots по workflow из `PROCESS.md`
§8 (`demo/docs/capture.mjs`, затем только после проверки `npm run docs:accept`),
inspection и существующие golden; любой diff исследуется;
- перед beta — полный smoke/golden/performance gate по runbook;
- full performance сравнивает candidate/base на одном Linux runner и публикует
отдельный interaction artifact со structural diagnostics;
- три bundle snapshots после release build должны совпадать.
## 14. План реализации и затрагиваемые файлы
Ожидаемые точки изменения:
1. `src/houseplan-card.ts` — HA intake/visual invalidation, diagnostics cache,
gesture terminal reconciliation и подключение live layers.
2. Новый либо существующий узкий `src/*render*`/`src/*interaction*` module —
pure dependency classifier, bounded state machine и RAF scheduler; точные
имена не являются контрактом.
3. `src/houseplan-editor-runtime.ts` — маршрутизация continuous editor previews
в lightweight invalidation без изменения canonical commit logic.
4. Unit tests для classifier/cache/controller и regression tests существующих
camera/editor controllers.
5. Targeted `demo/smoke_*.mjs` — production-bundle render-count и interaction
matrix; обновление smoke runner/package scripts при необходимости.
6. `demo/benchmark_large_house.mjs`, `demo/performance/card-contract.mjs`, новый
budget/profile и evaluator/report plumbing — `large-house-interaction-v1`.
7. `demo/performance/README.md` и оба changelog.
Реализация сначала выделяет pure contracts и diagnostics cache, затем HA
filter, затем camera/hover live path, затем редакторские consumers. Нельзя
массово снять `state: true`, пока у каждого изменяемого состояния нет
проверенного live paint и terminal reconciliation path.
## 15. Release-артефакты
Изменение пользовательски заметно как исправление зависаний, поэтому основной
implementation commit имеет `User-Visible: yes` и в том же коммите обновляет:
- `docs/CHANGELOG.md`;
- `docs/CHANGELOG.ru.md`.
Также обновляются:
- `demo/performance/README.md`;
- budget/profile и machine-readable performance report;
- canonical screenshots/golden evidence без ожидаемого визуального изменения;
- targeted smoke artifacts и full performance comparison.
Новые user-guide/i18n/security artifacts не требуются. Выпуск — обычной beta
по разделу 8 release runbook; issue не закрывается автором реализации.
## 16. Риски и снижение
| Риск | Вероятность / ущерб | Снижение |
|---|---|---|
| Фильтр ошибочно считает relevant HA tick посторонним | средняя / высокий | единый dependency authority, fail-open, матрица всех consumers и dialog states |
| Пропуск render одновременно пропускает activity/vacuum/reconnect intake | высокая / высокий | отдельный intake contract и тесты серий skipped ticks |
| Lightweight DOM расходится с canonical state | средняя / высокий | один state machine, last-event commit и обязательный terminal reconciliation |
| Несколько SVG/HTML/iso слоёв получают разные camera frames | средняя / высокий | один atomic projection writer и frame screenshots |
| Gesture оставляет pending HA/RAF после cancel/disconnect | средняя / высокий | единый terminal cleanup и lifecycle matrix |
| Child render формально скрывает прежнюю тяжёлую работу | средняя / средний | отдельные full/light counters, heavy-node identity и timing gate |
| Новый harness ломает сравнение со старым base | средняя / высокий | новый profile id, optional contract/fallback и тест candidate/base |
| Слишком мягкий timing budget снова пропускает проблему | средняя / высокий | structural assertions обязательны; absolute + relative gates, paired artifact review |
## 17. Откат
Откат — revert implementation commit(ов) #451 и нового interaction profile.
Данные и backend не требуют rollback/migration. Если дефект найден только в
одном live interaction, допускается временно вернуть этому interaction прежний
reactive path отдельным аварийным commit, не отключая безопасный diagnostics
cache и HA filter, но такой fallback обязан иметь issue и regression proof.
## 18. Принято предположительно, поменять свободно
Ниже технические решения, не являющиеся продуктовым выбором владельца:
- dependency projection может переиспользовать `RenderDeviceSnapshot`, но имеет
один источник истины и включает зависимости всего сохранённого плана;
- lightweight слой может быть Lit child component, imperative DOM projection
или их комбинацией, если full/light counters и cleanup contracts соблюдены;
- public diagnostics складывается из cached static core и динамического age;
- точные private field/helper names и разбиение unit/smoke файлов свободны;
- representative editor cases живут в performance profile, полная матрица — в
deterministic smoke, чтобы timing fixture не превращался в функциональный
e2e-комбайн.
Продуктовые решения владельца не предположительны: Q1 — relevant HA updates во
время жеста применяются последним итоговым кадром; Q2 — обычный hover входит в
#451 и сохраняет текущие эффекты через lightweight слой.
+1
View File
@@ -177,6 +177,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#447](https://github.com/Matysh/houseplan-card/issues/447) Наружная грань для мебели и сдвиг декора стрелками | [447-exterior-furniture-snap-keyboard-nudge.md](447-exterior-furniture-snap-keyboard-nudge.md) |
| [#448](https://github.com/Matysh/houseplan-card/issues/448) Единый бессрочный переключатель `hp_alpha` | [448-alpha-switch.md](448-alpha-switch.md) |
| [#449](https://github.com/Matysh/houseplan-card/issues/449) Двойной клик/тап по свободному фону вписывает весь план | [449-double-fit-all.md](449-double-fit-all.md) |
| [#451](https://github.com/Matysh/houseplan-card/issues/451) Фильтрация render и лёгкий live-слой взаимодействий | [451-render-performance.md](451-render-performance.md) |
## P3