Files
houseplan-card/docs/reviews/CODE-REVIEW-293-r1.md
T
claude[bot] 1d4bf9b018
Validate / docs (push) Failing after 25s
Validate / process-workflow-sync (push) Successful in 33s
Validate / provenance (push) Successful in 45s
Validate / process-gate (push) Failing after 42s
Validate / changes (push) Successful in 37s
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 / golden (push) Skipped
Validate / performance_smoke (push) Skipped
Validate / backend (push) Skipped
docs: review document for #293
Issue: #293
User-Visible: no
2026-08-24 15:10:36 +00:00

194 lines
21 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-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, не в скоупе для правки, зафиксирована с обоснованием, почему она не
дефект. Вердикт: зелёный.