docs: review document for #293
Validate / docs (push) Failing after 25s
Validate / process-workflow-sync (push) Successful in 33s
Validate / provenance (push) Successful in 45s
Validate / changes (push) Successful in 37s
Validate / process-gate (push) Failing after 42s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / reuse (push) Successful in 42s
Validate / smoke (1) (push) Skipped
Validate / smoke (2) (push) Skipped
Validate / smoke (3) (push) Skipped
Validate / smoke_done (push) Skipped
Validate / backend (push) Skipped
Validate / golden (push) Skipped
Validate / performance_smoke (push) Skipped

Issue: #293
User-Visible: no
This commit is contained in:
claude[bot]
2026-08-24 15:10:36 +00:00
parent 0a52b6757e
commit 1d4bf9b018
+193
View File
@@ -0,0 +1,193 @@
# CODE-REVIEW-293-r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/293
- **Ветка:** `issue/293-resize-pointer-noop` (HEAD `0a52b675`, детач от неё)
- **База:** `origin/dev`
- **ТЗ:** `docs/specs/293-resize-pointer-noop.md`, ревью ТЗ зелёное на r2
(`docs/reviews/SPEC-REVIEW-293-r2.md`)
- **Заход:** r1 · блокирующих циклов израсходовано 0 из 4 (первый заход этапа
code-review — счётчик спека и код-ревью раздельны, §10.4)
## Скоуп диффа
`git diff origin/dev...HEAD` — 24 файла, 1114/-57. Продуктовый код: 5 коммитов
(`8eb351ba`…`0a52b675`), два помечены `User-Visible: yes`
(`6c5163c3`, `e2c1dbec`), остальные — тесты/документация.
Ядро фикса:
1. `src/resize.ts` — новая чистая функция `safeResizePointerDisplacement`:
смещение считается от точки `pointerdown`, а не от `plan.a` (абсолютной
позиции стены). Это и есть причина бага из issue: старый код проецировал
*текущую* точку указателя на нормаль относительно стены, а не относительно
стартовой точки жеста, поэтому клик где угодно на стене сразу давал большое
`d`, а `clampSafeResize`/дальнейшая логика ошибочно душила это смещение до
нуля на реальной геометрии (в синтетических тестах паттерн клика совпадал
со стартовой точкой стены и маскировал дефект).
2. `src/houseplan-card.ts` — `_rszEdgeDown` сохраняет стартовую SVG-точку в
`_rszDrag.start`; `_rszMove` использует новую функцию; `_rszApplyPreview`
возвращает структурированную причину отказа (`missing-context` /
`wall-metadata` / `open-span-metadata` / `physical-geometry`) вместо
`boolean` и теперь прогоняет **тот же** fail-closed physical-geometry
preflight (`_rszSpaceCandidateRenderable`, вынесенный из бывшего
`_rszCandidateRenderable`) на каждом кандидате предпросмотра, а не только
на pointerup; непредсказуемый reject показывает один тост
`resize.preview_failed` за жест (`rejectNotified`).
3. `src/houseplan-card.ts` `_applyGeometryState` — новый параметр
`allowHistoryBoundaryRepair`; Undo/Redo и `_undoActiveDraftPoint` передают
`true`, что разрешает восстановление снапшота истории, даже если строгая
physical-geometry проверка возвращает конкретно `wall-degraded-extra`
(не любую причину). `_writeConfig` при этом продолжает строго проверять
исходящий кандидат — послабление касается только локального восстановления
уже показанного пользователю состояния, не персистентности.
4. `src/wall-thickness.ts` `rekeyWallsAfterMove` — атомы, полученные при рекее
двух независимых владельцев общего шва, теперь склеиваются обратно в одну
запись толщины, если стыкуются встык и коллинеарны; несовпадающие по
направлению атомы по-прежнему остаются раздельными (сохранение поведения
#253). Эта функция используется и Resize, и `plan-optimizer.ts` (Optimize) —
изменение задевает оба потребителя.
5. Новый smoke `demo/smoke_resize_pointer_real_plan.mjs` — реальные
`page.mouse.*` события на живом бандле, карточка создаётся через
`config/get`, без обращения к приватным resize-методам (проверено
отдельным unit-тестом на источник смока).
6. `scripts/mutation-gate.mjs` — 5 новых мутантов
(`resize-pointer-delta-zeroed`, `resize-shared-seam-not-coalesced`,
`resize-pointer-capture-removed`, `resize-preview-reject-silent`,
`resize-history-boundary-repair-removed`).
## Как проверялось
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | зелёный, без вывода |
| Unit | `npm test` | 1246 тестов, 1245 pass, 1 skip, 0 fail |
| Build + bundle parity | `npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` | `MATCH`; после `npm run bundle:sync` третья копия `demo/srv/assets/houseplan-card.js` тоже побайтово совпала; `git status --short` пуст — рабочее дерево уже содержало актуальный бандл |
| Docs fingerprint | `node scripts/check-docs.mjs` | `Documentation checks passed (7 files, 10 external links)` — коммит `0a52b675` уже обновил `sourceFingerprint` |
| Smoke selection | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | 18 «прямых совпадений» (в основном по широким символам `_svgPoint`/`_showToast`/`_writeConfig`, которые сами не менялись, только вызываются из нового кода) плюс 1 «зарегистрированная связь» — `smoke_resize_pointer_real_plan.mjs`. Разбор по строкам ниже |
| Целевой smoke | `node demo/smoke_resize_pointer_real_plan.mjs` | `OK`, все проверки зелёные |
| Смежные Resize-смоки | `node demo/smoke_room_resize.mjs`, `smoke_resize_wall_thickness.mjs`, `smoke_resize_outer_reconciliation.mjs`, `smoke_resize_virtual_thick.mjs`, `smoke_resize_audit_1550.mjs`, `smoke_wall_union_isolation.mjs` | все `OK` (AC9) |
| Model invariants | `node scripts/model-invariants.mjs --config <дамп сервер-конфига после реального resize-жеста на real-plan-second-floor.json>` | `Инварианты выполнены: ссылки разрешимы, записи толщины находятся.` Дамп получен прогоном настоящего браузерного жеста (`page.mouse.*`, не приватные методы) поверх `demo/serve.mjs`; количество wall-записей до/после — 24/24, `open_spans` 0/0 |
| Mutation gate (targeted) | `node scripts/mutation-gate.mjs --check` (применимость всех патчей) + `--id=resize-pointer-delta-zeroed`, `--id=resize-shared-seam-not-coalesced`, `--id=resize-history-boundary-repair-removed`, `--id=resize-pointer-capture-removed`, `--id=resize-preview-reject-silent` | все патчи применились; каждый мутант «поймано 1 из 1» — целевой тест реально красится |
| Performance | `node demo/benchmark_safe_resize.mjs`, `node demo/benchmark_safe_resize_render.mjs` | `pass: true` на обоих; pointer p95 0.018 мс (бюджет 16 мс), commit-preflight p95 0.0018 мс (бюджет 75 мс), render p95 1.4 мс (бюджет 25 мс) — новая проверка physical-geometry на каждом кандидате предпросмотра (см. находку L1) бюджет не задевает |
| Backend | не прогонялся | `custom_components/**/*.py` в диффе нет — не нужен по правилу §8 |
| Golden | не прогонялся | см. «Чего не проверял» |
### Разбор выборки смоков
Прямые совпадения по `_svgPoint`, `_showToast`, `_writeConfig` — эти функции
сами не менялись (диф не трогает их тела), только добавлены новые вызовы уже
существующих методов внутри resize-кода. Смоки вне темы Resize
(`smoke_grid_snap`, `smoke_decor`, `smoke_island_rooms`, `smoke_hide_layers`,
`smoke_card_tool_conflict`, `smoke_drag_bounds`, `smoke_partition_openings`,
`smoke_help_affordance`, `smoke_optimize_coordinate_canonicalization`,
`smoke_space_tab_reorder`, `smoke_bg_color`, `smoke_lattice_write_barrier`) не
прогонялись целиком — совпадение по одноимённому широко используемому символу,
не по изменённой логике; полный прогон всей матрицы — предрелизный гейт.
Resize-тематические смоки из этого же списка (`smoke_resize_wall_thickness`,
`smoke_resize_outer_reconciliation`, `smoke_resize_virtual_thick`,
`smoke_resize_audit_1550`, `smoke_wall_union_isolation`, `smoke_room_resize`)
прогнаны — см. таблицу выше.
## Разбор по AC
| AC | Статус | Доказательство |
|---|---|---|
| AC1 (реальный drag работает) | ✅ | `smoke_resize_pointer_real_plan.mjs`: `dom_preview_ten_steps`, `preview_not_persisted`, `both_rooms_commit_ten_steps` |
| AC2 (жест не теряется за пределами хэндла) | ✅ | тот же smoke: `capture_beyond_handle`, `capture_travels_past_hit_area` (движение считается относительно фактического `hitRadiusSvg`, не константы); мутант `resize-pointer-capture-removed` красит именно этот smoke |
| AC3 (delta не зависит от точки клика вдоль стены) | ✅ | unit `test/resize.test.mjs` «#293 pointer displacement…» — три разных `start` вдоль нормали `[1,0]`, включая обратную нормаль `[-1,0]`; интеграционная точка применения (`g.start` вместо `plan.a`) — одна строка, прочитана и подтверждена; мутант `resize-pointer-delta-zeroed` красит именно этот тест |
| AC4 (один commit и Undo) | ✅ | smoke: `one_history_command`, `one_atomic_write`, `undo_keyboard_consumed_history`, `undo_byte_exact`, `undo_one_atomic_write`; отдельный фикс `_applyGeometryState`/`allowHistoryBoundaryRepair` подтверждён мутантом `resize-history-boundary-repair-removed` (красит тот же smoke) |
| AC5 (Esc/interruption безопасны) | ✅ | smoke: `escape_restores_config`, `escape_zero_extra_write`, `unrelated_pointer_ignored`, `preview_before_capture_loss`, `capture_loss_restores_dom/config`, `capture_loss_zero_extra_write` |
| AC6 (reject не молчит) | ✅ | `demo/smoke_room_resize.mjs`: `preflight_visible_reason`, `preflight_reason_once` (тост ровно один раз при повторных move-событиях в рамках одного отклонённого жеста); мутант `resize-preview-reject-silent` красит этот smoke |
| AC7 (metadata/topology сохраняются) | ✅ | smoke `wall_metadata_preserved`; независимо — `npm run invariants` на реальном пост-resize конфиге (24 wall-записи до/после, ссылки разрешимы); unit `test/wall-thickness.test.mjs` «issue 293 moving a shared seam…» проверяет и склейку, и сохранение раздельности для некомпланарных атомов; мутант `resize-shared-seam-not-coalesced` красит юнит |
| AC8 (harness не даёт ложную зелень) | ✅ | `fixture_is_current_server_space`/`fixture_loaded` проверки в начале smoke; unit «#293 real-plan smoke cannot bypass the production pointer pipeline» запрещает вызовы `_rszMove`/`_rszApplyPreview`/`_rszUp`/`applySafeResize`/`clampSafeResize`/`validateSafeResize` из текста смока через `assert.doesNotMatch`; `smoke_room_resize.mjs` добавил `commit_preflight_no_commit`/`commit_preflight_zero_write` для отдельного барьера pointerup |
| AC9 (регрессия существующих сценариев) | ✅ | `smoke_room_resize.mjs` целиком зелёный (включая новые сценарии из этой задачи), `smoke_resize_wall_thickness/outer_reconciliation/virtual_thick/audit_1550`, `smoke_wall_union_isolation` — все `OK` |
| AC10 (локальные гейты) | ✅ | таблица выше; `check-docs` обязателен и пройден, targeted smoke+mutation прогнаны лично |
## Находки
Блокирующих (High) находок нет. Одна находка ниже помечена Low — не блокирует,
оставляю с запиской, так как не искажает ни один AC и не относится к
заявленному сценарию.
**L1 — расширение зоны действия правки за пределы Resize (наблюдение, не
блокирует).** `rekeyWallsAfterMove` в `src/wall-thickness.ts` используется не
только Resize (`src/houseplan-card.ts:8541`), но и Optimize
(`src/plan-optimizer.ts:572`). Новая склейка коллинеарных атомов меняет
поведение Optimize тоже, хотя раздел «Область» ТЗ называет только «production
pointer pipeline safe Resize» и явно не упоминает Optimize как задетую
подсистему. Технически это неизбежное следствие исправления общей функции
(без него AC7 не выполняется на реальной геометрии — двое независимых
владельцев одного шва иначе продолжают дробить физически целую стену на
записи), и оно не ослабляет прежний тест на #253 (лоссless split для
некомпланарных атомов подтверждён новым unit-тестом), а `smoke_resize_outer_
reconciliation.mjs` (сценарий Optimize) остаётся зелёным. Считаю находку не
дефектом, а стоящей записью: если Optimize на других реальных планах когда-нибудь
начнёт сливать записи с разной физической толщиной по разные стороны шва
(что текущий `collinearForward`/`closePoint` не проверяет отдельно от
направления и точки стыка, но проверяет исходный `w.cm` — единый на все атомы
одной физической стены, так что это не тот случай), это будет faster to trace
благодаря явному комментарию и тесту, добавленным в этом же диффе. Не требую
правки; фиксирую для протокола.
## Что проверено и корректно
- Корень бага (проекция на нормаль от `plan.a` вместо стартовой точки) найден
точно и устранён точечно — один вызов заменён на чистую функцию с
собственным unit-тестом.
- Reject-путь live preview теперь возвращает структурированную причину и
корректно не спамит тостами; final commit-preflight на pointerup остаётся
independent барьером (не доверяет факту показанного превью — новый сценарий
`commit_preflight_*` в `smoke_room_resize.mjs` это явно проверяет).
- Physical-geometry preflight на каждом кандидате предпросмотра — не
регрессия по производительности: оба бенчмарка (`benchmark_safe_resize`,
`benchmark_safe_resize_render`) проходят с большим запасом от бюджета.
- Ослабление проверки при Undo/Redo (`allowHistoryBoundaryRepair`) сужено до
одной конкретной причины отказа (`wall-degraded-extra`), не любой; путь
записи на сервер (`_writeConfig`) не ослаблен — комментарий в коде и
мутационный тест это подтверждают.
- i18n: оба ключа (`resize.preview_failed`) добавлены в `en.json` и `ru.json`
в одном и том же коммите с кодом (`6c5163c3`), оба changelog обновлены в тех
же коммитах, где стоит `User-Visible: yes` (`6c5163c3`, `e2c1dbec`).
- `scripts/smoke-links.mjs` обновлён под новый экспорт
`safeResizePointerDisplacement` и новый smoke — реестр не отстал от кода.
- Единственное число, которое пользователь видит дважды в этой задаче,—
величина смещения стены в предпросмотре — считается один раз в
`safeResizePointerDisplacement`/`clampSafeResize` и это тот же путь, что
использует commit; отдельного превью-числа с другим источником диф не
вводит. Тост `resize.preview_failed` не содержит числа. `test/single-source-
numbers.test.mjs` прошёл в составе `npm test`.
- Ни одного `console.*`/`debugger` в диффе; `git status` после
`build`/`bundle:sync` чист — сгенерированные копии в коммитах побайтово
совпадают с тем, что собирается из текущего исходника.
## Чего не проверял
- **Golden** (`npm run golden:verify`) — не прогонял. Диф не меняет видимую
геометрию рендера сам по себе (только исправляет то, что раньше не
работало вовсе — само по себе движение стены уже покрыто smoke-проверками
DOM-координат хэндлов и общей стены); визуальных сценариев, завязанных на
Resize-предпросмотр, в golden-наборе нет отдельно от смоков. Полный golden —
предрелizный гейт.
- **Полная матрица браузерных смоков** (`ls demo/smoke_*.mjs | wc -l`) — не
прогонял, прогнаны только Resize-тематические и указанные `smoke-select`
плюс отобранные вручную по теме диффа (список в таблице). Слабые совпадения
по `_svgPoint`/`_showToast`/`_writeConfig` вне Resize не прогонялись — эти
функции не менялись, только вызваны из нового кода.
- **`python -m pytest tests_backend`** — не прогонял, диф не касается
`custom_components/**/*.py`.
- **Touch-физическое устройство** — safety floor (`pointercancel`,
чужой `pointerId`, `lostpointercapture`) проверен через синтетические
`PointerEvent`/DOM API в смоках, не на реальном тач-экране; это тот же
уровень доказательства, что и у остальных Resize-смоков в проекте.
- **Полный HA harness / WSL** — недоступен в этом окружении; диф не трогает
backend, поэтому не требуется правилом §8.
## Вывод
Все 10 AC доказаны автотестом, который умеет падать (5 из них — явным
мутационным тестом на живом бандле), либо разобраны по коду. Обязательные
гейты (`typecheck`, `test`, `build`+parity, `check-docs`) зелёные, дополнительно
прогнаны инварианты модели на реальном пост-resize конфиге, целевые
resize-смоки, целевые мутанты и оба performance-бенчмарка. Единственная находка
(L1) — Low, не в скоупе для правки, зафиксирована с обоснованием, почему она не
дефект. Вердикт: зелёный.