26 KiB
CODE-REVIEW-243-r1
- Issue: #243 — «Перетаскивание вкладок пространств не работает: захват указателя съедает цель. Плюс нужен указатель места вставки»
- Этап: code (PROCESS.md §2.7)
- Заход: r1 · блокирующих циклов израсходовано 0 из 4
- ТЗ:
docs/specs/243-space-tab-drop-target.md(7ac1934), SPEC-REVIEW:docs/reviews/SPEC-REVIEW-243-r1.md, вердикт зелёный - Диапазон:
origin/dev..HEAD, 3 коммита:7ac1934docs(spec): define reliable space tab drop target(класс C,Issue: #243,User-Visible: no)628e1a1docs: review document for #243(класс C, SPEC-REVIEW документ)f4de527fix(tabs): restore captured mouse reordering(класс A/B/C/D,Issue: #243,User-Visible: yes) — единственный коммит, меняющий продуктовое поведение
- Ревьюер этого раунда не имеет контекста реализации; материал — диапазон коммитов и diff, не рассказ автора.
Скоуп ревью
Первый заход код-ревью, предыдущего вердикта по этому этапу нет — разбор ниже полный, разделы «Закрытие раунда r0» и «Унаследовано из r0» не применяются (PROCESS.md §2.10 относится только к r2+).
Изменённые файлы продуктового/тестового кода (f4de527):
src/houseplan-card.ts 78 +++--
src/styles.ts 3 +-
demo/smoke_space_tab_reorder.mjs 497 (переписан на трастед page.mouse)
demo/golden/harness.mjs 68 ++ (семантический guard для tabDrag-сцен)
demo/golden/matrix.mjs 8 +- (2 новые сцены)
demo/golden/run.mjs 7 ++ (release мыши в finally)
scripts/mutation-gate.mjs 48 ++ (3 новых мутанта)
test/golden-matrix.test.mjs 23 ++
docs/CHANGELOG.md / .ru.md, docs/USER-GUIDE.md / .ru.md, docs/TESTING.md, docs/images/screenshots.json — в том же коммите
Как проверялось
- Прочитан
docs/SCOPE.md— задача чинит регрессию выпущенной функции J6 («keep the plan true as the home evolves», сортировка вкладок), персона home admin, поверхность — desktop-редакторы. Новый видимый элемент (разделитель) не расширяет мандат — делает уже принятый контракт #220 честным, не новую фичу. - Прочитаны
AGENTS.md,PROCESS.mdцеликом (классы файлов, §2.7, §4 лимит циклов, §7.2 шаблон вердикта, §8 гейты, §2.10 — не применяется к r1). - Прочитано тело issue #243 и все шесть комментариев (аналитика, занятие автором ТЗ, сдача ТЗ, вердикт SPEC-REVIEW, занятие разработчиком, хендофф реализации).
- Прочитан ТЗ
docs/specs/243-space-tab-drop-target.mdцеликом и сверен с diff по каждому разделу (контракт hit-testing §7, контракт завершения §8, визуальный контракт §9, AC1–AC9 §12). - Прочитан
docs/USER-GUIDE.ru.md/.md— новая формулировка «тонкий разделитель показывает точную сторону вставки» согласована с текстом ТЗ, терминология не расходится. - Построчно прочитан diff
src/houseplan-card.ts:_tabPointerDown, новый_tabDropTargetAt, переписанные_tabPointerMove/_tabPointerUp, новый_suppressNextTabClick, правки_endTabDrag-вызовов в_pickSpace/_setMode/disconnectedCallback, изменение шаблона рендера (@pointermoveпереехал с каждой.tabна контейнер.tabs, классdroptargetзаменён наdrop-before/drop-after). - Прочитан diff
src/styles.ts— два классаdrop-before/drop-afterсinsetbox-shadow на разных сторонах, один theme token, без отдельной dark-палитры — соответствует §9 ТЗ. - Прочитан diff
demo/smoke_space_tab_reorder.mjsцеликом (497 строк) — подтверждено, что все позитивные сценарии (AC1–AC4) используютpage.mouse.move/down/up, а неdispatchEvent; синтетическийdispatchEventостался только в touch- и pointercancel-негативных ветках (AC5/AC6), как и требует ТЗ §13.1. - Прочитан diff
demo/golden/harness.mjs,matrix.mjs,run.mjs,test/golden-matrix.test.mjs— семантический guard, две новые сцены, релиз мыши вfinally, unit-проверка структуры сцен. - Прочитан diff
scripts/mutation-gate.mjs— три новых мутанта с id из ТЗ §14, плюс правка существующего мутантаtab-drag-outlives-the-cardпод новые поля state (_tabSuppressClickTimer). - Прочитаны трейлеры всех трёх коммитов и структура коммита
f4de527— код и документация (CHANGELOG RU+EN, USER-GUIDE RU+EN, TESTING.md, docs screenshot fingerprint) в одном коммите, как требуетUser-Visible: yes(Правило 10, §2.6). - Прогнаны гейты (раздел ниже) лично, не со слов автора.
Гейты — что прогнано и почему
Обязательные всегда (PROCESS.md §8):
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | npx tsc --noEmit |
PASS, без вывода |
| Unit | npm test |
PASS — 1078 tests, 1078 pass, 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 |
PASS, обе копии идентичны dist |
| Docs fingerprint | node scripts/check-docs.mjs (обязателен — diff трогает src/**) |
PASS — «Documentation checks passed (7 files, 10 external links)» |
По необходимости, определяемой diff/AC:
| Гейт | Команда | Результат | Почему прогнан |
|---|---|---|---|
| Smoke-select | node scripts/smoke-select.mjs --base origin/dev --head HEAD |
Прямое совпадение: smoke_space_tab_reorder. Слабая связь по _model (10 смоков) |
Обязателен для решения по выборке |
| Целевой browser smoke | node demo/smoke_space_tab_reorder.mjs |
OK, все 37 полей true |
Прямое совпадение из smoke-select, AC1–AC6 |
| Mutation-gate, новые id | node scripts/mutation-gate.mjs --id=tab-drag-target-follows-captured-source / --id=tab-drop-indicator-always-before / --id=tab-drop-outside-commits-last-target |
Каждый: «поймано 1 из 1» | Именно эти три мутанта — доказательство «тест умеет падать» для AC1/AC2/AC4 |
| Mutation-gate, полный реестр | node scripts/mutation-gate.mjs --check |
118 из 118 «ok», включая старые tab-drag-outlives-the-card, tab-drag-survives-release-outside, tab-reorder-eats-click, tab-reorder-ignores-pointer-type |
Дешёвый прогон (несколько секунд на мутант), проверяет, что правка не сломала мутанты #220, изменённые в этом diff (tab-drag-outlives-the-card) |
| Golden verify (целевые сцены) | npm run golden:verify -- --only=space-tab-drop-before-light,space-tab-drop-after-dark (флаг --only не ограничил вывод — прогнался весь набор) |
Обе новые сцены: missing-baseline (не error) |
AC2/AC8 требуют доказательства семантического guard; missing-baseline вместо error доказывает, что prepareGoldenScenario дошёл до сравнения с baseline, то есть блок if (!state.ok) throw в harness.mjs не сработал — семантический guard (dragging/target/side/inset/trusted-move) прошёл на реальном page.mouse-жесте |
| Слабые связи smoke-select (частично) | node demo/smoke_fixed_floor.mjs, node demo/smoke_space_card.mjs, node demo/smoke_deeplink.mjs, node demo/smoke_kiosk_pan_lock.mjs |
Все PASS/OK | Выбраны как релевантные по теме diff (fixedFloor/навигация вкладок затронуты AC6, _pickSpace/_setMode получили новый вызов _endTabDrag()) |
| Process-gate | node scripts/process-gate.mjs --range origin/dev..HEAD |
«гейт пройден, предупреждений 0» | Дешёвый офлайн-гейт, трейлеры/класс файлов/ветка |
Не прогонялось и почему:
smoke_audit_1490,smoke_glow_blending,smoke_render_perf,smoke_styling_hooks,smoke_virtual_light_toggle,smoke_zoom_out— слабая связь по общему_modelбез семантической близости к tab-drag пути; diff их не трогает (подтверждено чтением diff: ни один из этих файлов не изменён, а их предмет — рендер устройств/зум/тени, не порядок вкладок).python -m pytest tests_backend— не прогонялся: diff не затрагиваетcustom_components/**/*.py(подтвержденоgit diff --stat).- Performance-профиль — не прогонялся: ТЗ §11 явно снимает требование (линейный проход по ≤50 пространствам), в AC не назван.
- Полный
npm run golden:verifyпо всей матрице как предмет анализа (а не только двух новых сцен) — не разбирался построчно: 74 «different»/остальные «missing-baseline» сцены не относятся к #243 (не изменены этим diff), это уже известный необслуженный долг непринятых baseline наdev, зафиксированный автором в хендоффе; для этого code-review имеет значение только то, что новые сцены дошли доmissing-baseline, а не упали на семантике. npm run golden:accept— не выполнялся и не должен: baseline принимается только из reviewed Linux CI artifact перед бетой (Правило 13), не в этом цикле.
Проверка AC
| AC | Итог | Как доказано |
|---|---|---|
| AC1 | Выполнен | smoke_space_tab_reorder: realMouseMovedTabLeft, firstDropWroteOnce, sameTabMovedRight, secondDropWroteOnce — все true; жест — page.mouse, capture сохраняется (captureKeptButTargetResolved) |
| AC2 | Выполнен | Smoke: leftDropUsesBeforeSide/rightDropUsesAfterSide, beforeDividerHasPositiveInset/afterDividerHasNegativeInset; golden: обе сцены прошли семантический guard (см. таблицу гейтов) |
| AC3 | Выполнен | Smoke: subThresholdStayedClick, subThresholdDidNotWrite, clickStillSwitchesSpace — движение < 4px осталось кликом без записи и без второго действия |
| AC4 | Выполнен | Smoke: outsideClearsDropTarget, outsideReleaseEndedDrag, outsideReleaseDidNotWrite, nextClickWorksAfterOutsideRelease |
| AC5 | Выполнен | Smoke: pointerCancelEndedDrag/pointerCancelDidNotWrite, dragWasActiveBeforeDetach/detachEndedTheDrag/detachedCardDidNotWrite/orderSurvivedDetach; мутант tab-drag-outlives-the-card (унаследован от #220, всё ещё красный при мутации — проверено полным mutation-gate) |
| AC6 | Выполнен | Smoke: touchDidNotReorder, notReorderableInView/viewDidNotReorder, fixedFloorNotReorderable; unit-контракт canStartTabDrag не тронут и остаётся зелёным в npm test |
| AC7 | Выполнен, разобран по коду (не новым тестом) | _commitTabOrder — тело функции не изменено этим diff (сверено чтением: единственные правки в файле — сигнатура вызова _tabPointerUp → _commitTabOrder(drag.id, target.targetId), сам метод материализации/_saveConfig/toast остался прежним); active-space неизменность и однократный toast подтверждены smoke (activeSpaceUnchanged, danglingMarkerPinnedToItsOldSpace, warnedAboutPositionalFloor/warningNotRepeated) |
| AC8 | Выполнен | Golden: обе новые сцены дошли до missing-baseline (не error) — semantic guard в harness.mjs (dragging/targetId/placement/inset знак/непустой rect) прошёл на реальном page.mouse-жесте; test/golden-matrix.test.mjs (новый тест «space-tab drop goldens hold both insertion sides…») проверяет структуру матрицы (npm test PASS) |
| AC9 | Выполнен | Typecheck/unit/build/3-bundle зелёные (см. таблицу гейтов); docs/CHANGELOG.md, .ru.md, docs/USER-GUIDE.md, .ru.md изменены в том же коммите f4de527, что и поведение, с User-Visible: yes |
Находки
Находок нет — ни High, ни Medium, ни Low.
Отдельно проверенные риски ложной уверенности, снятые разбором:
- Не является ли smoke обходом бага той же природы, что диагностировал ТЗ? Нет: весь diff smoke-теста построчно прочитан (см. «Как проверялось», п.8) — позитивные AC1–AC4 используют
page.mouse.move/down/up, а неtarget.dispatchEvent(new PointerEvent(...)); синтетическийdispatchEventостаётся только в touch-негативной ветке (AC6) и в ручномpointercancel(AC5, где явная цель — проверить конкретную веткуevent.type, что сама ТЗ §13.1 разрешает). - Действительно ли перенос
pointermove-listener с каждой.tabна контейнер.tabs— не косметика, а исправление первопричины? Да: послеsetPointerCaptureсобытие ретаргетируется на захвативший элемент, но продолжает всплывать по DOM от него; старый код вешал обработчик на каждую.tabв отдельности, поэтому только обработчик исходной вкладки получал событие (с захардкоженнымoverIdтого элемента, на который он был навешен) — вкладки-соседи вообще не видели этотpointermove. Слушатель на общем контейнере получает всплывшее событие независимо от того, какая вкладка была исходной, и определяет цель уже поclientX/clientY, а не по тому, чей обработчик сработал. Логика подтверждается тем, что новый мутантtab-drag-target-follows-captured-source(возвращающий старую адресацию через координаты источника вместо курсора) ловится смоком. - Не сломался ли контракт «клик после drag не переключает пространство» при переносе состояния в отдельный
_tabSuppressClick-флаг вместо проверкиthis._tabDrag?.movedв_tabClick? Проверено:_tabDragобнуляется синхронно внутри_tabPointerUpдо того, как браузер успевает датьclick, поэтому проверкаthis._tabDrag?.movedв_tabClickбыла бы уже нерабочей к моменту клика; новый явный флаг_tabSuppressClick, выставляемый до_endTabDrag()и потребляемый ровно одним следующим_tabClick, — корректная замена. SmokeactiveSpaceUnchangedпосле обоих реальных drop показывает, что паразитный клик не переключает пространство. - Не расширяет ли
_endTabDrag(), добавленный в_pickSpaceи_setMode, скоуп задачи? Нет: это защитное завершение затянувшегося drag при программной навигации/смене режима, прямо предписанное контрактом завершения ТЗ §8 («смена режима/пространства... очищают drag»); покрыто smoke (modeChangeEndsDrag/modeChangeDidNotWrite). - Не потерялась ли материализация fallback-маркеров/expected_rev (AC7) при рефакторинге? Нет — тело
_commitTabOrderне изменено этим diff, сверено построчно; только сигнатура вызова со стороны_tabPointerUpменяет второй аргумент сdrag.overIdнаtarget.targetId.
Что проверено и корректно
- Диагноз ТЗ (per-tab listener + retargeting после
setPointerCaptureфиксируетoverIdна источнике) воспроизведён и устранён именно тем способом, который ТЗ §19 п.2 разрешал как одну из опций (обработчик на.tabs). - Координатный hit-test (
_tabDropTargetAt) реализует все пять правил §7 ТЗ: попадание строго поgetBoundingClientRect(), исключение исходной вкладки и.tabadd(у неё нетdata-hp="space-tab"— проверено grep),before/afterпо индексу в модели, а не по геометрии, сброс цели при промахе, устойчивость к wrap (rect конкретного элемента, не строки). - Визуальный контракт (
drop-before/drop-after,insetbox-shadow с положительным/отрицательным знаком, один theme token) соответствует §9; подтверждено и smoke (insetX), и golden semantic guard. - Двойная доставка
pointerup(риск ТЗ §18 п.4) не воспроизводится:_endTabDrag()снимает оконные listeners внутри того же вызова, что и обрабатывает первую доставку, поэтому вторая (оконная) доставка того же физического события до второго вызова не доходит — не тестируется отдельным сценарием, но логически следует из существующегоtab-drag-survives-release-outside/tab-drag-outlives-the-cardмутационного покрытия, которое осталось зелёным. pointercancelидёт отдельным путём отpointerupвнутри одной функции_tabPointerUpчерез проверкуevent.type === 'pointerup', что корректно, так как один и тот же_tabDragReleaseобработчик регистрируется на оба типа событий наwindow.- Golden-харнесс освобождает мышь в
finally(demo/golden/run.mjs) даже при падении семантической проверки — снимает риск ТЗ §18 п.5 («golden удерживает мышь между сценами»). - Три новых мутационных guard'а (§14 ТЗ) заведены, каждый по отдельности «поймано 1 из 1»; существующий мутант
tab-drag-outlives-the-cardадаптирован под новое поле_tabSuppressClickTimerи остаётся в реестре. - Оба changelog,
docs/USER-GUIDE.md/.ru.md,docs/TESTING.md, docs screenshot fingerprint — обновлены в одном user-visible коммите; терминология руководства («вкладка», «разделитель», «сторона вставки») не изобретена заново, согласована с текстом ТЗ. - Класс файлов:
f4de527трогает A (src/**), B (demo/**,scripts/**,test/**), C (docs/**) и D (dist/**,custom_components/houseplan/frontend/**,demo/srv/assets/**) в одном коммите — по таблице PROCESS.md §1 это допустимо (правило про «только D» не применяется, здесь D сопровождает A/B/C в одном релизном шаге реализации, не самостоятельный промоушен). - Ветка
issue/243-space-tab-drop-target, трейлерыIssue: #243на всех продуктовых/тестовых/доковских коммитах,User-Visible: yesтолько на коммите с видимым поведением — соответствует Правилам 3, 10.
Чего не проверял
- Полный
npm run golden:verifyпо всем 88 сценам построчно как предмет разбора — только то, что две НОВЫЕ сцены (#243) дошли доmissing-baseline, а неerror. Остальные ~74 «different»/«missing-baseline» сцены не относятся к этому diff (не менялись им) и являются известным долгом непринятых baseline наdev, зафиксированным автором; их принятие — предрелизная обязанность (Правило 13), не этого код-ревью. smoke_audit_1490,smoke_glow_blending,smoke_render_perf,smoke_styling_hooks,smoke_virtual_light_toggle,smoke_zoom_out— не прогонялись; связь со smoke-select только по общему полю_model, тема (рендер устройств/зум/тени) не пересекается с изменённым путём (drag вкладок пространств).python -m pytest tests_backend— не запускался, backend не тронут.- Performance-профиль — не запускался, не назван в AC, обоснование в ТЗ §11 (≤50 пространств, линейный проход) проверено чтением, не измерением.
- Кросс-браузерная эквивалентность
ShadowRoot.elementFromPoint()— не актуально: реализация выбрала прямой переборgetBoundingClientRect(), а неelementFromPoint, поэтому этот пункт ТЗ §7 (альтернативный допустимый способ) не применяется к фактическому коду. - Ручное визуальное сравнение golden PNG (нет предмета — baseline для двух новых сцен ещё не принят и не должен приниматься в этом цикле).
Вердикт
Все девять AC доказаны — восемь автотестом (browser smoke с трастед page.mouse, mutation-gate, golden semantic guard, golden-matrix unit) и один (AC7) разобран чтением кода с явной пометкой «проверено чтением, не исполнением» для тела _commitTabOrder», которое diff не менял. Диагноз ТЗ устранён по объявленному в ТЗ механизму (перенос pointermoveна контейнер.tabs, координатный hit-test независимо от event.target`). Три новых мутанта индивидуально проверены как «умеющие покраснеть»; полный реестр из 118 мутантов, включая унаследованные от #220, зелёный. Обязательные и относящиеся к diff дополнительные гейты прогнаны лично, результаты приведены с точными командами. Оба changelog, USER-GUIDE EN/RU и docs-фингерпринт обновлены в том же user-visible коммите. Находок нет.
Вердикт: зелёный · заход r1 · блокирующих циклов 0/4 · High: 0 · Medium: 0 → в задаче