Files
houseplan-card/docs/reviews/CODE-REVIEW-362-r1.md
T
2026-08-29 06:39:28 +00:00

182 lines
19 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-362-r1
Issue: #362 · Заход: r1 · Трек: `small` (лимит циклов код-ревью — 2, §5) ·
Вердикт: **зелёный** · блокирующих циклов израсходовано 0/2
Ветка `issue/362-decor-device-inert`. Хендофф был на `fafd3601`; конвейер
привёл ветку к `dev` перед ревью (поверх легло 3 коммита `dev`,
`fafd3601 → f57a0c08`, вершина `f57a0c08` "fix: make devices inert in the
Background editor"). Ветка полностью содержит `origin/dev`
(`merge-base(HEAD, origin/dev) == origin/dev`), т.е. это честный fast-forward
rebase без потери коммитов. Разбор — полный (это первый заход, и рёбейз того
требует по §7.2 в любом случае).
## Скоуп
Контракт (тело issue, small-track ТЗ, зелёное спек-ревью r1): в редакторе
Подложка (`mode-decor`) вся presentation-subtree устройства (ядро, 44px
псевдо-хит-область, shell/frame, капсула значения, LQI/бейджи, pulse,
opening-lock сателлиты) должна быть инертна к hit-testing, а нажатие в той же
точке — доставаться активному инструменту Подложки. View и Устройства не
меняются. Задача входит в J4/J6 (`docs/SCOPE.md`) и восстанавливает
инвариант, уже задокументированный в `docs/DECOR-EDITOR.md` (opacity 0.35,
устройства вне magnet targets).
## Как проверялось
Дешёвые гейты (обязательны всегда, прогнаны на `f57a0c08`):
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | PASS, без вывода |
| Unit | `npm test` | PASS — 1511 passed, 1 skipped, 0 failed (`# tests 1512`) |
| Build + bundle sync | `npm run build && npm run bundle:sync`, затем `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` и `cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` | PASS, три копии бандла побайтово идентичны |
| any-бюджет | `node scripts/no-new-any.mjs --base origin/dev --head HEAD` | PASS — «Новых any нет» (32 добавленные строки в 2 файлах) |
| Whitespace | `git diff --check origin/dev...HEAD` | PASS, без вывода |
| Docs fingerprint | `node scripts/check-docs.mjs` (обязателен — diff трогает `src/**`) | **RED** ожидаемо: `ERROR screenshot source fingerprint is stale`. Проверено сравнением: тот же гейт на `origin/dev`@`9431a5ce` (временный `git worktree`, чистая сборка) — **PASS** («Documentation checks passed»). Значит, стал stale именно из-за этой задачи. Это не эквивалент #230/#234 (там шаг вообще не прогоняли и находка терялась): отпечаток считается по всему `src/**`, поэтому *любая* правка фронтенда обязана его инвалидировать — «выбирать тут нечего» (см. текст задания ревью). Пересъёмка — отдельная джоба `Docs screenshots` (`workflow_dispatch`) с последующим `npm run docs:accept -- --reviewed`, коммит делает человек (`PROCESS.md` §8); это не часть цикла реализации и не повод для жёлтого вердикта, но обязана быть сделана до релиза — фиксирую как открытый пункт для релиз-менеджера, а не как находку к этому issue. |
По необходимости (определено diff'ом и AC):
- `node scripts/smoke-select.mjs --base origin/dev --head HEAD` — выборка:
**прямое совпадение (12)**: `smoke_decor.mjs`, `smoke_inert_openings.mjs`,
`smoke_isometric_live_touch.mjs`, `smoke_modes.mjs`,
`smoke_space_settings.mjs`, `smoke_esc_dialogs.mjs`, `smoke_feedback_v2.mjs`,
`smoke_lock_action.mjs`, `smoke_open_passage.mjs`,
`smoke_opening_binding.mjs`, `smoke_registryless_opening.mjs`,
`smoke_touch_tips.mjs`; плюс 29 «слабых» (одно распространённое имя —
`_mode`/`stopPropagation`), решение по ним — не гонять: диф не трогает то
поведение, о котором они спорят (wall/room/cover/furniture инструменты вне
Подложки), совпадение по имени случайно.
Прогнаны все 12 прямых (свежий бандл, `node demo/<name>.mjs` каждый) —
**все PASS**, включая `smoke_decor.mjs`, обновлённый этой задачей.
Опенинг-смоки (`smoke_inert_openings`, `smoke_lock_action`,
`smoke_esc_dialogs`, `smoke_open_passage`, `smoke_opening_binding`,
`smoke_registryless_opening`) прогнаны специально: правка тронула JS-гвард
`.oplock`-обработчика клика (см. находку Low ниже), эти смоки — прямая
regression-проверка того, что `View`-поведение замка (`_openingInfo`,
диалог) не сломано. `smoke_modes.mjs` подтверждает регрессии нет в
`Devices` (`devDragWorks`, `devClickOpensEditor` — оба true).
- `npm run golden:verify` — **не гонялся**. Diff не меняет ни одного
painted-свойства: `opacity`/цвет/geometry устройств не тронуты, единственное
новое CSS-свойство — `pointer-events`, которое не участвует в растеризации.
Единственная удалённая строка (`.stage.mode-decor .oplock { pointer-events:
none }`) была вычислительно избыточна и до правки: базовое правило `.oplock`
уже задаёт `pointer-events: none`, `auto` включается только
`.stage.mode-view .oplock` — вычисленное значение в `mode-decor` не
изменилось. AC4 в самом ТЗ также утверждает «golden без переприёмки».
- `python -m pytest tests_backend` — не гонялся, diff не трогает
`custom_components/**/*.py`.
- `node scripts/model-invariants.mjs` — не гонялся, diff не меняет геометрию,
`layout`, `marker.space` или `open_spans`.
- performance-профили — не названы в AC, не гонялись.
**«Одно число — один источник»**: диф не вводит и не меняет ни одной новой
пользовательской величины. Единственное число рядом с изменением — opacity
`0.35`, которое уже было односточником через `architectureOpacity` в
`_modeVisualState`/`houseplan-card.ts:1212,1394`, потребляемое CSS-переменной
`--hp-mode-architecture-opacity`; эта задача её не трогает.
### Тест умеет падать — проверено фактическим ревертом, не заявлением
Дважды откатывал часть фикса на рабочем дереве (сборка, прогон смока/юнита,
затем восстановление файла из бэкапа, повторная сборка и `cmp` трёх копий
бандла — дерево вернулось чистым, `git status` пуст):
1. Откат CSS-правила `.stage.mode-decor .devlayer, .stage.mode-decor
.devlayer *, .stage.mode-decor .dev::before` к старому
`.stage.mode-decor .devlayer { pointer-events: none; }` →
`node demo/smoke_decor.mjs` **упал** на трёх ассертах:
`decorDeviceCoreFallsThrough`, `decorDeviceCapsuleFallsThrough`,
`decorDeviceSubtreeIsPointerInert` (ожидалось true, получено false).
2. Откат гварда в `_pointerDown` к старому `if (this._mode === 'plan')
return;` → `node demo/smoke_decor.mjs` **упал** на 10 ассертах:
`decorDeviceHandlersFailClosed`, `lineStartsThroughDeviceCapsule`,
`rectStartsThroughDeviceCore`, `textStartsThroughDeviceCore`,
`furniturePlacesThroughDeviceCore` и 5 каскадных (`lineToolStartsDraft` и
др. — устройство перехватило `_drag`, что сломало последующие проверки
инструментов).
`test/device-marker-polish-contract.test.mjs` — новый юнит-тест регексами
сверяет ровно те же строки кода (CSS-правило, порядок гварда/`stopPropagation`
в `_clickDevice`, гварды `_pointerDown/_pointerMove/_pointerUp`, условия в
`@pointerover`/`@pointermove`); ревертнутые выше строки буквально не совпали
бы с этими регексами — падение подтверждено тем же способом, без отдельного
повторного прогона.
## AC — разбор
| AC | Статус | Доказательство |
|---|---|---|
| AC1 (hit-testing инертен для ядра/капсулы) | **Выполнен** | `smoke_decor.mjs`: `decorDeviceCoreFallsThrough`, `decorDeviceCapsuleFallsThrough`, `decorDeviceSubtreeIsPointerInert` — true; подтверждено умением падать (см. выше). CSS-специфичность проверена чтением: `.stage.mode-decor .devlayer *` (0,2,0) бьёт одноклассовые `.dev`/`.device-shell-frame` `pointer-events: auto` (0,1,0) в `devices.styles.ts:144,213`; `::before` — отдельным явным селектором, т.к. `*` не матчит псевдоэлементы. |
| AC2 (Line/Rect/Text/Furniture сквозь устройство) | **Выполнен** | `smoke_decor.mjs`: `lineStartsThroughDeviceCapsule`, `rectStartsThroughDeviceCore`, `textStartsThroughDeviceCore`, `furnitureToolArmedThroughDeviceCore`+`furnitureTargetFallsThroughDeviceCore`+`furniturePlacesThroughDeviceCore` — все true. Умение падать подтверждено (пункт 2 выше). |
| AC3 (нет device-побочных эффектов) | **Выполнен** | `decorDeviceHandlersFailClosed` (serviceCalls=0, wsCalls=0, `_tip`/`_infoCard`/`_drag` не выставлены) при прямом dispatch pointerover/pointerdown/click — true. Прочитан код: `_clickDevice`/`_pointerDown` возвращают до какого-либо побочного эффекта при `_mode ∉ {view, devices}`; `_pointerMove`/`_pointerUp` (drag/selection/`_savePos`) — гвард `_mode !== 'devices'`; long-press таймер живёт только в view-ветке `_pointerDown`, недостижим в Background. |
| AC4 (opacity 0.35 стабилен, смена инструмента не меняет пассивность) | **Выполнен** | `decorDeviceLayerIsTranslucent` (opacity≈0.35) — true; core/capsule fall-through и through-device постановка проверены при 4 разных `_decorTool` (line/rect/text/furniture). Golden не переприёмывался — обоснование в разделе «как проверялось» (paint не меняется). |
| AC5 (View/Devices не деградировали) | **Выполнен** | Регрессионные смоки: `smoke_decor.mjs` `visibleInView`/`inertInView` (для decor-фигур, не устройств — существующий несвязанный ассерт, не трогался); `smoke_modes.mjs` `devDragWorks`/`devClickOpensEditor` — true (Devices editor). View-специфичный `_pointerDown`-путь (long-press → `_infoCard`) не тронут кодом (ветка `if (this._mode === 'view')` идёт первой и делает `return` до нового гварда — гвард лишь сузил, какие ещё режимы доходят дальше). Opening-lock смоки (6 штук, список выше) — все PASS, без регрессии `_openingInfo`/View-клика. |
| AC6 (нет config/i18n/perf/touch изменений) | **Выполнен** | `git diff --stat` — только `src/houseplan-card.ts`, `src/styles/plan.styles.ts`, `demo/smoke_decor.mjs`, `test/*.mjs`, `docs/*`, бандлы; ни одного i18n/schema файла. Новых listener/observer/render-путей нет — все изменения это ранние `return` внутри уже существующих обработчиков плюс чисто декларативный CSS-каскад. Pointer/pen/touch не различаются нигде в изменённом коде (`PointerEvent`-путь общий для всех `pointerType`), поэтому инертность автоматически распространяется на все типы указателя без отдельного touch-контракта. |
## Находки
### Low — верифицировано чтением, фикс не требуется
`_openingInfo`-обработчик клика по `.oplock` (`src/houseplan-card.ts`,
`@click` в `_renderOpeningLocks`) получил тот же паттерн гварда
(`if (this._mode !== 'view') return;` до `stopPropagation()`), хотя контракт
ТЗ явно называет «opening-lock satellites» частью инертной subtree (пункт 1),
а сам AC1 в таблице доказательства называет только `.dev`/`::before`/
`.device-shell-frame`/capsule — без oplock. Ни один AC, ни новый блок
`smoke_decor.mjs` не проверяют это напрямую для `mode-decor`.
Прочитано и проверено: `.oplock` уже до этой задачи имел базовое
`pointer-events: none` (`dialogs.styles.ts:167`, комментарий «inert while
editing; clickable in View (rule below)»), и `auto` включался только
`.stage.mode-view .oplock` (`plan.styles.ts:488`). То есть в реальном браузере
хэндлер и до, и после правки недостижим кликом мыши/тача в `plan`/`devices`/
`decor` — CSS уже резал hit-testing независимо от этой задачи. Изменение —
чисто defense-in-depth, симметричное остальному фиксу (контрактный пункт 6:
«не полагаться только на `pointer-events: none` родителя»), и не меняет
наблюдаемое поведение ни в одном режиме. 6 opening-related смоков (список
выше) подтверждают отсутствие регрессии в View. Не блокирует: находка не
меняет функциональность и не требует правки в этом issue.
### Не найдено High/Medium
Ни одного High. Ни одного Medium — ни в скоупе, ни вне его. Новый issue не
заводится.
## Что проверено и корректно
- Двойная защита (CSS-каскад + fail-closed JS-гварды) реализована так, как
требует контракт: ни один слой не полагается только на другой.
- CSS-специфичность нового правила действительно перебивает все три
`pointer-events: auto` в `devices.styles.ts`, включая псевдоэлемент
`::before`, отдельно вынесенный, поскольку универсальный селектор `*` его
не матчит.
- Гварды в обработчиках копируют структуру существующего `_ctxDevice`
(правый клик — уже был fail-closed вне View до этой задачи, не тронут) —
согласованный паттерн по всей поверхности.
- `_keyDevice` (клавиатурная активация) уже был защищён гвардом `_mode
!== 'view' && _mode !== 'devices'` до этой задачи — не тронут, риска нет
(в Background `tabindex` не проставляется, элемент не фокусируем).
- И новый юнит-тест, и новый блок смока проверены на умение падать прямым
ревертом кода (см. выше) — не приняты на слово автора.
- Терминология CHANGELOG/DECOR-EDITOR.md сверена с `docs/USER-GUIDE.ru.md`
и `docs/USER-GUIDE.md` («Подложка»/Background editor) — совпадает.
- Трейлеры коммита `f57a0c08`: `Issue: #362`, `User-Visible: yes` — оба
changelog (`docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`) правлены в том же
коммите.
- Три копии бандла (`dist`, `custom_components/.../frontend`,
`demo/srv/assets`) побайтово идентичны после `npm run bundle:sync`.
## Чего не проверял
- `npm run golden:verify` и `pytest tests_backend` — не гонялись, обоснование
выше (diff не меняет paint и не трогает Python).
- Полный набор `demo/smoke_*.mjs` (не выборка) — не гонялся, диф не задевает
геометрию/стены/проёмы вне уже прогнанных opening-смоков; полный набор —
предрелизный гейт.
- Performance-профили — не названы в AC, не запускались.
- Пересъёмка docs-скриншотов (гейт `docs` в CI) — не выполнялась и не может
быть выполнена в этом цикле (нужна выделенная джоба `Docs screenshots` на
точном Chromium плюс `--reviewed`-приёмка человеком); зафиксировано выше как
открытый пункт для релиз-менеджера, не как находка к этому issue.