Files
houseplan-card/docs/reviews/CODE-REVIEW-447-r1.md
T
claude[bot] d5bfa0e3e1
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m5s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 22s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 25s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 42s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 16s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 7m7s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 8m25s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
docs: review document for #447
Issue: #447
User-Visible: no
2026-09-04 06:21:59 +00:00

21 KiB
Raw Blame History

CODE-REVIEW-447-r1

Issue: #447 · Этап: code · Заход: r1 · блокирующих циклов израсходовано 0 из 4

Материал: git diff origin/dev...HEAD на коммите f62dd6b6 (HEAD ветки issue/447-exterior-snap-keyboard, git merge-base origin/dev HEAD совпадает с origin/dev — ветка не отстаёт и не расходится).

Коммиты в диапазоне:

  • a1fbea07, 6c668d4b, 818ad2d2, 62ecb856 — спецификация и её ревью (spec-этап, уже пройден зелёным в r2, здесь не пересматривается);
  • 6b07b70d — продуктовый код, тесты, документация, changelog (Issue: #447, User-Visible: yes);
  • f62dd6b6 — принятый screenshot fingerprint (Issue: #447, User-Visible: no).

Скоуп проверки

ТЗ: docs/specs/447-exterior-furniture-snap-keyboard-nudge.md, прошло независимое spec-ревью r2 зелёным (H1 r1 закрыт). Код-ревью проверяет реализацию против контракта §1–§4 и AC1–AC9 этого файла.

Изменённые продуктовые файлы:

  • src/furniture-wall-surface.ts — парная наружная поверхность внешнего атома;
  • src/furniture-placement.ts — sideScore → eligibility-фильтр для парных поверхностей;
  • src/editors/decor/geometry.ts — nudgeDecorShape, чистый расчёт клавиатурной дельты;
  • src/houseplan-card.ts — Arrow-guard в _keyHandler, _decorNudge;
  • test/furniture.test.mjs, test/decor-geometry.test.mjs — новые unit;
  • demo/smoke_furniture.mjs, demo/smoke_decor.mjs — новые browser-сценарии;
  • scripts/mutation-gate.mjs — три новых дорогих мутанта;
  • docs/FURNITURE.md, docs/DECOR-EDITOR.md, docs/USER-GUIDE.ru.md, оба CHANGELOG, docs/images/screenshots.json.

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

Зелёного Validate на f62dd6b6 не найдено, поэтому дешёвые гейты прогнаны самостоятельно, на этом SHA:

Гейт Результат
npx tsc --noEmit зелёный, без вывода
npm test 1889 тестов, 1888 passed, 0 failed, 1 skipped (совпадает с числом из отчёта реализации)
npm run build собрался, 11.8s
npm run bundle:sync зелёный; git status после — чисто, обе поставляемые копии (custom_components/.../frontend, dist/) побайтно совпадают с закоммиченными
npm run bundle:budget зелёный: initial View 293220 B gzip / потолок 294000 B; предупреждение о запасе 6780 Б — тот же долг #367, не новый
node scripts/check-docs.mjs зелёный (диф трогает src/** → обязателен); docs/images/screenshots.json до/после: imageSha256 всех 10 сценариев не изменился, изменился только sourceFingerprint/sourceSha256 — переотпечаток без визуальной регрессии
node scripts/no-new-any.mjs --base origin/dev --head HEAD 117 новых строк, новых any нет
node scripts/mutation-gate.mjs --check все мутанты реестра, включая три новых (furniture-exterior-surface-removed, decor-keyboard-nudge-reruns-magnet, decor-keyboard-nudge-drops-focus-dialog-guards) — ok (патчи применимы, guard-файлы существуют); дорогой прогон (пересборка бандла на мутанте) не повторялся — это предрелизный гейт, автор уже прогнал и получил красный→зелёный на каждом
node scripts/smoke-select.mjs --base origin/dev --head HEAD см. раздел ниже
node demo/smoke_furniture.mjs зелёный, все поля true, включая три новых (exteriorPreviewStaysOutside, exteriorCommitMatchesPreview, exteriorExactAxisDragPreservesSide)
node demo/smoke_decor.mjs зелёный, все поля true, включая девять новых Arrow-полей
npm run invariants не прогонялся: диф не трогает persisted geometry/layout/wall thickness records/marker.space/open_spans — новые поверхности transient editor-only (подтверждено чтением furnitureWallSurfacesFor: кэш по _cfgEpoch, не пишется в конфиг)
python -m pytest tests_backend не прогонялся: custom_components/**/*.py не тронут (только сгенерированный frontend-бандл)

smoke-select

Изменено файлов src/**: 4 · символов проекта на изменённых строках: 27
Матрица: 219 смоков · порог «широкого» символа: больше 43 смоков
Прямое совпадение (30): ...
Слабая связь (40): ...
Не учитывались как широкие: _gridPitch

Решение по каждой строке «прямого совпадения»: не прогонял ни один из оставшихся 28, кроме уже расширенных smoke_furniture.mjs и smoke_decor.mjs. Основание: общие символы совпадения — _curSpaceCfg, _saveConfig, _mode, _decorTool, _decorList, _decorMove, _decorSel, _decorDraft, _dtDrag, _bdDrag, _geometrySnapshot, NORM_W — это состояние общего editor-harness, которое называет почти любой decor/furniture смок независимо от темы; ни один из них не читает roomFurnitureWallSurfaces, snapFurnitureToWall или клавиатурную дельту. Показательно, что сам _gridPitch — тот единственный символ, который централен именно для AC4 — инструмент исключил как «слишком широкий» (пересекает порог в 43 смока) и в список совпадений вообще не попал; это подтверждает, что список совпадений управляется общими полями оркестрации, а не темой задачи. smoke_grid_snap.mjs и smoke_drag_bounds.mjs проверяют pointer-snap и pointer-drag границы — не Arrow-путь; AC6 (bounds/no-deform для клавиатурного сдвига) уже доказан unit-тестом с проверяемой падаемостью (см. ниже), отдельный browser-повтор не добавляет покрытия для этой дельты. Слабые связи (40 позиций) не просматривались — все совпадения там по одному распространённому имени (_mode, _curSpaceCfg и т.п.), гейт называет это поводом посмотреть, не обязанностью прогонять.

Дисциплина «тест должен уметь падать»

Проверено для тестов, которые я прогонял, локальной мутацией с последующим git checkout --:

  • const exterior = ownersByAtom.get(atomId)?.size === 1 && half > 1e-9; → const exterior = false; — npm test красит 4 теста (AC1/AC2/AC3-смежные unit в test/furniture.test.mjs);
  • const wantedX = renderDx / canvasW, wantedY = renderDy / canvasH; → const wantedX = renderDx, wantedY = renderDy; в nudgeDecorShape — test/decor-geometry.test.mjs красит тест «keyboard nudge moves every decor kind by one visible cell...» (AC4), причём падает именно на масштабе (AssertionError: expected true, actual false в close()), а не на постороннем поле.

Оба файла возвращены в исходное состояние, git status после — чисто.

Находки

Не найдено ни одной находки — ни High, ни Medium, ни Low.

Проверено и корректно

  • AC1 (наружная поверхность). roomFurnitureWallSurfaces группирует атомы по ownersByAtom (ключ — канонический segmentIdentity(axisA, axisB), инвариантный к порядку точек), и только атом с ровно одним владельцем и half > 1e-9 получает вторую, наружную поверхность (src/furniture-wall-surface.ts:92-108). Наружная поверхность построена как axis - inwardNormal*half с нормалью -inwardNormal — ровно то, что требует §1 ТЗ. Внутренняя поверхность (уже существовавшая) сохранена без изменений геометрии, только помечена roomSide: 'inside'.
  • snapFurnitureToWall — фильтр, не тай-брейк, только для парных поверхностей. if (surface.roomSide) continue; на стороне «за стеной» (src/furniture-placement.ts:108-114) полностью убирает поверхность из участия при равном/меньшем расстоянии — то, что r1 spec-ревью потребовало явно решить (§2 ТЗ: «фильтр, не тай-брейк»). Общие/нулевые/independent поверхности (surface.roomSide === undefined) не проходят через continue и сохраняют старое поведение sideScore = -2 — регрессии для #445 нет, подтверждено тестом «a shared thick wall selects the intent side...» (не изменён логически, только дополнен проверкой roomSide === undefined).
  • AC2 (общая/нулевая/independent стена). Новый тест «partially shared edges are atomised before exterior ownership is assigned» бьёт по главному риску ТЗ («принять частично общий атом за внешний»): вертикальная стена 100→400 с частичным перекрытием комнатой side на 200→300 даёт shared (2 поверхности, roomSide === undefined) ровно на пересекающемся интервале и inside+outside на обоих внешних огрызках 100–200 и 300–400. Существующий тест инвариантности к порядку/winding/surface order не затронут и остаётся зелёным.
  • Exact-axis tie-break. Новый placement на оси без preferredNormal детерминированно выбирает inside за счёт вторичного тай-брейка stableId.localeCompare (":inside" < ":outside" лексикографически) — устройство неэлегантное, но корректное и покрыто тестом «new exact-axis exterior placement defaults inside while drag preserves either side»; тот же тест подтверждает, что drag с preferredNormal=[0,-1] (наружу) сохраняет наружную сторону.
  • AC3 (preview/commit/drag). demo/smoke_furniture.mjs прогнан лично: наружный pointer даёt preview снаружи, pointerdown коммитит идентичную геометрию, drag с наружной стороны на ось сохраняет сторону — все три поля true.
  • AC4 (клавиатурная дельта, единый источник масштаба). _decorNudge вызывает nudgeDecorShape(selected, renderDx, renderDy, NORM_W, this._decorH, CANVAS_LIMIT) (src/houseplan-card.ts:8460-8463); _decorH — геттер, буквально возвращающий NORM_W (src/houseplan-card.ts:8342-8344, холст квадратный), поэтому renderDx/NORM_W и renderDy/this._decorH вычисляют то же GRID_STEP_N, что уже использует mouse-drag _decorMoveUpdate (src/houseplan-editor-runtime.ts:4353-4354) — то самое требование r1 spec-ревью (H1) выполнено в коде, не только в тексте ТЗ. Unit-тест использует те же экспортированные константы (GRID_PITCH, GRID_STEP_N, NORM_W, CANVAS_LIMIT) из space-geometry.ts, а не захардкоженные числа — «одно число, один источник» соблюдено.
  • nudgeDecorShape не деформирует и не пересчитывает снап. Для линии оба конца получают одну дельту; для box-фигур меняются только x/y, не w/h/angle; функция явно документирована как «does not know about grid or wall snapping». Off-grid фаза сохраняется (дельта прибавляется, не округляется) — проверено тестом с дробным начальным x=0.1013.
  • AC5 (без повторного магнита). demo/smoke_decor.mjs подставляет шпионы на _decorSnap и _editorRuntime._furnMoveUpdate вокруг Arrow-нажатий и проверяет decorSnapCalls === 0 && furnitureMoveCalls === 0 — резолверы магнита физически не вызываются, это не косвенный вывод по результату. Соответствующий мутант decor-keyboard-nudge-reruns-magnet (вызов _decorSnap внутри _decorNudge) ловится именно этим шпионом.
  • AC6 (bounds без деформации). Тест «keyboard nudge preserves line geometry and clamps the whole object at canvas bounds» проверяет и линию (длина/направление неизменны у границы), и повёрнутый на 45° rect через boxCorners (реальные повёрнутые углы, не осевой bbox) — последний шаг короче клетки, следующий — null (no-op). Падаемость теста подтверждена вручную (см. выше).
  • AC7 (history/save = одно нажатие). demo/smoke_decor.mjs: arrowIsOneHistoryAndSaveTransaction проверяет _geometryHistory.size===1, имя записи history.decor_move, ровно один вызов debounced-save и рост _cfgEpoch на 1 за один keydown; boundaryNoopIsConsumedButNotSaved проверяет, что no-op у границы не трогает ни историю, ни _cfgEpoch, ни счётчик save. Undo/Redo восстанавливают ровно один шаг (arrowUndoRedoIsExactlyOneStep), selection не теряется.
  • AC8 (поля/диалоги/жесты/модификаторы). Guard в _keyHandler (src/houseplan-card.ts:3024-3034) переиспользует существующий inField/inEditorSecondary/_editorSecondaryDialogBlocked паттерн, уже проверенный для Delete/Backspace двумя строками выше. Смок отдельно проверил input/textarea/select/contenteditable, toolbar-кнопку, открытый _decorEraseConfirm, активный _decorMove и Ctrl/Cmd/Alt+Arrow — все не меняют декор. Shift+Arrow даёт тот же один шаг (mod не включает shiftKey, что видно по определению const mod = e.ctrlKey || e.metaKey) — соответствует явному решению владельца «ускоренного шага по Shift нет».
  • AC9 (совместимость и бюджет). Все перечисленные в AC9 гейты (typecheck, test, build, bundle:sync, bundle:budget, check-docs) зелёные, включая мой независимый прогон, не только отчёт автора. furnitureWallSurfacesFor кэширует по [space.id, _cfgEpoch, _cellCm, _gridPitch, _wallKeyPitch] — как и раньше, один пересчёт на geometry epoch, новый парный кандидат не меняет модель кэширования.
  • Trailers и changelog. 6b07b70d: Issue: #447, User-Visible: yes, в том же коммите правки docs/CHANGELOG.md и docs/CHANGELOG.ru.md. f62dd6b6: Issue: #447, User-Visible: no — верно для чисто fingerprint-коммита без видимого изменения (все 10 imageSha256 побайтно прежние).
  • Терминология. «Редактор подложки», «Выбрать», формулировки про стрелки в docs/USER-GUIDE.ru.md и docs/DECOR-EDITOR.md согласованы друг с другом и с уже принятой в проекте терминологией интерфейса.
  • Одно число — один источник. Диф не добавляет новую видимую пользователю величину, показанную в двух местах (badge длины/площади не пересчитывается по-новому — nudge просто сдвигает существующую геометрию, которую badge и так читает из того же объекта). test/single-source-numbers.test.mjs в общем прогоне npm test зелёный.

Не проверял

  • Дорогой прогон scripts/mutation-gate.mjs (пересборка бандла на каждом из трёх новых мутантов) — это предрелизный гейт (PROCESS.md §8); ограничился --check (применимость патчей) и подтвердил силу соответствующих unit/smoke вручную отдельной локальной мутацией не из реестра (см. «тест должен уметь падать» выше). Автор в комментарии сообщил, что все три мутанта дали красный→зелёный переход при полном прогоне.
  • Полный набор demo/smoke_*.mjs (219 файлов), golden (npm run golden:verify) и performance-профили — не названы в AC и не запускались; задача не меняет статическую композицию готового плана (только интерактивное позиционирование, уже покрытое DOM-coordinate смоками), это явно оговорено в разделе «План тестирования» ТЗ.
  • 28 из 30 «прямых совпадений» smoke-select — решение и обоснование см. в разделе «smoke-select» выше; 40 «слабых связей» не просматривались.
  • npm run invariants и backend pytest — диф не касается persisted-геометрии и custom_components/**/*.py, гейты неприменимы (обоснование выше).
  • Ручное тестирование в браузере не проводилось (не входит в цикл ревью); вопрос «оно вообще работает» закрыт чтением кода плюс самостоятельным прогоном demo/smoke_furniture.mjs/demo/smoke_decor.mjs, которые управляют реальным production-бандлом через Playwright, а не мок.

Вывод

AC1–AC9 доказаны кодом и тестами, которые я перепроверил лично (прогон плюс целевая мутация для двух самых нагруженных AC). High/Medium находок нет.


Материал раунда

  • Ветка: issue/447-exterior-snap-keyboard, коммит f62dd6b632cf — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
  • Дерево материала: f6a04165e64ca68009db103a9a8ee83426aff47d
    git log --all --format='%H %T' | grep f6a04165e64c
    
  • ТЗ docs/specs/447-exterior-furniture-snap-keyboard-nudge.md, блоб 5c0036470b3bd675147ad5dc308b8d760be18b9c
    git log --all --find-object=5c0036470b3bd675147ad5dc308b8d760be18b9c -- docs/specs/447-exterior-furniture-snap-keyboard-nudge.md