Files
houseplan-card/docs/reviews/CODE-REVIEW-451-r3.md
T
2026-09-04 22:11:57 +00:00

224 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```