Files
houseplan-card/docs/reviews/CODE-REVIEW-173-r1.md
T
2026-08-18 20:33:44 +03:00

30 KiB
Raw Blame History

CODE-REVIEW-173-r1

  • Issue: https://github.com/Matysh/houseplan-card/issues/173
  • Диапазон: origin/dev..HEAD (d096a02 ТЗ, 4f67bac ревью ТЗ, f931159 feat: unify Plan wall drawing — единственный коммит класса A/B)
  • Роль: ревьюер кода (не автор), этап S7-code-review, сессия без контекста реализации
  • Цикл: r1/4
  • ТЗ: docs/specs/173-unified-wall-tool.md, ревью ТЗ — docs/reviews/SPEC-REVIEW-173-r1.md (зелёный, r1/4, High:0 Medium:0, три Low)

Скоуп ревью

Основной диф (f931159, 28 файлов, +3304/-1926):

  • src/wall-face-graph.ts — новый чистый модуль planar-graph (atomization, half-edge face traversal, before/after delta);
  • src/houseplan-card.ts — интеграция: единый tool «Стены», finish открытой цепочки, _wallFaceBatch (очередь faces), atomic batch commit, provenance толщины, compatibility со старым partition warm-tool;
  • test/wall-face-graph.test.mjs — unit на чистый модуль;
  • demo/smoke_unified_wall_tool.mjs (новый), demo/smoke_room_autoclose.mjs, demo/smoke_draw_wall_thickness.mjs — целевые/регрессионные smoke;
  • demo/benchmark_large_house.mjs, demo/performance/* — accepted-click измерение и bounded-cache бюджет для large-house-plan-snap-v1;
  • i18n en.json/ru.json, docs/{ARCHITECTURE,CANVAS,WALL-THICKNESS,UX-MODES, USER-GUIDE.ru,USER-GUIDE,TESTING,TOUCH-SUPPORT,STATUS}.md, docs/CHANGELOG{.ru,}.md;
  • dist/houseplan-card.js, две другие копии бандла (класс D).

Не в скоупе изменений (проверено по git diff --stat, класс не задет): custom_components/houseplan/**/*.py, backend schema, demo/golden/**.

Как проверялось — гейты

Гейт Команда Результат
typecheck npx tsc --noEmit pass, без ошибок
unit npm test 843/843 pass (npm run inventory: Node unit 843)
build + sync бандла 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, три копии побайтно идентичны
целевой smoke (AC1–AC13) node demo/smoke_unified_wall_tool.mjs 19/19 pass
регрессия #138/AC7 node demo/smoke_room_autoclose.mjs 9/9 pass, включая xIntersectionOffersFacesWithoutPartialRooms, openingCutPreventsAutoClose
регрессия толщины (AC1/AC12) node demo/smoke_draw_wall_thickness.mjs 11/11 pass, drawButtonNamedWalls: true
touch safety floor (AC3/AC16) node demo/smoke_editor_gestures.mjs 5/5 pass — pinch/pan не рисуют, tap рисует
overlap/island примитив (косвенно к AC8) node demo/smoke_island_rooms.mjs 7/7 pass — но см. Medium-2, этот smoke не тронут #173 и не проходит через новую интеграцию
performance (AC15) npm run benchmark:large-house-plan-snap --profile=large-house-plan-snap-v1 pass, без брошенных structural contract failed/accepted-click contract failed; wallFaceAcceptedClickMs ≈ 2.4–4.1 мс (порог в самом бенчмарке — 1000 мс); wallFaceGraph cache entries = 2 (бюджет: max 4, growth 0)
process-gate (локально) node scripts/process-gate.mjs «гейт пройден, предупреждений 0» (диапазон origin/dev..HEAD, 3 коммита)
мутационная проверка «тест умеет падать» вручную: 1) снял фильтр sourceKeys.includes(addedSourceKey) в findNewWallFacesInGraphs — ни один тест не покраснел (см. «Что проверено», пункт про математическую избыточность); 2) снял consumed.has(atom.key) в _applyWallFaceBatch — 2/19 проверок smoke_unified_wall_tool покраснели (acceptCreatesRoomAtomically, mixedQueueAppliesOnce); оба раза откат, бандл пересобран и сверен подтверждает дисциплину §18 для проверенных тестов

Не прогонялось и почему:

  • npm run golden:verify — diff не содержит изменений demo/golden/**, никакой baseline не принимался; по процессу (§8/§11.4) golden — предрелизный гейт, а не гейт код-ревью. AC12 (визуальная неизменность Plan/View/Iso) проверено чтением кода (см. ниже), не исполнением golden.
  • python -m pytest tests_backend -q — ни один файл custom_components/houseplan/**/*.py не тронут (подтверждено git diff --stat), AC14 не требует прогона backend.
  • Полный набор из 138 browser-smoke — диспропорционально задаче: тронута ровно одна поверхность (Plan editor, единый инструмент стен), прогнаны все smoke, прямо названные в AC/§15.2 плюс два соседних (толщина, gestures, island-overlap) по diff-риску. Остальные 132 не имеют пересечения с изменённым кодом.
  • npm run benchmark:compare против baseline SHA — нет сохранённого отчёта предыдущего SHA в этой сессии; сырые цифры бенчмарка (см. выше) сверены с demo/performance/budgets-large-house-plan-snap.json вручную — ни один существующий бюджет (cacheEntries, cacheGrowth, timings) не ослаблен, только добавлены новые ключи wallFaceGraph: 4 / growth 0.

