Files
houseplan-card/docs/reviews/CODE-REVIEW-564-r1.md
T
2026-09-14 00:38:19 +00:00

126 lines
27 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-564-r1
**Issue:** #564 — «В узкой колонке нажатие достаётся соседнему маркеру: круг 44 px без разрешения перекрытий»
**Заход:** r1 (первый и единственный заход код-ревью на данный момент; циклов ревью ТЗ было потрачено 1/2, циклов код-ревью — 0/4)
**Материал:** `git log --oneline origin/dev..HEAD` / `git diff origin/dev...HEAD`, зафиксировано на точном SHA `0af582daa56632736560df0bff6c78f778618c5a`. Рабочая копия уже на нём; `git fetch`/`checkout` не выполнялись.
## Скоуп
Коммиты в диапазоне:
```
6de854a0 fix: разрешить перекрытия целей маркеров (#564)
f9ed9d2a fix: сохранить точную активацию маркера (#564)
412ca0f6 docs: привязать кадры к исходникам #564
ba74cd15 test: сверить слои Zigbee с арбитражем маркеров (#564)
0af582da test: защитить hover-gate маркеров (#564)
```
(Плюс два более ранних коммита `docs: review document for #564` — публикация документов ревью ТЗ r1/r2, не код.)
Три ранних попытки поставить `S7-code-review` были остановлены гейтом **до** ревью (мутантный Validate дважды, конфликт ребейза один раз) и вернули задачу в `S6-in-progress` без траты циклов ревью — это подтверждено комментариями issue и не требует отдельной проверки здесь: код на момент этих отказов никто не читал.
Изменение вводит единый resolver владения экранной точкой для перекрывающихся 44‑px целей маркеров устройств (`src/device-hit-owner.ts`), подключает его ко всем pointer-путям в `src/houseplan-card.ts` (click, pointerdown/move/up/cancel, hover/tooltip, contextmenu, drag редактора устройств) и переписывает z-index/pointer-events модель в `src/styles/devices.styles.ts` так, чтобы нарисованные капсулы всех маркеров лежали в одном слое выше всех невидимых 44‑px «полов», вместо разрешения по DOM-порядку внутри отдельного stacking context каждого маркера.
Полный набор файлов совпадает с ожидаемым перечнем ТЗ §7.1: `src/device-hit-owner.ts` (новый), `src/houseplan-card.ts`, `src/styles/devices.styles.ts`, `test/device-hit-owner.test.mjs`, `test/device-hit-owner-contract.test.mjs`, `scripts/mutation-registry.mjs`, `demo/smoke_household_journeys.mjs`, `demo/smoke_linked_virtual_light.mjs` (уже существовавший вспомогательный regression-смок с реальным pointer-кликом), `demo/smoke_zigbee_topology_hover.mjs` (правка мутировавшего оракула слоёв, найдена уже после первой попытки ревью), `docs/ARCHITECTURE.md`, `docs/TOUCH-SUPPORT.md`, `docs/TESTING.md`, `docs/CHANGELOG.md`/`.ru.md`. `src/houseplan-editor-runtime.ts` в диффе не тронут — верно согласно ТЗ («resolver здесь не дублируется»): drag в редакторе устройств продолжает получать уже разрешённого владельца из `houseplan-card.ts`.
Файлы класса D (`dist/**`, `custom_components/houseplan/frontend/**`) обновлены синхронно и совпадают с ожидаемым релизным промоушеном обычной задачи (не промоушен-коммит).
## Как проверялось
| Гейт | Команда | Результат |
|---|---|---|
| Дешёвые (typecheck/unit/build/bundle-sync) | подтверждены зелёным Validate на точном SHA `0af582da` (https://github.com/Matysh/houseplan-card/actions/runs/34792398113) | зелёный, не перегонялись повторно (#343) |
| `node scripts/check-docs.mjs` | ручной прогон в ревью | `Documentation checks passed (7 files, 12 external links)` |
| `node scripts/mutation-gate.mjs --id=dense-device-hit-falls-back-to-input-order` | ручной прогон в ревью | `поймано 1 из 1` |
| `node scripts/mutation-gate.mjs --id=device-touch-hover-gate-removed` | ручной прогон в ревью | `поймано 1 из 1` |
| `node scripts/mutation-gate.mjs --id=zigbee-topology-hovered-endpoint-elevation-removed` | ручной прогон в ревью | `поймано 1 из 1` |
| `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | ручной прогон в ревью | 29 прямых совпадений, 41 слабая связь (см. ниже) |
| Смоки (см. ниже) | `npm run build && npm run bundle:sync`, затем `node demo/smoke_<name>.mjs` каждый по отдельности | 15/15 зелёных |
| `npm run docs:accept -- --identical` | заявлено автором в хендоффе, перепроверено по `docs/images/screenshots.json` | `imageSha256` всех 11 кадров не изменился между `dev` и HEAD — байтовая идентичность подтверждена, только `sourceFingerprint`/`sourceSha256` обновились |
### Браузерные смоки
`smoke-select.mjs` напечатал 29 прямых совпадений и 41 слабую связь по изменённым символам (`_deviceDrag`, `_pointerMove`, `_mode`, `_drag`, `_renderDevices`, `_devicePresentation`, `_pointerModality`, `_notePointer`, `_showTip`, `byId`, `_config`, `DevItem` и др.). Прогнаны все смоки из явного AC-набора плюс прямые совпадения, релевантные изменённому pointer/drag/hover пути (drag, режимы, hover/tooltip, pan/zoom, устройства-редактор):
`smoke_household_journeys.mjs` · `smoke_linked_virtual_light.mjs` · `smoke_zigbee_topology_hover.mjs` · `smoke_drag_bounds.mjs` · `smoke_modes.mjs` · `smoke_align_guides.mjs` · `smoke_device_position_history.mjs` · `smoke_grid_snap.mjs` · `smoke_pan_any_zoom.mjs` · `smoke_static_icon.mjs` · `smoke_wireless_controller_parity.mjs` · `smoke_touch_tips.mjs` · `smoke_feedback_v2.mjs` · `smoke_room_tooltip_toggle.mjs` · `smoke_room_fit.mjs`
Все 15 — **зелёные** (собственный прогон, свежий бандл через `npm run build && npm run bundle:sync`).
**Не прогонялись:** остальные прямые совпадения (`smoke_decor.mjs`, `smoke_dialog_footer_width.mjs`, `smoke_houseplan_panel.mjs`, `smoke_junction_patch_resilience.mjs`, `smoke_kiosk.mjs`, `smoke_optional_space_model.mjs`, `smoke_pdf_export.mjs`, `smoke_space_switch_transitions.mjs`, `smoke_space_tab_reorder.mjs`, `smoke_summary_panel_polish.mjs`, `smoke_support_feedback.mjs`, `smoke_controls.mjs`, `smoke_opening_entity_search.mjs`, `smoke_opening_tunnel_fill.mjs`, `smoke_resize_inner_dimensions.mjs`) и все 41 «слабых» связей — совпадение по общеупотребимым именам (`_config`, `_mode`, `byId`) без содержательной связи с изменённым pointer/hit-владением; связанные с ними поверхности (диалоги, панель, junction, PDF, вкладки пространств) не читают и не изменяют `_deviceHits`/`_pointerDown`/`_pointerMove`/`_clickDevice` семантику, затронутую диффом. `smoke_editor_gestures.mjs` и `smoke_long_press_gesture.mjs` (регрессии #563 по имени) не выбраны инструментом и не тронуты диффом — модуль `touch-gesture-click-guard.ts`, который они защищают, в этом дифф не менялся; смоки `smoke_linked_virtual_light.mjs`/`smoke_pan_any_zoom.mjs` из прогнанного набора уже упражняют смежные части того же контракта (реальный pointer-click, pinch/pan) и прошли.
**Не прогонялись (осознанно, по AC10/§10 ТЗ):** `golden`, полный `performance_smoke`, полный набор из 248 смоков — по плану тестирования ТЗ §10 они явно отнесены к пред-релизному гейту, не к этому ревью. `pytest tests_backend` — бэкенд не менялся (файлов `.py` в диффе нет).
## Находки
### M1 (Medium, в скоупе задачи) — AC2 не имеет заявленного browser-smoke доказательства для Icon/Text/Double/legacy и четырёх направлений капсулы
**AC2** (тело issue #564, §9): «Painted core/capsule всегда выигрывает у невидимого floor соседа; во взаимном пересечении painted areas и в пересечении только floors выбирается ближайший core.» Обязательное доказательство по тексту ТЗ: **«Pure unit matrix плюс browser smoke для Icon/Text/Double/legacy и четырёх направлений capsule»**.
Что фактически есть:
- `test/device-hit-owner.test.mjs` — чистая unit-матрица на синтетической геометрии (`candidate()` строит прямоугольники руками), включая один горизонтальный «pill» (40×12) для проверки, что капсула не считает углы bbox своими (`docs/device-hit-owner.test.mjs:34-38`). Направления сверху/снизу отдельно не проверены даже синтетически (модель симметрична, но explicit-теста на вертикальный pill нет).
- `demo/smoke_household_journeys.mjs` — реальный браузерный прогон, но пять маркеров `dense1..dense5` заданы как `binding: 'virtual', is_light: true` **без** `with_values`/легаси-настроек — это рендерится как обычный Icon-маркер (`.device-shell:not(.with-values):not(.text-shell)`), без `pos-right/pos-left/pos-top/pos-bottom` вариаций.
Ни одного browser-теста, который рендерит **Text/Double/legacy** capsule или направления **pos-top/pos-bottom/pos-left/pos-right** и проверяет для них приоритет painted-area / nearest-core через реальный `getBoundingClientRect()` `.device-shell-frame`, в диффе нет. Хендофф-комментарий автора это не оговаривает и не даёт альтернативной записи «проверено чтением, не исполнением» для этой части AC2.
**Почему это важно, а не формальность.** Реализация (`DeviceHitController.indexFor`, `src/device-hit-owner.ts:203-228`) вычисляет `floorRadius` как `Math.max(22, Math.min(painted.width, painted.height) / 2)`, полагаясь на инвариант «короткая сторона нарисованной капсулы равна `--device-shell-size`» (комментарий в коде это явно называет допущением). Чтением CSS (`src/styles/devices.styles.ts:195-247`) это похоже на правду: `with-values` варианты растягивают `.device-shell` только по одной оси (`pos-right/left` — по ширине, `pos-top/bottom` — по высоте), а `.device-shell-frame` наследует размеры контейнера через `inset`. Но это вывод из чтения кода, а не из выполнения — AC2 прямо требовал исполняемое доказательство именно для этих вариантов, потому что именно там инвариант мог не выполниться (например, из-за `--device-shell-inset` или паддингов текстового блока). Golden-эталоны с «плотными Icon/Text/Double/legacy markers» упомянуты в ТЗ §10, но там же явно отнесены к «Перед бетой» и являются визуальным (пиксельным), а не функциональным (hit-arbitration) сравнением — они не закрывают этот пробел даже после прогона.
**Серьёзность:** Medium, в скоупе задачи (эта же AC этой же issue) — по правилу §2.7/§4 чинится тем же автором в том же issue, без отдельного цикла свыше лимита; без High-находок это жёлтый вердикт.
**Предлагаемое закрытие:** либо добавить фокусированный browser-смок (или расширить `smoke_household_journeys.mjs`/`test/device-hit-owner-contract.test.mjs`), который рендерит по одному маркеру каждого типа (Icon/Text/Double/legacy) в каждом из четырёх направлений плотно к соседу и подтверждает, что `_deviceHitOwnerAt`/клик попадает в painted-владельца, либо явно записать в issue «проверено чтением исходников CSS-инварианта, не исполнением» с указанием конкретной строки-допущения, которую ревью не может закрыть само.
## Проверено и корректно
- **AC1** (J7 390/780 px, click → dense1) — `demo/smoke_household_journeys.mjs`, реально исполненный сценарий: `j7.tablet_centre_belongs_to_its_marker`, `j7.phone_centre_belongs_to_its_marker`, `j7.tablet_click_reaches_its_marker`, `j7.phone_click_reaches_its_marker` — все проходят на 15/15 зелёном прогоне. Раньше 390-px замер печатался без проверки (`docs/QUALITY-560.md`), теперь он — обязательный assert; тест умеет падать (это был явный красный до правки, задокументированный в самом issue).
- **AC3** (44×44 floor не уменьшен, вся капсула сохраняет hover/action) — подтверждено чтением: `.dev::before { width/height: max(44px, var(--device-shell-size)) }` не тронут по значению, только `z-index`/`pointer-events` перенесены (`src/styles/devices.styles.ts:178-190`). Существующие #213-регрессии из набора прогона (`smoke_static_icon.mjs`, `smoke_wireless_controller_parity.mjs`) зелёные.
- **AC4** (один owner от pointerdown до pointerup/cancel, tap/long-press/contextmenu/drag редактора не перескакивают) — доказано и unit-тестом латча (`test/device-hit-owner.test.mjs`, `DevicePointerOwnerLatch` begin/release/consumeClick/cancel), и новым реальным браузерным сценарием `j7.pointer_owner_is_latched_through_terminal_click` в `smoke_household_journeys.mjs` (полная pointerdown→pointermove(на соседа)→pointerup→click последовательность подтверждает, что владелец остаётся `dense1`). Чтением кода подтверждено, что Devices-editor drag (`_pointerDown`/`_pointerMove`/`_pointerUp` при `mode==='devices'`) использует того же резолвнутого `d`, а не заново вызывает геометрию на каждый пиксель (`src/houseplan-card.ts:7185-7193`).
- **AC5** (hover/tooltip у того же ближайшего owner, touch/pen не получают ложный mouse hover) — `_showDeviceTip` уважает существующий `PointerModalityController` (`_pointerModality.hoverEnabled`) до вызова `_deviceHits.hover`, поведение не изменилось относительно #212, только добавлен уровень владения. `smoke_touch_tips.mjs` и `smoke_feedback_v2.mjs` зелёные.
- **AC7** (детерминированный tie-break, изоляция между картами, не выбирает hidden/removed/другой space) — прочитано и проверено unit-тестом: `nearest()` в `src/device-hit-owner.ts:53-69` использует сравнение `candidate.id` и не зависит от порядка массива (тест `#564 tie-break is stable and independent of candidate/DOM order` гоняет один и тот же набор в обоих порядках). `DeviceHitController` — приватное поле экземпляра карточки (instance-local), `.at()` дополнительно фильтрует по `item.space === space`. Скрытые/удалённые маркеры отсеиваются через `core.width <= 0 || core.height <= 0` до попадания в индекс.
- **AC8** (нет per-frame layout scan; кэш инвалидируется по контракту ТЗ §7) — прочитано: `indexFor()` кэширует до явного `invalidate()`; инвалидация подключена к `MutationObserver` на `class/style/data-id` внутри `.devlayer` (что реально ловит pan/zoom — маркеры позиционируются через `style="left:...%;top:...%"`, то есть смена камеры меняет атрибут `style` на каждом `.dev` и попадает под текущий `attributeFilter`) и к `ResizeObserver` на `.stage`. Unit-тест на 200 кандидатах (`#564 spatial index resolves transformed screen coordinates locally`) подтверждает локальность поиска. Полный performance-гейт по AC10/ТЗ §10 сознательно отложен на пред-релиз — это не пропуск, а то, что сама AC8/AC10 требует именно так.
- **AC9** (защитный AC доказан таблицей «чем краснеет»):
| AC | Чем доказан | Чем краснеет |
|---|---|---|
| AC9 — арбитраж по ближайшему core, не по DOM-порядку | `node --test test/device-hit-owner.test.mjs` | мутант `dense-device-hit-falls-back-to-input-order` (`scripts/mutation-registry.mjs`) заменяет сравнение расстояний на «первый в списке всегда лучший»; перепроверено вручную в этом ревью — `node scripts/mutation-gate.mjs --id=dense-device-hit-falls-back-to-input-order` → «поймано 1 из 1» |
| (сопутствующий #212-контракт) hover красится через `data-hp-device-hover`, а не устаревший CSS `:hover` | `node --test --test-name-pattern="issue 212 removes the global touch latch" test/device-marker-polish-contract.test.mjs` | мутант `device-touch-hover-gate-removed`, добавленный последним коммитом после того, как первый прогон Validate-с-мутантами нашёл сбежавший на старом регэкспе мутант; перепроверено — «поймано 1 из 1» |
| (сопутствующий #464-контракт) подсвеченный Zigbee-endpoint остаётся выше overlay | `node demo/smoke_zigbee_topology_hover.mjs` | мутант `zigbee-topology-hovered-endpoint-elevation-removed`, обновлённый под новый `[data-hp-device-hover]` селектор; перепроверено — «поймано 1 из 1» |
- **AC10** (typecheck/unit/build в цикле; smoke/golden/perf — до беты) — Validate на точном SHA `0af582da` зелёный (ссылка выше); полный smoke/golden/perf сознательно не гонялись — это соответствует самой AC10, а не пропуск гейта.
- **AC11** (схема, координаты, action settings, i18n, backend API не менялись) — подтверждено чтением диффа: файлов `.py`, `custom_components/**`, `src/i18n/**` в изменении нет; `docs/CHANGELOG.md`/`.ru.md` в обоих User-Visible-коммитах описывают только исправление выбора маркера.
- **Трейлеры и changelog.** Все 5 коммитов несут `Issue: #564`; оба `User-Visible: yes` коммита (`6de854a0`, `f9ed9d2a`) правят `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` в том же коммите — проверено по `git show --stat`.
- **Гейт документации.** `node scripts/check-docs.mjs` зелёный (`docs/**` не устарел относительно `src/**`); `docs/images/screenshots.json` — 11/11 `imageSha256` не изменились (байтовая идентичность), заявление автора о `docs:accept -- --identical` подтверждено артефактом.
- **Дежурный контракт #563 (pinch-safety, AC6).** Модуль `src/touch-gesture-click-guard.ts` в диффе не изменён; `smoke_pan_any_zoom.mjs` (pinch/pan) и `smoke_linked_virtual_light.mjs` (реальный pointerdown→pointerup→click клик) из прогнанного набора — зелёные, что покрывает основной риск взаимодействия нового резолвера с существующим guard.
- **Единственное число, видимое дважды.** Изменение не вводит новых пользовательских величин (только геометрия hit-теста), проверка `test/single-source-numbers.test.mjs` диффом не затронута — не применимо.
## Чего не проверял
- Golden-эталоны (`npm run golden:verify`/`golden:capture`) — не гонялись; по ТЗ §10 и AC10 это пред-релизный гейт, а не гейт ревью. Визуально «плотные Icon/Text/Double/legacy markers» golden-кадры явно отнесены туда же же документом ТЗ.
- `performance_smoke` / полная performance-матрица на 200 маркерах — не гонялась; отложена на пред-релиз по AC8/AC10, source-уровня доказательство (unit-тест на 200 кандидатах) проверено.
- `python -m pytest tests_backend` — не гонялся, бэкенд не тронут.
- 41 «слабая связь» `smoke-select` и оставшиеся 15 «прямых совпадений» из 29 (список выше) — не гонялись; по чтению кода не пересекаются с изменённой семантикой владения устройствами.
- Кросс-браузерное поведение `ev instanceof PointerEvent` в `DeviceHitController.click()` (`src/device-hit-owner.ts:265-276`): резолвер для click полагается на то, что нативное событие `click` в целевом браузере типизировано как `PointerEvent` (это так в пиннутом Chromium/Playwright, на котором и получены все прогоны). Не проверялось, деградирует ли арбитраж клика на браузерах, где `click` остаётся классическим `MouseEvent` — ТЗ не называет кросс-браузерную матрицу событий нормативным источником, а CSS-часть фикса (глобальный z-index painted-слоя) в любом случае решает основной сценарий issue независимо от типа события. Оставляю как контекст, не как находку: не воспроизведено ни на одном доступном окружении.
## Материал раунда
- Ветка: `issue/564-dense-marker-hit` (согласно комментариям issue; рабочая копия — detached HEAD на `0af582daa56632736560df0bff6c78f778618c5a`).
- Дерево на момент ревью: `git rev-parse HEAD` = `0af582daa56632736560df0bff6c78f778618c5a`.
- `origin/dev` на момент диффа: см. `git merge-base origin/dev HEAD` в среде ревью в момент прогона (диапазон `origin/dev..HEAD` дал 5 продуктовых/тестовых коммитов, перечисленных в разделе «Скоуп»).
## Вердикт
Жёлтый. Единственная блокирующая находка — M1 (Medium, в скоупе), High нет. Все AC, кроме доказательной части AC2, подтверждены выполнимым тестом либо чтением с явной пометкой. Возврат автору на устранение M1 в рамках этого же issue, без нового цикла лимита ТЗ и без отдельного issue.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/564-dense-marker-hit`, коммит `0af582daa566` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `2ad31d0d3a8ea413d0881e7f7d60273d8def154b`
```
git log --all --format='%H %T' | grep 2ad31d0d3a8e
```
- Тело issue: `f958e178616c6d48277f99106f9d84e76de72762c0c7b3e6f315231ff1cfec08`
- Вердикт конвейера: `yellow` · High 0