Files
houseplan-card/docs/reviews/CODE-REVIEW-294-r1.md
T
2026-08-24 18:16:46 +00:00

20 KiB
Raw Blame History

CODE-REVIEW-294-r1

  • Issue: #294 — «Рисование стен: Esc отцепляет текущую цепочку без удаления последнего отрезка»
  • Заход: r1 (первый код-ревью-цикл; ревью ТЗ прошло r1→r2 отдельно и не считается в этом бюджете)
  • Блокирующих циклов: 0/4 израсходовано до этого захода
  • Ревьюемый коммит: 32e8de4f («feat: detach wall chain on Escape»), ветка issue/294-wall-esc-detach
  • Материал: git log --oneline origin/dev..HEAD, git diff origin/dev...HEAD
  • ТЗ: docs/specs/294-wall-esc-detach.md (редакция r2, принята зелёным SPEC-REVIEW-294-r2)

Скоуп изменения

Диапазон origin/dev..HEAD — пять коммитов: аналитика/ТЗ/два ревью ТЗ уже приняты отдельно, реализация — один коммит 32e8de4f. Файлы этого коммита:

  • src/houseplan-card.ts — 1 содержательная строка: Escape в ветке this._tool === 'draw' && this._path.length теперь вызывает this._finishWallChain() вместо this._undoPoint();
  • src/i18n/en.json, src/i18n/ru.json — markup.hint_points различает Esc/Ctrl+Z;
  • demo/smoke_unified_wall_tool.mjs — новые regression-сценарии через реальный window keydown и stage click;
  • docs/USER-GUIDE.md, docs/USER-GUIDE.ru.md — все шесть поверхностей, перечисленных в §5 ТЗ и требуемых AC7;
  • docs/CHANGELOG.md, docs/CHANGELOG.ru.md — бюллетень в том же коммите;
  • dist/houseplan-card.js, custom_components/houseplan/frontend/houseplan-card.js — синхронизированный бандл (класс D, сгенерировано bundle:sync).

Трейлеры коммита: Issue: #294, User-Visible: yes — оба changelog в этом же коммите. Корректно.

Как проверялось

Гейты

