Files
houseplan-card/docs/reviews/CODE-REVIEW-220-r1.md
T
2026-08-20 21:41:11 +00:00

278 lines
23 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.
# Код-ревью issue #220 — порядок вкладок пространств перетаскиванием
- **Issue:** https://github.com/Matysh/houseplan-card/issues/220
- **ТЗ:** `docs/specs/220-space-tab-reorder.md`, зелёное ревью r3
(`docs/reviews/SPEC-REVIEW-220-r3.md`), заход исчерпан на 2/4.
- **Заход код-ревью:** r1/4 (первый цикл код-ревью; счётчик отдельный от
ревью ТЗ, §10.4 PROCESS.md).
- **Диапазон:** ветка `issue/220-space-tab-reorder`, коммит `8369c0e`
(единственный коммит реализации поверх `e57e1d9`).
- **Ревьюер:** свежая сессия, без контекста реализации.
## 1. Скоуп проверки
Диапазон — весь коммит `8369c0e` (первый код-ревью для этой задачи, полный
разбор, §2.10 не применяется): `src/space-order.ts` (новый), правки
`src/houseplan-card.ts` (обработчики drag, `_commitTabOrder`), `src/styles.ts`,
i18n en/ru, `scripts/mutation-gate.mjs`, `test/space-order.test.mjs`,
`demo/smoke_space_tab_reorder.mjs`, оба changelog, оба USER-GUIDE, три копии
бандла.
## 2. Как проверялось
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | чисто |
| Unit | `npm test` | 982/982, 0 fail |
| Build + сверка копий | `npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js && cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` | пересобран, все три копии побайтово совпадают, `git status` после сборки чист |
| Смок AC1/2/4/5/7 | `node demo/smoke_space_tab_reorder.mjs` | OK, все 18 проверок зелёные |
| Смок соседнего контракта (#210) | `node demo/smoke_fixed_floor.mjs` | OK |
| Смок соседнего контракта (размещение маркеров) | `node demo/smoke_subarea.mjs` | OK |
| Mutation-gate, дешёвая проверка якорей | `node scripts/mutation-gate.mjs --check` | все патчи, включая 4 новых, находят свой якорь в исходниках |
| **Прямой вызов production-функций** (не гейт, целевая проверка находки H1) | см. §4.1 | воспроизводит расхождение |
**Не прогонялось и почему:** `golden:verify` — задача не трогает визуал в
состоянии покоя (панель вкладок вне матрицы, §15 ТЗ), diff подтверждает: правки
только в `.tab`/`.dragging`/`.droptarget` стилях активного взаимодействия;
`pytest tests_backend` — ни один файл `custom_components/**/*.py` не тронут;
полный (дорогой) прогон `mutation-gate` с пересборкой на каждого мутанта — это
предрелizный гейт, не гейт ревью (§8 PROCESS.md); остальные 124 браузерных
смока — diff не задевает их поверхности (canvas-инструменты, устройства,
партиции и т.д.), только панель вкладок и её ближайших соседей, которые
прогнаны выше.
## 3. Находки
### H1 (High) — материализация неверно определяет «зависимые от порядка» маркеры: обычный маркер устройства с площадью из реестра HA получает чужой `space`
**Что не так.** `markersNeedingPlacement` (`src/space-order.ts:101-119`) решает,
какому маркеру нужно материализовать `firstSpaceId`, глядя только на
персистентные поля самого маркера — `marker.area` и `marker.space`:
```ts
const area = typeof marker.area === 'string' ? marker.area : '';
if (area && areaToSpace[area]) continue;
out.push({ id, space: firstSpaceId });
```
Но реальное размещение маркера, привязанного к HA-устройству или сущности
(`binding: 'device:...'` / `'entity:...'`), решает не только `marker.area` —
`resolveExplicitMarkerPlacement` (`src/devices.ts:1045-1065`) в первую очередь
берёт **область из реестра HA** (`dev?.area_id` / `reg?.area_id`), и только
если её нет — откатывается к `marker.area`, `marker.space`, затем
`firstSpaceId`:
```ts
const area = marker.area || registryArea || '';
return { area, space: (area && areaToSpace[area]) || marker.space || firstSpaceId };
```
**Ровно так и создаются обычные маркеры.** `_markerDraft`
(`houseplan-card.ts:18868-18913`) и её аналог для существующей формы
(`houseplan-card.ts:13305-13344`) пишут `marker.area`/`marker.space` **только
если** `d.binding === 'virtual' || d.room` — то есть только для виртуальных
маркеров или маркеров, которым вручную выбрана комната. Любой маркер,
созданный обычным способом «привязать существующее HA-устройство» (переименовать,
сменить иконку, добавить контролы, спрятать с плана) без ручного выбора
комнаты — **самый частый путь в реальной инсталляции** (SCOPE.md: 20–200
устройств, в основном автообнаруженных) — сохраняется **без** `area` и без
`space` вовсе. Такой маркер сегодня прекрасно резолвится через область своего
HA-устройства и от порядка `spaces` не зависит.
`markersNeedingPlacement` не видит `registryArea` — у неё в сигнатуре нет ни
самого устройства, ни его области, только поля маркера. Поэтому она
классифицирует **любой такой маркер** как «зависящий от `firstSpaceId`» и
`_commitTabOrder` (`houseplan-card.ts:1300-1330`) прямо в транзакции
перестановки дописывает ему `marker.space = <старое первое пространство>` —
хотя AC3/§8.3 ТЗ прямо требуют, чтобы такие маркеры «остались побитово
прежними».
**Воспроизведение** (прямой вызов той же production-сборки, без браузера):
```js
import { buildDevices } from './test-build/devices.js';
import { markersNeedingPlacement } from './test-build/space-order.js';
const hass = { devices: { boiler: {
id: 'boiler', name: 'Boiler', model: 'X', area_id: 'attic',
identifiers: [['demo', 'boiler']], entry_type: null, via_device_id: null,
} }, entities: {}, states: {}, areas: {} };
const marker = { id: 'boiler-marker', binding: 'device:boiler' }; // ни area, ни space
const ctx = { hass, areaToSpace: { attic: 'f2' }, markers: [marker], settings: {},
excluded: new Set(), showAll: false, firstSpaceId: 'f1', loc: () => '' };
buildDevices(ctx).find((d) => d.id === 'boiler-marker').space;
// → 'f2' — маркер сегодня корректно и стабильно резолвится через область HA,
// пространство 'f2' не имеет отношения к firstSpaceId
markersNeedingPlacement([marker], { attic: 'f2' }, 'f1');
// → [{ id: 'boiler-marker', space: 'f1' }]
// _commitTabOrder на основании этого результата запишет marker.space = 'f1' —
// пространство, в котором маркер никогда не находился.
```
Запущено против собранного `test-build` этой самой ветки (`npx tsc -p
tsconfig.test.json`) — расхождение воспроизводится, не гипотетическое.
**Почему это не ловится существующими тестами.** Юнит `test/space-order.test.mjs`
(«only order-dependent markers are pinned») и смок (`smoke-dangling`, marker с
`binding: 'virtual'`) проверяют только маркеры, у которых «отсутствие
зависимости от порядка» и так видно по их собственным полям (`area: 'kitchen'`,
`space: 'f2'`). Ни один тест не заводит обычный `device:`/`entity:`-маркер без
area/space, чьё реальное размещение решает область его HA-устройства —
единственный случай, где расхождение проявляется. Мутационный гейт тоже не
покрывает эту ветку: из пяти записей §14 ТЗ реализованы четыре (см. M2 ниже),
причём именно недостающая `materialization-touches-bound-markers`
концептуально ближе всего к этой находке.
**Практическое следствие.** Прямо сейчас маркер не переезжает визуально: в
`resolveExplicitMarkerPlacement` `area && areaToSpace[area]` стоит раньше
`marker.space` в приоритете, поэтому испорченный `marker.space` временно
маскируется правильной резолюцией через область. Но конфиг тихо получает
недостоверное персистентное поле на **каждом** обычном маркере устройства без
ручной комнаты при **каждой** перестановке вкладок: как только HA-область этого
устройства когда-либо станет недоступна (переназначение area в HA, замена
устройства, повторная привязка сущности) — `area && areaToSpace[area]`
перестанет резолвиться, и маркер молча прыгнет в то самое `firstSpaceId`,
которое было актуально на момент случайной перестановки вкладок месяцы назад,
без какой-либо связи с текущим действием пользователя. Это ровно тот
сценарий («маркеры уезжают»), который §8.3 ТЗ называет главным риском задачи —
только отложенный во времени и оттого более коварный.
**Это находка в скоупе задачи** (§8.3/AC3 — центральный контракт этой самой
задачи), блокирует зелёный вердикт.
### M1 (Medium, в скоупе) — перетаскивание вкладки не захватывает указатель: отпускание за пределами панели оставляет «зависшее» состояние drag
`_tabPointerDown` (`houseplan-card.ts:1259-1272`) не вызывает
`setPointerCapture`/`capturePointer`, в отличие от **всех** остальных
pointer-based drag-жестов в этом файле — resize краёв/углов
(`_rszEdgeDown`/`_rszCornerDown`, `capturePointer(ev)`), компас
(`setPointerCapture` напрямую, `:14271`), инструменты декора (`:9598`, `:9608`,
`:14944` и другие) — все идут через явный захват указателя или через
специально написанный для этого хелпер:
```ts
// houseplan-card.ts:667-673
const capturePointer = (ev: PointerEvent): void => {
try { (ev.target as Element | null)?.setPointerCapture?.(ev.pointerId); }
catch { /* an inactive pointerId must never kill the drag */ }
};
```
Без захвата `pointermove`/`pointerup` у `_tabPointerMove`/`_tabPointerUp`
привязаны к каждому `.tab`-элементу и получают события только пока указатель
физически находится над этим элементом. Если пользователь, естественно
двигая руку по горизонтали, слегка уводит курсор за пределы строки вкладок
(вниз, на «+», в промежуток между вкладками) и там отпускает кнопку — ни
`pointerup`, ни `pointercancel` не срабатывают ни на одном `.tab`. Результат:
1. `_tabDrag` остаётся с `moved: true` навсегда — вкладка-источник продолжает
показывать класс `dragging` (`opacity: 0.55`), последняя наведённая —
`droptarget` (синяя рамка), пока не начнётся и не завершится следующий
полноценный drag на каком-то `.tab`.
2. `_tabClick` (`houseplan-card.ts:1290-1293`) читает `this._tabDrag?.moved`,
чтобы отличить клик от драга. Пока `_tabDrag` в таком «зависшем» виде,
следующий тап **другим** типом указателя (например, тач на гибридном
тач-экране ноутбука — актуально: `docs/TOUCH-SUPPORT.md` прямо
рассматривает такие устройства) не пройдёт через `_tabPointerDown`
(`canStartTabDrag` вернёт `false` для `pointerType !== 'mouse'`, ранний
`return`), состояние не сбросится, и `_tabClick` молча проглотит переключение
пространства.
Спек §8.1 требует «при отпускании вне панели порядок не меняется» — это
формально верно (commit не вызывается), но не покрывает визуальный/интерактивный
артефакт, который отпускание вне панели создаёт. Смок не ловит это: `drag()` в
`demo/smoke_space_tab_reorder.mjs` всегда завершает `pointerup` на целевой
вкладке (`to`), сценарий «отпустить вне панели» не воспроизведён ни разу.
**Почин.** Один вызов `capturePointer(event)` (используя уже существующий
хелпер `houseplan-card.ts:667`) в начале `_tabPointerDown`, как это уже сделано
для каждого другого перетаскивания в файле.
### M2 (Medium, в скоупе) — мутационный гейт реализован не полностью относительно принятого §14 ТЗ
Спека (`docs/specs/220-space-tab-reorder.md` §14) фиксирует пять записей
мутационного гейта, в том числе:
```
| materialization-touches-bound-markers | материализовать space и у маркеров с area | юнит AC3 |
```
В `scripts/mutation-gate.mjs` добавлены только четыре: `tab-reorder-not-persisted`,
`reorder-skips-materialization`, `tab-reorder-eats-click`,
`tab-reorder-ignores-pointer-type`. Хендофф-комментарий в issue тоже называет
«четыре записи, все прогнаны» — без пометки, что пятая из принятого ТЗ
пропущена, и без объяснения почему. Раз AC5.md §14 — часть зелёного ТЗ (не
«принятое предположение»), тихое сокращение состава гейта — отклонение от DoR,
а не право исполнителя.
Показательно, что именно эта недостающая проверка концептуально ближе всего к
H1: она должна была утверждать «материализация не трогает маркеры с area» —
и её реализация (более широкая, чем нынешний юнит-тест, который проверяет
только буквальное поле `marker.area`) с большей вероятностью поймала бы
находку выше на этапе реализации.
**Не отдельный issue** — это Medium в скоупе текущей задачи (§14 — её
собственный принятый план тестов), правится в этой же ветке добавлением
пятой записи мутационного гейта с юнит-тестом, покрывающим маркер, чьё
размещение решает область HA-устройства, а не собственное поле `area`.
## 4. AC — чем доказано (сверх заявленного автором)
| AC | Проверка ревьюера | Вывод |
|---|---|---|
| AC1 | смок прогнан повторно, зелёный; код `_commitTabOrder` + `reorderSpaceIds`/`applySpaceOrder` прочитан | доказано |
| AC2 | смок повторно; unit `passedDragThreshold`/`canStartTabDrag` прочитаны | доказано |
| AC3 | смок повторно зелёный **для сценария из смока**, но разбор по коду нашёл незакрытый случай — **H1** | **не доказано полностью**, находка блокирует |
| AC4 | `swipeTarget` берёт `this._model.map(m => m.id)` (`:5825`), `_model` пересчитывается по `_cfgEpoch`, который `_saveConfig()` инкрементирует синхронно до дебаунса записи — прочитано, логика верна | проверено чтением, корректно |
| AC5 | смок повторно; `canStartTabDrag` разобран построчно, семь юнит-веток на отрицание | доказано |
| AC6 | `_commitTabOrder` → `_saveConfig()` → `_saveConfigDebounced` → `_writeConfig()` — тот же путь конфликта, что у остальных правок (`toast.conflict`, `_reloadConfigOnly`), своего кода обработки не добавлено — прочитано | проверено чтением, не исполнением (согласен с автором) |
| AC7 | смок повторно зелёный | доказано |
| AC8 | md5 всех трёх копий бандла совпадает после независимой пересборки; changelog/USER-GUIDE поменяны в обоих языках | доказано — **с оговоркой**: формулировка «Markers stay exactly where they were» станет точной только после фикса H1 |
## 5. Что проверено и корректно
- Порог 4 px, разделение клика и драга, вставка перед целевой вкладкой (а не
обмен местами) — соответствует §17 ТЗ.
- `.tabadd` («+») не участвует: у неё нет `pointerdown`/`pointermove`/`pointerup`
обработчиков, поэтому она не может стать ни источником, ни целью — код
подтверждает намерение спеки без отдельного смок-кейса.
- Одно пространство — `canStartTabDrag` возвращает `false` при `spaceCount <= 1`
(юнит-тест есть).
- `_hasFixedFloor` корректно блокирует drag (панель с фиксированным этажом
показывает одну вкладку — двойная защита, не только через `spaceCount`).
- View/киоск/тач исключены на уровне чистой функции и подтверждены смоком.
- Атомарность записи (порядок + материализация одним `cfg`-мутированием перед
единственным `_saveConfig()`) соблюдена — ровно то, что требовал норматив
§17.3 ТЗ и на чём настоял ревью ТЗ r2/r3 (M3).
- Тост «once per session» — `_tabOrderWarned` не персистится, инстанс-поле,
сбрасывается только при пересоздании карточки — соответствует формулировке
АС7 «за сессию».
- Три копии бандла побайтово идентичны после независимой пересборки ревьюером.
## 6. Чего не проверял
- Полный (дорогой, предрелизный) прогон `mutation-gate.mjs` с реальной
пересборкой на каждый мутант — вне объёма код-ревью (§8 PROCESS.md), прогнан
только `--check`.
- `golden:verify` — визуал панели вкладок не в матрице, изменений в покое нет;
не прогонялся сознательно (см. §2).
- `pytest tests_backend` — Python не тронут.
- Остальные ~124 браузерных смока за пределами панели вкладок и её
непосредственных соседей (#210, привязка маркеров) — diff их поверхностей не
касается.
- Ручного тестирования в браузере (drag мышью вживую) не было — по регламенту
цикла (нет фазы ручного тестирования); H1 и M1 доказаны разбором по коду и
прямым вызовом продакшен-функций соответственно, а не интерактивной сессией.
## 7. Итог
Один High (H1) — блокирует. Два Medium (M1, M2) — в скоупе задачи, правятся в
этой же ветке. AC3 не доказан полностью: смок проверяет только тот подкласс
«зависимых от порядка» маркеров, который сам код классифицирует явными
полями, и не покрывает самый частый в реальных конфигурациях случай —
маркер устройства, чьё размещение решает область HA, а не поле маркера.
Возврат автору: `S6-in-progress`.