# Код-ревью 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`.