Гейт Статус Примечание
npx tsc --noEmit pass без вывода
npm test pass 1251 tests, 1250 pass, 1 skip, 0 fail — совпадает с хендоффом
npm run build + cmp dist ↔ frontend pass сборка идентична закоммиченным dist/ и custom_components/.../frontend/
npm run bundle:sync (для стенда) выполнен локально ревьюером demo/srv/assets/houseplan-card.js не коммитится (#255), пересобран для прогона смоков
node scripts/check-docs.mjs FAIL см. Находку 1 — обязателен, диф трогает src/**
node scripts/smoke-select.mjs --base origin/dev --head HEAD выполнен вывод ниже
node demo/smoke_unified_wall_tool.mjs (прямое совпадение) FAIL (2 из 26) см. Находку 2
node demo/smoke_wall_chain_merge.mjs (прямое совпадение) pass 9/9, без регрессий
node demo/smoke_wall_chain_thickness.mjs (прямое совпадение) pass OK
npm run golden:verify не прогонялся см. «Чего не проверял»
npm run invariants / geometry model не прогонялся отдельно см. «Чего не проверял»
python -m pytest tests_backend не прогонялся диф не трогает custom_components/**/*.py

Вывод scripts/smoke-select.mjs --base origin/dev --head HEAD:

Изменено файлов src/**: 1 · символов проекта на изменённых строках: 2
Матрица: 184 смоков · порог «широкого» символа: больше 36 смоков

Прямое совпадение (3):
  demo/smoke_unified_wall_tool.mjs
    ← _finishWallChain
  demo/smoke_wall_chain_merge.mjs
    ← _finishWallChain
  demo/smoke_wall_chain_thickness.mjs
    ← _finishWallChain

Решение по строке: все три «прямых совпадения» прогнаны (таблица выше). Остальные 181 смоков не запускались — задача меняет одну ветку клавиатурного обработчика существующего единого инструмента «Стены», не трогает face detection, split, decor, resize, touch и т.д.; полный прогон матрицы — гейт предрелиза (§8), не этого ревью.

AC — построчно

  • AC1 (smoke): доказан кодом и частично смоком. _finishWallChain() при this._path.length >= 2 конвертирует сегменты в partitions той же функцией, что и при смене инструмента, удаляет room_draft, очищает _path/_activeDraftId/_draftSegmentCms/_closingWallCm и снэп-hover (src/houseplan-card.ts:6720-6774). Ассерт escapeFinishesWithoutDeletingSegments в смоке зелёный. **Проверено чтением
    • прогоном смока.**
  • AC2 (smoke): код проверен чтением и подтверждён отдельным отладочным прогоном, но штатный смок красный — см. Находку 2. Причина красного — дефект теста (сравнение чисел с плавающей точкой), не продукта: код разбора клика падает в ту же _markupClick → _path.length === 0 ветку → новая точка становится первым узлом чистого пути, а _draftEndAt ищет только в space.room_drafts, который после Esc-finish пуст, поэтому старая точка C не подхватывается (src/houseplan-card.ts:7565-7591, 7621-7636). AC2 подтверждён чтением кода и точечной проверкой (см. Находку 2), но не зелёным автотестом — до фикса теста это открытый пункт.
  • AC3 (smoke): escapeClearsOnlyTransientFirstPoint зелёный; ветка this._path.length < 2 в _finishWallChain очищает состояние без записи room_draft/partition/history (6723-6730). Подтверждено.
  • AC4 (smoke): ctrlZContractStillRemovesLastSegment зелёный; Ctrl/Cmd+Z не тронут этим диффом и по-прежнему идёт через _undoActiveDraftPoint/_undoPoint (2588-2608). Подтверждено.
  • AC5 (smoke): firstEscapeOnlyCancelsRoomDialog и secondEscapeFinishesRestoredDraft зелёные; в _onKey проверка if (this._roomDialog) { …; return; } (2623-2627) стоит раньше ветки draw/_path.length (2628-2635) и не тронута диффом. Подтверждено.
  • AC6 (smoke + чтение): rejectedFinishKeepsActiveDraft зелёный; проверка MAX_PARTITIONS в _finishWallChain (6739-6742) выполняется раньше любой мутации _path/sp.partitions/history, поэтому false не теряет состояние. Подтверждено.
  • AC7 (unit + ревью кода): все шесть поверхностей из §5 ТЗ построчно сверены с docs/USER-GUIDE.md/docs/USER-GUIDE.ru.md — см. раздел «Что проверено и корректно». test/i18n-parity часть npm test зелёная. Подтверждено чтением, без старых противоречащих формулировок.
  • AC8 (unit + build): tsc, npm test, npm run build+bundle parity — все pass (таблица гейтов). Подтверждено исполнением.

Находки

Находка 1 (Medium, в скоупе — правится в этом issue)

Обязательный гейт node scripts/check-docs.mjs красный на ревьюемом SHA.

ERROR screenshot source fingerprint is stale; run npm run build && node demo/docs/capture.mjs

Воспроизведение: на origin/dev (8c036564) тот же скрипт зелёный — Documentation checks passed (7 files, 10 external links). На 32e8de4f (этот диф) — красный, потому что docs/images/screenshots.json.sourceFingerprint считается по всему src/** (#245/#246), а src/houseplan-card.ts изменён, и пересъёмка/принятие скриншотов в этом коммите не выполнены.

Это ровно тот гейт, для которого правила ревью прямо говорят «выбирать нечего» и напоминают цену пропуска: в #230 и #234 его пропустили, и dev простоял с красным job docs до следующей задачи (#237). Оставлять его красным в этом ревью значило бы повторить #230/#234.

Правка: прогнать job «Docs screenshots» (workflow_dispatch), принять результат npm run docs:accept -- --reviewed --from=<артефакт> и закоммитить обновлённый docs/images/screenshots.json (+ при необходимости PNG) вместе с задачей, либо явно объяснить в хендоффе, почему для этого текстового изменения пересъёмка не нужна (в текущем виде это не объяснено и гейт просто красный).

Находка 2 (Medium, в скоупе — правится в этом issue)

Новый таргетированный смок demo/smoke_unified_wall_tool.mjs падает на двух ассертах, которые как раз доказывают AC2.

FAILED (2):
  - nextClickStartsIndependentChain: expected true, got false
  - secondClickCreatesOnlyIndependentSegment: expected true, got false

Воспроизведение: npm run bundle:sync && node demo/smoke_unified_wall_tool.mjs (бандл стенда не коммитится — #255 — поэтому синхронизация нужна перед прогоном). Причина — не регрессия продукта, а сравнение координат с плавающей точкой без допуска. Новый хелпер clickStage(x, y) (строки 18-28) переводит логические координаты в clientX/clientY через rect/view, диспатчит настоящий DOM click на .stage, а тест затем требует точного равенства card._path[0][0] === 500 && card._path[0][1] === 500. Отдельным отладочным прогоном подтверждено: тот же клик (500, 500) на чистой карте даёт card._path === [[500, 500.00000000000006]] — снэп к сетке в _resolvePlanDrawPoint/_svgPoint вносит обычную для плавающей арифметики погрешность 6·10⁻¹⁴. Второй ассерт (secondClickCreatesOnlyIndependentSegment) делит то же число на NORM_W и сравнивает с 0.5 — получает 0.5000000000000001 и тоже падает. Сам продукт при этом работает верно: _draftEndAt (строки 7621-7636) ищет привязку только в space.room_drafts, который после Esc-finish пуст (черновик уже стал partitions), поэтому клик по D действительно не подхватывает старую точку C — просто тест это не может подтвердить в текущем виде.

Остальные 8 новых ассертов того же файла (включая три с реальным window keydown — escapeFinishesWithoutDeletingSegments, repeatedEscapeAfterFinishIsNoop, rejectedFinishKeepsActiveDraft, а также диалоговые firstEscapeOnlyCancelsRoomDialog/secondEscapeFinishesRestoredDraft) зелёные и доказывают AC1, AC3, AC5, AC6 честно — тест умеет падать: замена card._finishWallChain = () => false перед rejectedFinishKeepsActiveDraft подтверждает это на месте.

Правка: сравнивать с допуском (как это уже принято для координат в проекте, например Math.hypot(...) <= eps в _draftEndAt этого же файла) либо округлять полученную точку перед сравнением, а не требовать === 500 после реального клика через трансформацию viewBox.

Что проверено и корректно

  • Единственная содержательная правка продукта — одна строка (src/houseplan-card.ts:2630-2633): подмена _undoPoint() на _finishWallChain() в ветке Escape при активном draw и непустом _path. Никакой другой код _onKey, _undoPoint, _undoActiveDraftPoint, _activateMarkupTool, приоритет _roomDialog/_physicalDrag не менялся — риск регрессии сосредоточен ровно в этой ветке, и она разобрана по AC1-AC6 выше.
  • _finishWallChain при отказе (MAX_PARTITIONS) не мутирует _path до проверки лимита — active state переживает false (AC6), самостоятельно подтверждено чтением строк 6720-6742 и зелёным rejectedFinishKeepsActiveDraft.
  • Приоритет _roomDialog перед веткой draw не нарушен: строка 2623-2627 идёт раньше 2628-2635 и не тронута диффом (AC5).
  • _resumeDraftBySpace не сохраняет ссылку на завершённый draft: путь path.length < 2 (без активного _activeDraftId, что гарантировано инвариантом «draft создаётся только при path.length >= 2», строка 7758) и путь успешного finish (delete this._resumeDraftBySpace[this._space], строка 6767) оба корректны — прочитано и совпадает с ассертами !card._resumeDraftBySpace[card._space].
  • Все шесть справочных поверхностей из ТЗ §5/AC7 действительно переписаны и построчно сверены с docs/USER-GUIDE.md/docs/USER-GUIDE.ru.md; старых формулировок «Esc/Ctrl+Z — убрать точку»/«cancels an unfinished path» для контекста Walls не осталось. markup.hint_points в en.json/ru.json совпадает по смыслу с этими описаниями.
  • Оба changelog обновлены в том же коммите 32e8de4f, что и продукт — User-Visible: yes соблюдён.
  • dist/houseplan-card.js и custom_components/houseplan/frontend/houseplan-card.js идентичны локально пересобранному бандлу (cmp совпал).
  • «Одно число — один источник»: этот диф не вводит и не дублирует ни одной видимой пользователю величины (только текст подсказки/руководства); числовых значений (толщина, площадь) правка не касается.
  • Модель геометрии (рёбра, толщина, layout, marker.space, open_spans) не затронута: изменена только точка вызова уже существующей и протестированной _finishWallChain, а не сама геометрия/merge/лимиты. npm test (включающий инварианты модели на всех моделях проекта) прошёл целиком.

Чего не проверял

  • npm run golden:verify — не прогонялся. Диф не меняет рендер геометрии, стилей или слоёв (только текст подсказки и порядок вызова уже протестированной функции), и ни один golden-сценарий не ссылается на hint_points/Escape-детач; риск сочтён нерелевантным для этого изменения.
  • node scripts/model-invariants.mjs --config <…> отдельно не запускался. Диф не меняет генерацию/мерж геометрии — переиспользует существующий _finishWallChain, уже покрытый smoke_wall_chain_merge.mjs/ smoke_wall_chain_thickness.mjs (прогнаны, зелёные) и структурными инвариантами внутри npm test.
  • python -m pytest tests_backend — не прогонялся, диф не трогает custom_components/**/*.py.
  • Полная матрица demo/smoke_*.mjs (184 файла) не прогонялась — прогнаны три «прямых совпадения» из smoke-select; остальные 181 — предрелизная обязанность (§8), задача не задевает face detection, split, decor, resize, touch, kiosk и т.п.
  • Touch/kiosk-поведение не проверялось руками — правка задевает только desktop-обработчик Escape, что подтверждено чтением: единственное добавленное ветвление стоит после this._tool === 'draw' в клавиатурном обработчике, тач-жесты (pointercancel, pinch, pan) идут через другие ветки того же _onKey/_stagePointer*, не тронутые диффом.
  • Производительность отдельно не профилировалась — в АС и ТЗ не заявлено влияние на perf-бюджет, а изменение сводится к перенаправлению одного вызова уже существующей O(n) операции.

Итог

High: 0. Medium: 2, обе в скоупе задачи (обязательный docs-гейт красный; собственный новый смок ломается на двух ассертах из-за сравнения floating-point координат без допуска, хотя доказываемое им поведение AC2 при проверке чтением кода и точечным прогоном подтверждено верным). Без High это жёлтый вердикт, возврат автору на правку в этом же issue — отдельный issue не заводится (обе находки в скоупе, §2.7/#202).