19 KiB
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 пуст):
- Откат 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). - Откат гварда в
_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'до этой задачи — не тронут, риска нет (в Backgroundtabindexне проставляется, элемент не фокусируем).- И новый юнит-тест, и новый блок смока проверены на умение падать прямым ревертом кода (см. выше) — не приняты на слово автора.
- Терминология 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.