Проверка AC1–AC17

AC Вердикт Как доказано
AC1 ✅ unit (legacy Partition token becomes Walls…) + smoke (oneWallsButton, partitionButtonRemoved, splitRemains). Golden не прогонялся — предрелизный гейт
AC2 ✅ код-чтение _persistActiveDraftSegment/_markupClick (первая точка не пишет config) + smoke (openChainIsCrashSafeDraft, toolChangeFinishesPartitions, finishedChainDoesNotResume)
AC3 ✅ код-чтение _onKey/_finishWallChain/_cancelPath (pan/pinch/pointercancel не вызывают finish — подтверждено отсутствием изменений в их обработчиках) + smoke (escapeUndoContractStillRemovesLastSegment, ctrlZInQueueRemovesTerminalPoint, cancelKeepsWholeTerminalDraft) + smoke_editor_gestures (pinch/pan не рисуют). Reload/remount — только чтением, отдельного smoke на это нет
AC4 ✅ unit wall-face-graph.test.mjs (endpoint/T/X/collinear, near-miss, malformed, permutation invariance, 2000-сегментный sparse plan)
AC5 ✅ unit + прочитан алгоритм: canonical half-edge/DCEL walk с identityCycle, area-фильтром > 0 корректно исключает compound/exterior циклы структурно, не эвристически
AC6 ✅ unit (delta only returns faces introduced by…) + smoke (closedFaceOpensDialog, terminalSegmentPersistedBeforeDecision) + smoke_room_autoclose
AC7 ✅ smoke (multiFaceTClosureOrdered, T/X-сценарии smoke_unified_wall_tool и smoke_room_autoclose); gap через opening — openingCutPreventsAutoClose: true
AC8 ⚠️ проверено чтением, не исполнением _overlapRoom/roomsOverlap не изменены этим issue; wiring в _offerWallFaces/_applyWallFaceBatch прочитан и выглядит корректным (двойная проверка: на предложении face и повторно при commit). Но ни unit (в wall-face-graph.test.mjs, где такой тест структурно невозможен), ни smoke не проверяют duplicate/partial-overlap/nesting именно через новую интеграцию — см. Medium-2, заведён #177
AC9 ✅ smoke (cleanSplitUsesOneCandidate, cleanSplitRetainsParentMetadata, cleanSplitConsumesDivider); splitRoomPath/roomsOverlap не изменены, регрессия Split подтверждена тем, что связанные unit-тесты (не тронуты) в общем прогоне 843/843 зелёные
AC10 ✅ smoke (multiFaceTClosureOrdered — area order; firstQueueDecisionIsBuffered; mixedQueueAppliesOnce — одна транзакция); Cancel/error — cancelKeepsWholeTerminalDraft
AC11 ✅ smoke + мутационная проверка (снятие consumed.has(atom.key) ломает acceptCreatesRoomAtomically/mixedQueueAppliesOnce)
AC12 ⚠️ проверено чтением, не исполнением _applyWallFaceBatch переиспользует немодифицированные materializeWallIntervals, applyWallThicknessToNewRoom, wallIntervals, setWallThickness, _normalizeWalls — те же хелперы, что и старый _commitRoom/Split; второй renderer не введён. Golden (визуальная неизменность Plan/View/Iso) не прогонялся — предрелизный гейт
AC13 ✅ код-чтение (MAX_PARTITIONS/MAX_ROOMS проверяются до mutation в _finishWallChain и _applyWallFaceBatch) + history.wall_chain_finish/history.wall_face_batch как именованные команды. Save-conflict path не эмулировался отдельно — проверено чтением, что batch не трогает существующий reload/conflict механизм
AC14 ✅ git diff --stat подтверждает: ни один файл custom_components/houseplan/**/*.py не тронут; новых persisted полей нет (модель диффа src/houseplan-card.ts не добавляет новых ключей конфига, только новые runtime-поля класса)
AC15 ✅ npm run benchmark:large-house-plan-snap выполнен: budgets не ослаблены, accepted-click ~2.4–4.1 мс, cache bounded (2 из максимум 4), traversal не запускается на pointermove (assert wallFaceCacheStableOnPointer в самом бенчмарке)
AC16 ✅ smoke_editor_gestures (pinch/pan не рисуют) + код-чтение: в диффе нет ни одного изменения в обработчиках pointercancel/pinch/pan — новые finish-точки триггерятся только явной сменой tool/mode/space, что подтверждено grep по всему диффу (0 совпадений на `pointercancel
AC17 ✅ typecheck/unit/build зелёные, три копии бандла идентичны (см. таблицу гейтов), RU/EN i18n и все перечисленные в §17 ТЗ документы обновлены тем же коммитом f931159; User-Visible: yes, оба changelog правлены

Находки

Находок уровня High нет.

Medium-1 — мёртвый код старого инструмента partition не удалён

Файлы: src/houseplan-card.ts:502,533,2187,2216,2269,5216,5283,5304,5341,6178, 6415,6643-6644,6943,9221,9259-9260,9278,11388,16587,17243,17358,17360,17410; src/i18n/en.json/ru.json (title.markup_partition, markup.hint_partition)

Кнопка Partition убрана из toolbar (AC1 подтверждён), но сам инструмент внутри модели — нет. MarkupTool по-прежнему содержит значение 'partition', MARKUP_TOOLS его тоже содержит, приватный метод _partitionClick(...) полностью реализует старую логику «два клика → одна перегородка», и порядка 15 условных веток (this._tool === 'draw' || this._tool === 'partition') по всему файлу продолжают его учитывать — в лимитах, snap, hints, Undo/Escape, рендере превью.

Воспроизведение недостижимости: единственное место, присваивающее this._tool из внешнего/сохранённого значения — this._tool = normalizeMarkupTool(vp.tool) (warm viewport). Внутри normalizeMarkupTool любое значение сначала проходит через normalizeUnifiedWallTool(value), которое безусловно маппит 'partition' → 'draw' до проверки MARKUP_TOOLS.has(...). Ни один обработчик клика тулбара не вызывает _activateMarkupTool('partition') (grep -n "_activateMarkupTool(" — 10 вызовов, ни одного с 'partition'). Значит, this._tool === 'partition' не может стать истинным при обычном пользовательском взаимодействии — код действительно недостижим, а не просто редко используем.

Функционального дефекта это не создаёт (проверено чтением и грепом исчерпывающе), но:

  1. противоречит собственной идее #173 — «единый инструмент» — на уровне внутренней модели, а не только UI;
  2. title.markup_partition/markup.hint_partition стали осиротевшими i18n ключами (использовались только удалённой кнопкой);
  3. риск: будущий контрибьютор может по аналогии вернуть кнопку/ветку, реанимировав нетестируемое, не соответствующее новому crash-safe/finish/ limit контракту поведение.

Решение ревьюера: Medium, не блокирует — заведён #176 (tech-debt, P3, S1-new).

Medium-2 — AC8 не имеет unit/smoke-доказательства именно для новой интеграции

Файлы: test/wall-face-graph.test.mjs (нет теста overlap/duplicate/ nesting), demo/smoke_unified_wall_tool.mjs, demo/smoke_room_autoclose.mjs (ни один не проверяет exact-duplicate/partial-overlap отказ или nested-room через _offerWallFaces/_applyWallFaceBatch)

ТЗ §15.1 п.6 относил проверку «exact duplicate, partial overlap, inner/outer nesting» к unit-суите wall-face-graph.test.mjs. Структурно это невозможно: overlap-семантика (_overlapRoom, roomsOverlap) живёт в src/houseplan-card.ts и не тронута этим issue, а wall-face-graph.ts — чистый модуль без знания о комнатах. Ни там, ни в добавленном/изменённом smoke нет сценария, который замыкает новую площадь поверх существующей комнаты (duplicate), частично поверх неё (partial) или полностью внутри неё (nesting) через новый unified-инструмент. Существующий demo/smoke_island_rooms.mjs (не тронут #173) проверяет тот же примитив _overlapRoom напрямую и через легаси _commitRoom(), минуя _markupClick/_offerWallFaces — то есть не покрывает именно новую интеграцию.

Код-чтение подтверждает, что wiring корректен: _offerWallFaces фильтрует eligible через !this._overlapRoom(face.ring) при предложении, а _applyWallFaceBatch независимо повторяет проверку через roomsOverlap против existingRooms перед commit (defense-in-depth по §10.3 ТЗ) — но заявленный в AC8 способ доказательства (unit + smoke) для этой конкретной поверхности фактически не выполнен.

Решение ревьюера: Medium, не блокирует — заведён #177 (tests, tech-debt, P3, S1-new).

Low-1 — _activateMarkupTool использует if/else без скобок на одной значимой ветке

Файл: src/houseplan-card.ts:6057-6060

if (this._tool === 'draw' && !this._finishWallChain()) return;
else this._cancelPath();
this._tool = tool;

Работает корректно (проверено чтением и трассировкой всех вызовов _activateMarkupTool): при активном tool draw и успешном _finishWallChain() код всё равно доходит до _cancelPath(), что и нужно для сброса _pendingSplit/_splitSel/т.п. при смене инструмента. Но форма if (...) return; else ...; без фигурных скобок на пустой видимой границе читается медленнее, чем заслуживает; в проекте, разделяющем ревью на «что проверено чтением», лишняя секунда на разбор управляющей структуры — не абстрактная придирка.

Решение ревьюера: Low, не блокирует. Можно поправить форматированием при следующей правке этого метода либо оставить как есть.

Low-2 — accepted-click измерение не оформлено как отдельный отслеживаемый бюджет

Файл: demo/performance/budgets-large-house-plan-snap.json, demo/benchmark_large_house.mjs

wallFaceAcceptedClickMs проверяется инлайновым порогом прямо в скрипте бенчмарка (> 1000 → throw), а не как отслеживаемая метрика в timings схемы бюджета (в отличие от planSnapPointerMs, spaceSwitchMs и т.д., которые сравниваются с историческим SHA через benchmark:compare). Фактический результат (2.4–4.1 мс) настолько далёк от порога, что регрессии первого порядка это не пропустит, но постепенный дрейф (например, до 300–500 мс) не будет замечен compare.mjs между релизами, как замечаются остальные тайминги.

Решение ревьюера: Low, не блокирует. На усмотрение автора — добавить wallFaceAcceptedClickMs в отслеживаемые timings бюджета в будущей правке производительности, либо оставить как есть, раз запас по порогу на два с лишним порядка.

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

  • Алгоритм planar-graph (wall-face-graph.ts) — canonical half-edge/DCEL traversal с identityCycle (убирает derived collinear-вершины из identity, чтобы безобидная T-подсадка на прямой стене не выглядела «новой» faces) и положительной/отрицательной ориентацией для отсечения exterior/compound циклов структурно, не эвристическим pairwise-поиском, который прямо запрещён §9.3 ТЗ. Sweep-broadphase на interval treap даёт заявленную сложность без деградации на разреженных планах (unit: 2000 сегментов, faces.length === 0, детерминированный порядок атомов при реверсе ввода).
  • Дисциплина «тест должен уметь падать» подтверждена мутационно. Снятие проверки consumed.has(atom.key) в _applyWallFaceBatch (AC11) ломает 2/19 проверок smoke_unified_wall_tool.mjs (acceptCreatesRoomAtomically, mixedQueueAppliesOnce); откат и повторная сборка подтвердили возврат к 19/19. Отдельно проверено (не для отчёта, а для собственной уверенности), что снятие фильтра sourceKeys.includes(addedSourceKey) в findNewWallFacesInGraphs не ломает ни одного теста — это математически корректно: в планарном графе добавление ровно одного ребра не может создать face, не содержащую это ребро на границе, поэтому фильтр — защитный дубль поверх уже достаточного beforeKeys-diff, а не непротестированная дыра.
  • Finish-контракт (AC2/AC3) на всех трёх точках выхода. _slideTo, _onHashChange и переключение mode (_setMode-путь) единообразно вызывают if (this._wallFaceBatch) this._roomDialogCancel(); if (this._mode === 'plan' && this._tool === 'draw' && !this._finishWallChain()) return; — проверено построчно по всем трём местам диффа, поведение идентично.
  • Cancel/Escape корректно восстанавливает terminal draft. _roomDialogCancel() при активном _wallFaceBatch восстанавливает _path/_draftSegmentCms/ _activeDraftId из снимка batch.activePath/activeCms/activeDraftId, сделанного в момент _beginWallFaceBatch; плейн Escape достаёт _roomDialogCancel() через уже существующую ветку if (this._roomDialog).
  • Лимиты проверяются до mutation. И _canAppendRoomDraftPoint (на каждый клик — резервирует место под будущий finish), и _finishWallChain (MAX_PARTITIONS), и _applyWallFaceBatch (MAX_ROOMS, повторно MAX_PARTITIONS) — везде проверка предшествует записи в sp, что закрывает риск ТЗ «Finish превысит partition limit при смене tool».
  • Clean split переиспользует немодифицированные splitRoomPath/roomsOverlap. _offerWallFaces вызывает существующий splitRoomPath на полном this._path для каждой существующей комнаты; поскольку splitRoomPath требует, чтобы все промежуточные точки пути лежали строго внутри полигона комнаты и ни один сегмент не пересекал её стену, геометрически невозможно, чтобы тот же клик одновременно давал net clean split одной комнаты И независимую постороннюю face за её пределами — ветка return сразу после первого найденного split-совпадения безопасна, не теряет параллельные кандидаты.
  • Provenance и толщина (AC10.4/AC11). _wallSourceCmAt ищет существующий сегмент (sourceKind === 'partition'/room-draft) по расстоянию до точки раньше активной цепочки, обеспечивая приоритет существующей authoritative толщины над DRAW_WALL_DEFAULT_CM; consumed/unconsumed atoms разделяются через atomizeWallSegments(...).sourceKeys пересечение с activeSourceCms.
  • Совместимость (AC14). Ни один файл custom_components/houseplan/**/*.py не тронут; git diff --stat origin/dev...HEAD подтверждает отсутствие изменений backend/schema. Legacy room_drafts/partitions не мигрируются автоматически (не тронуты нормализацией при чтении, только на explicit finish/batch) — соответствует §11 ТЗ.
  • Документация консистентна и полна (AC17, §17 ТЗ). Все перечисленные в §17 документы обновлены тем же коммитом; все три Low-находки предыдущего SPEC-REVIEW-173-r1 (отсутствие заголовка «Проблема», дрейф терминологии «Walls» в UX-MODES.md, опечатка в AC17) исправлены автором в этом же диффе (git diff d096a02..HEAD -- docs/specs/173-unified-wall-tool.md, docs/UX-MODES.md).
  • i18n RU/EN синхронны — каждый новый/изменённый ключ (markup.add, title.markup_add, markup.hint_points, markup.hint_start, btn.keep_as_walls, room.queue_progress, toast.wall_rooms_saved, toast.wall_chain_saved, history.wall_chain_finish, history.wall_face_batch) присутствует в обоих файлах.
  • Трейлеры и changelog. Единственный коммит класса A/B, f931159, несёт Issue: #173 и User-Visible: yes; оба changelog (docs/CHANGELOG.md, docs/CHANGELOG.ru.md) правлены тем же коммитом. process-gate.mjs подтверждает 0 предупреждений на диапазоне origin/dev..HEAD.

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

  • Не прогонял golden:verify — diff не трогает demo/golden/**, ни один baseline не принимался; AC12 (визуальная неизменность рендера) проверено только чтением кода (переиспользование немодифицированных canonical хелперов), не исполнением golden. Это предрелизный гейт по процессу.
  • Не прогонял pytest tests_backend — ни один Python-файл не тронут, AC14 не требует backend-исполнения.
  • Не прогонял полный набор из 138 browser-smoke — прогнаны все явно названные в AC/§15.2 плюс три соседних по diff-риску (толщина, gestures, island- overlap primitve); оставшиеся 132 не пересекаются с изменённой поверхностью.
  • Не воспроизводил multi-client optimistic-lock conflict во время открытого _wallFaceBatch вживую (WS reconnect с параллельной правкой) — только чтением кода, что batch не вводит новый conflict-путь помимо уже существующего _saveConfig/reload механизма (AC13 «save conflict»).
  • Не проверял reload/remount во время активной цепочки исполнением (нет выделенного smoke на это в диффе) — только чтением: ни один код-путь _finishWallChain/_wallFaceBatch не вызывается из reload-обработчика, только из явных tool/mode/space переходов.
  • Не запускал npm run benchmark:compare против сохранённого отчёта базового SHA (такого отчёта нет в этой сессии) — сверил сырые цифры одного прогона бенчмарка вручную с budgets-large-house-plan-snap.json.
  • Не проверял алгоритмическую сложность O((E + I) log E) формальным профилированием на больших E — доверился unit-тесту на 2000 сегментов (не показал деградации) и общей архитектуре sweep+interval-treap, которая структурно даёт эту сложность.

Вердикт

Зелёный. High: 0, Medium: 2 (оба вынесены отдельными issue — #176 мёртвый код инструмента partition, #177 недостающее unit/smoke-покрытие overlap/duplicate/nesting для новой интеграции), Low: 2 (не блокируют, оставлены на усмотрение автора). Все 17 AC подтверждены — большинство тестами с проверенной дисциплиной «тест умеет падать» (мутационная проверка), часть чтением кода с явной пометкой; ни одна находка не свидетельствует о том, что изменение не решает заявленный сценарий или ухудшает смежное поведение.