Files
houseplan-card/docs/reviews/CODE-REVIEW-277-r1.md
T
2026-08-24 04:53:17 +00:00

319 lines
30 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-277-r1
- **Issue:** #277 — безопасный Resize без изменения топологии
- **Заход:** r1 (первый код-ревью-заход; спецификация прошла отдельные заходы
SPEC-REVIEW-277-r1 → r2, зелёный, зафиксирован SHA не называется в issue —
восстановлен по коммитам: `9e356504` → правки `1bbd53ee` → зелёный r2)
- **Ветка:** `issue/277-safe-resize`
- **SHA ревью:** `241821c40e9b2f43b79945af3377fc3b3f4ac084` (`origin/dev` на момент
ревью: без новых коммитов после ветвления — полный разбор, не по дельте)
- **Диапазон:** `origin/dev...HEAD`, 11 коммитов (4 — спецификация/ревью ТЗ,
класс C; 7 — реализация)
## Скоуп
Полный разбор (первый код-ревью-заход по этой задаче). Диапазон коммитов
реализации:
| Коммит | Тема | Класс | User-Visible |
|---|---|---|---|
| `6335b0e5` | fix: make room resize topology-safe | A | yes (оба changelog) |
| `cd0a682a` | test: align legacy resize smokes with safe mode | B | no |
| `2e22ed79` | fix: stop resize before lossy metadata rekey | A | yes (оба changelog) |
| `a5a0f6da` | docs: accept safe resize screenshots | C/D | no |
| `03b45f2e` | build: synchronize safe resize release bundle | D | no |
| `b02a3712` | test: align mixed-thickness smoke with safe resize | B | no |
| `241821c4` | test: accept beta.8 safe resize baselines | D | no (`Release:`/`Baseline-Reviewed:` присутствуют) |
Затронутые продуктовые файлы: `src/resize.ts` (+372 строки чистой геометрии),
`src/houseplan-card.ts` (контроллер, рендер, i18n-проводка), `src/styles.ts`
(удаление `.rszcorner/.rszframe/.rszknob`, добавление `.disabled`),
`src/i18n/{en,ru}.json`. Плюс тесты, смоки, mutation-gate, benchmark, golden,
9 файлов документации, оба changelog.
Все коммиты несут `Issue: #277` и корректный `User-Visible`; оба
`User-Visible: yes` коммита правят `docs/CHANGELOG.md` и `.ru.md` в том же
коммите. Коммит с `demo/golden/baselines/**` несёт `Release: v1.67.0-beta.8` и
`Baseline-Reviewed: <ссылка>` — проверено не только по формату (см. «Гейты»).
## Как проверялось
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | зелёный |
| Unit | `npm test` | 1194/1194 passed, 0 fail |
| Build + 3 копии бандла | `npm run build && npm run bundle:sync` + `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` | зелёный, байт-в-байт; демо-копия (несомитаемая, #255) синхронизирована тем же скриптом |
| Docs fingerprint | `node scripts/check-docs.mjs` | «Documentation checks passed (7 files, 10 external links)» |
| Выбор смоков по дельте | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | 37 «прямых совпадений» (0 «зарегистрированных связей», 0 «неопределённостей») — полный вывод приложен ниже |
| Смоки (37 прямых совпадений) | `node demo/smoke_<name>.mjs` × 37 | все 37 зелёные (список — ниже) |
| Golden | `npm run golden:verify` | 80/80 passed, включая новые `safe-resize-handles-clamp-{light,dark}` |
| Mutation gate (реестр) | `node scripts/mutation-gate.mjs --check` | все ~60 мутантов «ok» (патчи применимы), включая 5 новых `safe-resize-*` |
| Mutation gate (дорогой прогон, только 5 новых мутантов AC14) | `node scripts/mutation-gate.mjs --id=<id>` × 5 | все 5 «поймано 1 из 1» |
| Performance | `npm run benchmark:safe-resize` | `"pass": true`; pointer p95 0.0128 ms (бюджет 16 ms, ≤20% над baseline — candidate на два порядка быстрее historical baseline 3.93 ms); commit-preflight p95 0.002 ms (бюджет 75 ms) |
| Model invariants (общий) | часть `npm test` (`test/model-invariants.test.mjs`) | зелёный |
| Model invariants (конкретная конфигурация) | `node scripts/model-invariants.mjs --config <after-resize export>` — конфигурация собрана вручную через реальный pointer-жест в demo-стенде (комната + стена 20 см + hosted-незав. partition + ordinary opening), после успешного commit | «Инварианты выполнены: ссылки разрешимы, записи толщины находятся» — ключ стены корректно перекодирован на новую позицию, opening и partition не задеты |
| Backend | — | не запускался: diff не трогает `custom_components/**/*.py` |
| CI-провенанс `Baseline-Reviewed` | `gh run view 32689229827` / `--log` для job `golden` | run красный **целиком ожидаемо**: единственная причина — `missing-baseline` для двух новых сцен `safe-resize-handles-clamp-{light,dark}`; остальные 104 сцены и job `frontend` прошли на этом самом SHA. Соответствует описанию коммита `241821c4` и правилу «полный Linux-артефакт», а не «зелёный CI» |
| CI-провенанс `Reviewed artifact` (docs screenshots) | `gh run view 32689026469` | run зелёный, `workflow_dispatch`, соответствует коммиту `a5a0f6da` |
Полный вывод `smoke-select.mjs`:
```
Изменено файлов src/**: 3 · символов проекта на изменённых строках: 104
Матрица: 177 смоков · порог «широкого» символа: больше 35 смоков
Прямое совпадение (37): smoke_junction_patch_resilience, smoke_resize_audit_1550,
smoke_room_resize, smoke_glow, smoke_multiwall_junction, smoke_resize_inner_dimensions,
smoke_wall_key_roundtrip, smoke_multiwall_strip_containment, smoke_partition_openings,
smoke_zero_divider_taper, smoke_bg_color, smoke_grid_snap, smoke_merge_split,
smoke_open_passage, smoke_opening_preview, smoke_pan_any_zoom, smoke_registryless_opening,
smoke_resize_virtual_thick, smoke_resize_wall_thickness, smoke_split_corner_wall,
smoke_wall_junctions, smoke_wall_thickness_transition, smoke_card_tool_conflict,
smoke_device_preview_parity, smoke_drag_bounds, smoke_draw_wall_thickness,
smoke_help_affordance, smoke_hide_layers, smoke_infinite_canvas, smoke_opening_binding,
smoke_optimize_coordinate_canonicalization, smoke_optimize_geometry_preflight,
smoke_plan_drawing_repairs, smoke_plan_snap_overlay, smoke_render_perf,
smoke_space_tab_reorder, smoke_sun_soft.
Зарегистрированная связь: 0. НЕОПРЕДЕЛЁННОСТЬ: 0.
```
Решение по строке: прогнать все 37 — диапазон прямых совпадений умеренный, а
`src/houseplan-card.ts` правился широко (импорт-лист, `_rszApplyPreview`,
`_wallUnionGeometry` reuse), так что «слабые» смоки (`smoke_sun_soft` ←
`axisOf` — общее имя функции) тоже стоило проверить не по названию, а по
факту. Полная матрица (177) не прогонялась — она предрелизный гейт (§8);
данный диапазон полностью покрывает найденные прямые совпадения.
## Находки
### Medium (в скоупе задачи — правится в этой же issue)
**M1. Кэш допустимости (eligibility) на самом деле не memo­изирует дорогую
часть — рендер ручек Resize пересчитывает полный geometry fingerprint для
КАЖДОЙ ручки на КАЖДЫЙ рендер, а не один раз на изменение геометрии.**
`src/houseplan-card.ts:8330-8351` (`_rszResolution`):
```ts
const snap = this._rszDrag?.snap || this._rszSnapshot();
const key = `${this._space}|${this._cellCm}|${this._gridPitch}|${snap}`;
if (!this._rszEligibilityCache || this._rszEligibilityCache.key !== key) {
this._rszEligibilityCache = { key, values: new Map() };
}
```
`_rszSnapshot()` (`:8353-8356`) вызывает `JSON.stringify(this._geometrySnapshot())`,
а `_geometrySnapshot()` (`:7078-7104`) делает `JSON.parse(JSON.stringify(...))`
над rooms/openings/walls/open_spans/room_drafts/partitions/wall_columns/decor
всего пространства. `_renderResizeLayer()` (`:8691` и далее) вызывает
`_rszResolution(r.id, i)` для КАЖДОЙ стены КАЖДОЙ комнаты на каждый вызов
`render()`, то есть указанный fingerprint пересчитывается N раз за один
рендер (N = число ручек), а не один раз. Ключ кэша обязан быть вычислен
ПЕРЕД тем, как узнать, есть ли попадание в кэш — поэтому «дорогая часть»
(fingerprint) выполняется независимо от того, попадает запрос в кэш или нет.
Это напрямую противоречит §13 ТЗ («Eligibility всех handles мемоизируется по
exact geometry fingerprint и не пересчитывает boolean geometry на каждый
render») и `docs/ARCHITECTURE.md`, который повторяет то же утверждение.
Само ТЗ (§13) отдельно называет естественный сигнал инвалидации — «Cache
ограничен активным пространством и очищается при cfg epoch/tool exit» — то
есть уже существующий дешёвый счётчик `this._cfgEpoch` (44 места инкремента,
только на реальных изменениях геометрии) годился бы как ключ кэша практически
бесплатно. Вместо этого ключ строится из полного `JSON.stringify` сцены.
**Воспроизведение** (команды выполнены мной, не автором):
Однопользовательский этаж из штатной фикстуры `demo/fixtures/large-house.mjs`
(`makeLargeHouseFixture().config.spaces[0]`: 20 комнат, 80 ручек, 20
partitions, 14 columns, 167 decor, 49 walls) — это та же «large-house» шкала,
которой пользуется собственный `benchmark:large-house` проекта, то есть не
надуманный крайний случай, а верхняя граница целевого масштаба SCOPE.md
(«house/large flat, 20–200 devices, several floors»).
```
инструмент resize, requestUpdate() без изменения геометрии (эмулирует
посторонний реактивный ререндер — тик устройства, sun-ray и т.п. — при
открытом инструменте Resize), кэш ТЁПЛЫЙ (ничего не инвалидировано):
6 замеров: 148, 137, 134, 130, 131, 129 ms
тот же рендер инструментом 'boundary' (те же 20 комнат/20 partitions/14
columns/167 decor, БЕЗ слоя ручек Resize):
5 замеров: 112, 103, 103, 102, 102 ms
инструмент resize, кэш ХОЛОДНЫЙ на каждом замере (эмулирует изменение
cfgEpoch на каждый тик):
8 замеров: ~200-224 ms
```
Разница «resize минус boundary» (≈ +30-100 мс на рендер) целиком относится к
циклу `_rszResolution`/`_rszSnapshot`, и «тёплый» кэш почти не дешевле
«холодного» (130 мс против 205 мс) — то есть кэш ловит повторный вызов
`resolveSafeResize`, но не ловит повторный fingerprint, который и есть
доминирующая стоимость. Это ключевое отличие от `benchmark_safe_resize.mjs`
(AC13, зелёный): бенчмарк меряет только чистые функции `clampSafeResize`/
`applySafeResize`, минуя именно этот путь — поэтому AC13 numerically проходит,
а реальный путь рендера ручек остаётся непроверенным этим бенчмарком.
**Почему это Medium, не High.** Не искажает геометрию, не создаёт запись,
не блокирует основной safety-контракт задачи (AC1–AC12, AC14–AC17
подтверждены отдельно). Влияет на отзывчивость: при открытом инструменте
Resize на доме такого масштаба каждый посторонний реактивный тик (живое
состояние устройства, sun-ray, час/минута) стоит лишние ~30-100 мс сверх
базовой стоимости рендера — заметный, повторяющийся джанк, а не разовая
проблема. В скоупе задачи: сам кэш и его контракт производительности —
предмет #277, не соседняя подсистема.
**Как чинить (не мой мандат — только для контекста автору):** вычислять
`key`/`snap` один раз на вызов `_renderResizeLayer()` (или на `_cfgEpoch`,
как и предполагает сам текст ТЗ), а не один раз на каждую пару
`(roomId, edge)`.
### Low (снимаю с записью, не блокирует)
**L1. Мёртвый код и устаревший комментарий: `_rszSel` теперь ничего не
рендерит, хотя всё ещё выставляется кликом с комментарием про удалённую
угловую рамку.**
`src/houseplan-card.ts:7386-7389`:
```ts
if (this._tool === 'resize') {
// a click picks the room for the scale frame; handle drags never get here
...
this._rszSel = room?.id || null;
```
Угловая scale-рамка (AC11) удалена из `_renderResizeLayer()` полностью — блок
`const sel = this._rszSel ? ... ; if (sel) { ... }` вычищен вместе с
`.rszcorner/.rszframe/.rszknob`. `_rszSel` продолжает выставляться на клик по
комнате в инструменте Resize и путешествует в view-state (`rszSel` при
сохранении/восстановлении вида), но не имеет ни одного потребителя в рендере.
Комментарий буквально описывает удалённую фичу. Не влияет на поведение —
клик просто ничего не делает визуально, как и раньше при клике по
неинтерактивной точке. Снимаю с записью: чисто косметическая уборка,
безопасно оставить автору на будущее мелкое polish-изменение, блокировать
задачу из-за одной мёртвой строки состояния и комментария нецелесообразно.
## Проверка AC — таблица
| AC | Как доказано | Вердикт |
|---|---|---|
| AC1 | `test/resize.test.mjs` («diagonal and non-perpendicular…»); i18n-тест `resize-production-path.test.mjs`; смок `safe_resize.*` | доказано (тест умеет падать — проверено мутантом `safe-resize-axis-eligibility-bypassed`, 1/1) |
| AC2 | `test/resize.test.mjs` («exactly two existing vertices…», топология вручную сломана в тесте и корректно отклонена); model-invariants на реальном post-commit конфиге (см. выше) | доказано |
| AC3 | `test/resize.test.mjs` («exact shared: two rooms move…»); смок `safe_resize.commit_left/right/third_static` | доказано |
| AC4 | `test/resize.test.mjs` («partial shared and third-owner…», включая фикстуру AC17); мутант `safe-resize-third-room-cascade-enabled` 1/1 | доказано |
| AC5 | `test/resize.test.mjs` («irregular exact pair clamps at the first corner…»); смок `safe_resize.corner_clamped/corner_topology`; мутант `safe-resize-topology-signature-bypassed` 1/1 | доказано |
| AC6 | `test/resize.test.mjs` («side opening stops at the physical jamb…»); мутант `safe-resize-opening-jamb-bypassed` 1/1; golden `safe-resize-handles-clamp-{light,dark}` (просмотрено — обе сцены «passed» в `golden:verify`) | доказано |
| AC7 | `test/resize.test.mjs` («moving opening travels once; hosted moving opening is disabled»); смок `safe_resize.opening_once` | доказано |
| AC8 | смок `safe_resize.preview_moved/preview_not_persisted/commit_left/one_undo`; `test/resize-production-path.test.mjs` (lossy-rekey guard); мутант `safe-resize-commit-preflight-bypassed` 1/1 | доказано |
| AC9 | `_rszCandidateRenderable`/`validateSafeResize` разобраны чтением (проверено чтением, не исполнением — сама структура try/catch + fail-closed на исключении); смок `safe_resize.preflight_no_commit/preflight_zero_write` инжектирует отказ `_checkOptimizeGeometry` и проверяет zero-write | доказано |
| AC10 | смок `safe_resize.undo_exact`; `test/resize.test.mjs` round-trip проверки | доказано |
| AC11 | `test/resize-production-path.test.mjs` (grep-проверка отсутствия старых символов в бандле-исходнике и в стилях); ручной grep `applyRoomScale`/`clampRoomScale`/`shiftSharedSpans`/`simplifyPoly(` в `houseplan-card.ts` — отсутствуют; golden `safe-resize-handles-clamp-*` (визуально нет рамки) | доказано |
| AC12 | `test/resize-production-path.test.mjs` (aria-disabled/tabindex/_rszDisabledKey/cursor); смок `safe_resize.disabled_visible/disabled_reason/disabled_focusable/disabled_no_drag/disabled_zero_write` | доказано |
| AC13 | `npm run benchmark:safe-resize` — `"pass": true`, числа приведены выше | доказано для чистого pointer/preflight-пути geометрии; **НЕ покрывает** путь рендера ручек — см. M1 |
| AC14 | `node scripts/mutation-gate.mjs --id=<5 новых id>` — все 5 «поймано 1 из 1» (прогнано мной, не только `--check`) | доказано |
| AC15 | typecheck/unit/build — зелёные (таблица «Как проверялось»); байт-идентичность бандлов подтверждена `cmp` | доказано |
| AC16 | `docs/RESIZE.md`, `ARCHITECTURE.md`, `CANVAS.md`, `WALL-THICKNESS.md`, `TESTING.md`, `USER-GUIDE.{md,ru.md}`, оба changelog — прочитаны построчно, согласованы с реализацией и друг с другом; `check-docs` зелёный | доказано |
| AC17 | `test/fixtures/resize-safe-regression.json` + unit-тест ссылается на неё (`resize.test.mjs`); `demo/smoke_room_resize.mjs` воспроизводит ту же топологию (совпадающие координаты) реальными pointer-событиями через production-бандл и проверяет disabled/zero-write; `model-invariants` пройден на аналогичном (не буквально том же файле) post-resize экспорте | доказано с оговоркой (Low, не заводится отдельно): смок дублирует геометрию фикстуры инлайн вместо чтения `resize-safe-regression.json` напрямую — при рассинхронизации фикстуры и смока рассинхронизация не будет замечена автоматически. Не блокирует: обе стороны сейчас идентичны и это видно построчным сравнением координат, а объём — одна строка ТЗ, не критерий приёмки. |
## Что проверено и корректно
- Полный geometry pipeline (`resolveSafeResize`/`applySafeResize`/
`validateSafeResize`/`clampSafeResize`) прочитан целиком; порядок причин
disabled соответствует §4 ТЗ ровно (диагональ → side-angle →
duplicate-physical-wall → partial-shared → unequal-shared →
multiple-rooms → thickness-conflict → opening-conflict →
invalid-geometry).
- Старые механизмы (`planEdgeDrag`/`applyEdgeDrag`/`clampEdgeDrag`/
`applyRoomScale`/`clampRoomScale`/`shiftSharedSpans`/`simplifyPoly`)
остаются в `src/resize.ts` только для регрессионной истории тестов
(`test/resize.test.mjs` их использует), но не импортируются в
`houseplan-card.ts` — подтверждено grep’ом и отдельным тестом.
Угловая scale-рамка удалена из рендера и стилей (см. AC11).
- `_rszUp` откатывает превью и не создаёт запись/Undo при провале
`snapshotStillCurrent`/`topologyValid`/`candidateValid` — включает защиту от
гонки с внешним изменением конфига между `pointerdown` и `pointerup`
(`_rszSnapshot() === g.snap`), что закрывает многоклиентский сценарий J6.
`_rszApplyPreview` дополнительно останавливает превью до потери толщины/
virtual-span при перекодировании (коммит `2e22ed79`, добавлен автором до
хендоффа на ревью — не отдельная находка ревью).
Live-исполнение подтверждено вручную: собран реальный `_serverCfg`
(комната + стена 20 см + independent-partition + ordinary opening),
выполнен настоящий pointer-жест через demo-стенд, коммит создал ровно одну
запись Undo (`_geometryHistory.size === 1`), стена корректно перекодирована
на новую позицию с сохранённым `cm`, partition не тронута — и это же
проверено `model-invariants.mjs` (см. таблицу гейтов).
- «Одно число — один источник»: `_rszEdgeLabels()` берёт длины/площадь из
того же кандидата `res.polys`, что коммитится, и через канонические
форматтеры (`formatLength`/`formatArea`/`innerContourForRoom`/`areaM2`,
та же конвенция внутренних граней, что и у карточки комнаты, #233) — не
через отдельный приближённый расчёт для превью.
`test/single-source-numbers.test.mjs` входит в зелёный `npm test`.
- i18n: все 9 disabled-причин плюс `resize.commit_failed` присутствуют и
непусты в en/ru (проверено тестом и вручную); тексты причин не содержат
room id/координат/исключений (§4 ТЗ) — прочитаны все 9×2 строки.
Формулировка `title.markup_resize`/`markup.hint_resize` использует
«ручку» по-русски (не непереведённый «handle»), согласованно с
`USER-GUIDE.ru.md` — учтено замечание L1 из SPEC-REVIEW-277-r2 (это
замечание было по тексту спеки, не по реализации, но реализация ему не
противоречит).
- Риск-таблица §12.1 спеки подтверждена по факту: #276 действительно
`S8-merged` (влился раньше #277, как и предписано порядком merge), #264
действительно остаётся `S1-new` (не начат параллельно, как и предписывал
риск-митигейт), #278 — `S5-ready`, не начат. Порядок #276→#277→#278
соблюдён.
- `Baseline-Reviewed`/`Reviewed artifact` в коммитах `241821c4`/`a5a0f6da`
проверены по факту через `gh run view --log`, не только по формату строки:
оба указывают на реальные прогоны на точном SHA/ветке этой задачи;
«красный» golden-run `32689229827` красен исключительно из-за ожидаемого
`missing-baseline` двух новых сцен — не сокрытая находка.
- Производительность (кроме M1): бенчмарк численно на два порядка лучше
бюджета, budgets/cache-bound соблюдены, кэш `clampSafeResize` ограничен
(`WeakMap`, 4096 записей на план, проверено тестом
`safeResizeCachedDeltaCount`).
- Документация (`RESIZE.md`, `ARCHITECTURE.md`, `CANVAS.md`,
`WALL-THICKNESS.md`, `TESTING.md`, `USER-GUIDE.{md,ru}`, `DEVELOPMENT.md`,
`STATUS.md`) прочитана целиком; описывает именно то поведение, которое
реализовано — включая честные технические формулировки (rekey/preflight/
clamp), а не только пользовательский пересказ.
## Чего не проверял
- Полный набор `demo/smoke_*.mjs` (177 файлов) — предрелизный гейт (§8),
запущены только 37 «прямых совпадений» дельты.
- `python -m pytest tests_backend` — не запускался: diff не касается
`custom_components/**/*.py`.
- Полный `npm run golden:capture`/визуальный человеческий пересмотр всех 80
сцен построчно — доверился «passed/failed» отчёту `golden:verify` и
предметно посмотрел только на факт прохождения двух новых
`safe-resize-handles-clamp-*`; попиксельное сравнение не делал (это работа
`golden:verify`, а не ревьюера).
- WSL/полный HA-харнесс — не касается этой задачи (нет правок Python).
- Реальный приватный экспорт пользователя, вызвавший #277 — не публикуется
по условиям issue; проверена только анонимизированная `resize-safe-
regression.json` и её топология.
- Мутанты вне пятёрки `safe-resize-*` (остальные ~55 в реестре) — не
перепрогонялись полностью (дорогой прогон), только `--check`
(применимость патчей); они не относятся к дельте этой задачи.
## Унаследовано из ревью ТЗ
Код-ревью не наследует находки предыдущего ревью ТЗ автоматически (разные
стадии, разные бюджеты циклов, §10.4 PROCESS.md: «цикл считается по этапу»).
Тем не менее использовано как контекст: `SPEC-REVIEW-277-r2.md` (зелёный,
SHA `4ed94a52`) — все три M1-M3 находки r1 спеки закрыты (риски, «что человек
увидит», AC17-фикстура), обе Low-находки r2 (нейминг «handle»/«ручка»,
непоказанный SHA r1) сняты автором ревью ТЗ с записью, без обязательных
правок. Проверено, что реализация не противоречит финальному тексту ТЗ
(`docs/specs/277-safe-resize.md`, HEAD) — сверено построчно по всем 18
разделам.
## Вердикт
Жёлтый: одна Medium-находка в скоупе задачи (M1), 0 High. AC1–AC12 и
AC14–AC17 доказаны автотестами, которые умеют падать (мутанты 1/1) или живым
воспроизведением через production-бандл; AC13 доказан для чистой геометрии,
но не для пути рендера ручек, где M1 и обнаружена. L1 снимается с записью
(мёртвый код, не блокирует).