Files
houseplan-card/docs/reviews/CODE-REVIEW-329-r1.md
T
2026-08-27 21:01:48 +00:00

28 KiB
Raw Blame History

CODE-REVIEW-329-r1

Issue: #329 · этап: code (PROCESS.md §2.7) · заход r1 · блокирующих циклов 0/4 (до этого вердикта)

Скоуп

Диапазон: origin/dev...HEAD на ветке issue/329-junction-limits, HEAD = 7017896e (докоммит после ребейза на dev, который вобрал #264 — извлечённый ResizeController). Ветка чисто ребейзится на origin/dev (git merge-base origin/dev HEAD == tip origin/dev), конфликта нет — два предыдущих запуска конвейера, отказавшихся ревьюить из-за конфликта, здесь не актуальны, ветка перебазирована и запушена автором (комментарий 2026-08-27T20:00:24Z).

Задача обычного трека (не small): ТЗ — docs/specs/329-junction-limits.md, ревизия 6, спек-ревью прошло r1→r3 (зелёный на r3), затем автор внёс правки владельца §4/П3 напрямую (ревизии 4–6) без дополнительного раунда спек-ревью — это первый code-review заход для итогового кода.

43 изменённых файла, из них продуктовый код: src/junction-limits.ts (новый), src/houseplan-card.ts, src/wall-thickness.ts, src/resize-controller.ts, src/i18n/{en,ru}.json, custom_components/houseplan/junction_limits.py (новый), custom_components/houseplan/websocket_api.py. Остальное — тесты, golden, документация, сгенерированный бандл.

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

Дешёвые гейты (обязательные всегда):

  • npx tsc --noEmit — чисто.
  • npm test — 1388/1389 pass, 1 skip (питоновский parity-тест живёт в tests_backend, не в node-раннере). Ни одного red.
  • npm run build — собрался; dist/houseplan-card.js побайтово совпадает с custom_components/houseplan/frontend/houseplan-card.js (сверено diff -q после npm run bundle:sync).
  • node scripts/check-docs.mjs — «Documentation checks passed (7 files, 10 external links)», отпечаток скриншотов свежий (последний коммит ветки как раз чинит это после ребейза).

Геометрия/инварианты (диф трогает рёбра стен, wall_segments, толщину, layout):

  • npm run invariants -- --config test/fixtures/329-sharp-apex.json — «Инварианты выполнены: ссылки разрешимы, записи толщины находятся» (ключ записи толщины и ключ решёточного ребра совпадают на самой фикстуре issue).
  • npm run golden:verify — весь текущий набор (полная матрица, не только новая сцена) прошёл, включая новую sharp-apex-legacy-dark (AC6) и уже существующую junction-owner-repro-dark; ни одна старая сцена не изменилась — риск §10 закрыт исполнением, а не заявлением.

Бэкенд (диф трогает custom_components/houseplan/*.py):

  • python -m pytest tests_backend/test_junction_limits.py -q — 7 passed, включая test_parity_with_the_frontend_checks (реально прогнал TS-функции через node --eval и сравнил с питоновским вердиктом — не skip, test-build/ был собран npm run invariants до этого).
  • Полный python -m pytest tests_backend -q в этой среде не поднимается: homeassistant не установлен (это облачная CI-среда без .venv-backend, установить полный HA недостаточно оправдано ради одного ревью) — падает на импорте в трёх модулях, не в затронутых #329 файлах. Ограничение окружения, не решение сузить проверку: описано в AGENTS.md («Локально только pytest без HA — silently skips test_ha_*.py»). Здесь импорт вообще падает раньше skip-логики conftest.py, поэтому я запустил ИМЕННО целевой файл отдельно (см. выше) вместо того, чтобы полагаться на «зелёный», который ничего не доказывает.
  • Дополнительно: написал и выполнил собственный python-репро (см. находку H1) — прямой вызов validate_junction_limits на сконструированных fixtures, не полагаясь на чтение кода.

Мутанты (диф добавляет/меняет 4 мутанта в scripts/mutation-gate.mjs) — прогнаны точечно, не весь гейт (несоразмерно объёму ревью):

  • junction-limit-angle-not-enforced — поймано 1/1.
  • junction-limit-write-gate-removed — поймано 1/1.
  • degenerate-apex-bevelled-again — поймано 1/1.
  • resize-preview-reject-silent (изменённый якорь) — поймано 1/1.

Браузерные смоки — выбраны через node scripts/smoke-select.mjs --base origin/dev --head HEAD (43 символа на изменённых строках src/** в 4 файлах, порог широкого символа 38 смоков — под порог не попал ни один, инструмент не потребовал расширять выборку до всей матрицы 193). Прогнаны: оба смока, названных в AC/изменённых диффом (smoke_junction_limits.mjs — AC1/AC3/AC5a/AC7a/AC7b/§3, smoke_island_rooms.mjs — П3 на комнатах-островах), плюс все 13 «прямых совпадений» и 1 «зарегистрированная связь» (smoke_real_plan_masonry — инструмент сам напомнил, что на реальном плане кладка рвётся там, где синтетика цела, и восемь прошлых задач по стыкам уже входили в бету с необнаруженным разрывом; учитывая, что диф правит insetContour/outsetContour, счёл прогон обязательным, а не факультативным). Итог — 15/15 OK, ни один не покраснел.

Находки

H1 (High, в скоупе). Бэкенд отказывает в легитимной правке легаси-плана — прямое нарушение §3

custom_components/houseplan/junction_limits.py::validate_junction_limits сравнивает «before» и «after» по СЫРЫМ документам (msg["config"] и data.get("config")), не пропуская ни один из них через commit_wall_segment_model. Фронтенд ровно эту ошибку уже нашёл и исправил в коммите 4758767e («junction limits judge both sides after the same migration») — коммит-сообщение прямым текстом объясняет механику: «The baseline for inheritance was the raw previous document, which for a legacy space carries no wall catalogue at all — so every inherited short segment of a real plan looked new». На бэкенде аналогичная миграция (commit_wall_segment_model) вызывается только в import_export.py и в ws_plan_optimize (и то — условно, при submitted_model < WALL_SEGMENT_MODEL_VERSION), но НЕ в ws_config_set, где живёт вызов validate_junction_limits (websocket_api.py:1334).

Следствие: хранимый (previous) документ пространства, которое ещё ни разу не проходило структурную запись после введения каталога wall_segments (model_version < 8), не содержит wall_segments вовсе — limit_segments() читает только wall_segments/partitions/room_drafts, поэтому space_violations(old_space) возвращает [] для такого пространства НЕЗАВИСИМО от реальной геометрии. Кандидат же (msg["config"]) современный клиент уже мигрировал на клиенте до отправки — там wall_segments есть. Любое унаследованное нарушение (в т.ч. безобидное, годами живущее в реальном плане пользователя — ровно повод, по которому заведён #329) на первой же структурной правке после обновления карточки читается как «новое» и запись ЦЕЛИКОМ отклоняется — даже если правка не касалась нарушающего узла. Это прямое нарушение §3 ТЗ («миграция... не блокируются никогда», «правка, не трогающая нарушающий элемент, проходит как обычно») и AC8/AC9 по факту (тест паритета в test_junction_limits.py не ловит это, потому что в его фикстурах ОБЕ стороны уже содержат wall_segments — сценарий «previous без каталога» не фигурирует нигде в тестах).

Воспроизведено исполнением (не только чтением кода) — прямой вызов модуля:

previous = {"spaces": [{
    "id": "s", "cell_cm": 5.0, "model_version": 0, "rooms": [],
    "walls": [  # легаси-хранение, БЕЗ ключа "wall_segments"
        {"key": "w0", "a": [0.0, 0.0], "b": [...], "cm": 15},
        {"key": "w1", "a": [0.0, 0.0], "b": [...], "cm": 15},  # угол 9°
    ],
}]}
candidate = {"spaces": [{
    "id": "s", "cell_cm": 5.0, "model_version": 9,
    "rooms": [{"id": "r1", "name": "Renamed"}],  # НЕСВЯЗАННАЯ правка
    "wall_segments": [  # тот же угол 9°, клиент уже мигрировал
        {"id": "w0", ...}, {"id": "w1", ...},
    ],
}]}
jl.validate_junction_limits(candidate, previous)
# → JunctionLimitError: junction_limit_angle; actual=9; limit=15

Результат: REFUSED: junction_limit_angle — легитимная переименование комнаты на давно существующем плане с историческим острым углом отклоняется целиком. Реальный масштаб поражения велик: сама мотивация #329 — план РЕАЛЬНОГО пользователя с давним острым углом; после этой задачи такой пользователь не сможет сохранить вообще никакую правку своего плана, пока не прогонит Optimize (о котором он может не знать) — притом что §3 прямо обещает обратное, и фронтенд этот же случай уже умеет обрабатывать корректно (проверено смоком §3: унаследованное нарушение не блокирует несвязанную запись внутри smoke_junction_limits.mjs, который проходит именно потому, что бьёт только по фронтенд-пути).

Чинится по образцу уже принятого решения: перед сравнением пропустить old_space (и, для симметрии, space) через commit_wall_segment_model — тем же приёмом, каким _junctionLimitsIntroduced в houseplan-card.ts уже чинит идентичный дефект на фронтенде. docs/CONFIG-COMPATIBILITY.md (этот же дифф) уже ОБЕЩАЕТ это поведение текстом «both have gone through the same commitWallSegmentModel migration» — то есть документация описывает контракт, которого бэкенд не реализует.

M1 (Medium, в скоупе). Мёртвый код в wall-thickness.ts, чьё удаление коммит уже задекларировал

Коммит 002795f7 («fix: no jags on the edges of a degenerate apex») убрал оба места, где вызывались degenerateApexCaps()/clipPolygonOutsideCap(), и сообщение коммита прямо утверждает: «The clip helper and its cap plumbing are gone». На деле в текущем HEAD остаются:

  • apexCaps?: number[][][] — поле интерфейса MultiWallRoomRing (src/wall-thickness.ts:2815), никогда не устанавливается и не читается;
  • export function clipPolygonOutsideCap(...) (строка 3637, ~30 строк);
  • export function degenerateApexCaps(...) (строка 3696, ~60 строк).

Ни один из них не вызывается из продуктового кода (grep по src/ даёт только сами определения) и не покрыт тестами (grep по test/ — ноль совпадений). Реальный переход на «острие вместо фаски» сделан иначе — через isDegenerateApexCorner прямо в insetContour/outsetContour — эти функции остались как нетронутый, непроверяемый и вводящий в заблуждение довесок: следующий читающий код решит, что это часть работающего конвейера клиппинга (интерфейс даже комментирует поле как «#329 §4: clip regions...»), хотя оно мертво с коммита, заявившего обратное.

M2 (Medium, в скоупе). Дублированный раздел в docs/USER-GUIDE.ru.md

Раздел «### Ограничения стыков стен» вставлен ДВАЖДЫ подряд, слово в слово (строки 420–443 и 445–468 идентичны по всему тексту, включая таблицу и оба абзаца после неё). Английская версия (docs/USER-GUIDE.md, «### Wall junction limits») дублирования не имеет — это чисто копипаст-дефект русской версии, попавший в тот же коммит, что и остальная документация задачи (User-Visible: yes требует правки обоих changelog и, по духу того же правила, аккуратной документации на обоих языках).

M3 (Medium, в скоупе). USER-GUIDE описывает канал Resize неполно — читатель не узнает про тост

И docs/USER-GUIDE.md, и docs/USER-GUIDE.ru.md в новом разделе пишут: «рисование и «Толщина» показывают тост с названием правила, а Resize останавливает стену в последней разрешённой позиции» (RU, строки 441–443/ 466–468; аналогично EN). Формулировка явно противопоставляет тост (для рисования/Толщины) и «просто останавливается» (для Resize) — то есть читатель разумно поймёт, что при Resize тоста нет.

По факту src/houseplan-card.ts (строки ~9181–9188) показывает тост и для Resize: resize.limit_stopped + _junctionLimitLabel(...), что подтверждено исполнением — смок smoke_junction_limits.mjs проверяет resizeRefusalNamesRule (тост содержит «5») и resizeRefusalOnce === 1, и оба зелёные. Это ровно тот класс дефекта, который проверяет test/single-source-numbers.test.mjs для чисел, но здесь смысловая сторона: сам факт наличия канала обратной связи задокументирован неверно. AC7a (ревизия 6 спека) корректно описывает тост-канал — расхождение только в изданном для пользователя USER-GUIDE, который вправляли отдельно и не сверили с финальным текстом AC7a.

Смежное наблюдение (не отдельная находка, отметка для автора): §2 самого ТЗ (нормативный текст) тоже не обновлён вслед за AC7a ревизии 6 и всё ещё говорит «тост не используется» для Resize — но ТЗ на этапе code-review не правится, отмечаю для полноты картины, поскольку именно расхождение §2 с AC уже один раз обсуждалось на спек-ревью (r2-M1) и, похоже, вернулось в другом месте документа.

M4 (Medium, в скоупе). AC10 (постусловие Optimize) не доказан ни тестом, ни явным разбором

AC10: «Optimize на легаси-фикстуре не создаёт новых нарушений (AC-постусловие §3)». В диффе нет ни юнита, ни смока, который прогоняет optimizePlans/ws_plan_optimize на фикстуре с существующим нарушением и сравнивает набор нарушений до/после. grep по «Optimize»/«optimize» во всех тестовых файлах, тронутых этой задачей, ничего не находит.

Это не тривиально верно «по построению»: optimizePlans вызывает alignAllToGrid и repairNearAxisRoomWalls, которые двигают узлы к решётке и выпрямляют почти-осевые стены — оба потенциально смещают геометрию на доли сантиметра. Ни один из порогов П1–П5 не имеет запаса шире шага решётки в общем случае (например, узел ровно на 5.0 см от чужой стены после сдвига на привязку может уйти ниже порога). Формально проверить это чтением я не могу (нужно было бы доказать, что снап монотонно не уменьшает ни один из пяти инвариантов, что для П1/П4 не очевидно), поэтому по правилу «либо тест, либо разбор с явной пометкой» AC10 сейчас не доказан никак.

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

  • П1–П5 (src/junction-limits.ts) — юнит-покрытие точное, включая границы (14°/15°/16°, 6/7 стен, 19/20/25 см, 4/5 см, пустой/непустой просвет), T-стык как легальная инцидентность, компенсацию перепада толщин (АС3b) — числа владельца (30/20 см → 5 см доборный атом) воспроизведены и на фронте, и на бэке идентично.
  • §4 (честная вершина легаси) — isDegenerateApexCorner, insetContour/outsetContour дают ровно одну точку в вершине плана и снаружи, и внутри; внешнее кольцо тела — треугольник без ступенек и микровершин; golden-сцена sharp-apex-legacy-dark подтверждает это на пиксельном уровне, вся остальная golden-матрица не сдвинулась.
  • Наследование нарушений считается ПО ПРАВИЛУ (increasedViolations), а не по subject id — комментарий и тест прямо объясняют, почему id нестабилен через структурную запись (переатомизация); фронтенд-часть барьера (_junctionLimitsIntroduced) верно мигрирует ОБЕ стороны через commitWallSegmentModel перед сравнением — это именно то, чего не хватает на бэкенде (H1).
  • AC7a (Resize) — проверено настоящим pointer-жестом (не вызовом внутренних методов): сетка 2 см, две комнаты в 10 см, тяга 6 см останавливается на 6 см (не доезжает до нарушающих 4 см), ровно один тост с «5». _rszLimitViolation сбрасывается в начале КАЖДОЙ проекции — проверил код: без этого сброса тост от предыдущего шага мог бы объяснить отказ другого рода; тест на это (resize-preview-reject-silent мутант) ловит немоту, но не проверяет свежесть причины — сам код читаем и корректен (обнуление на первой строке _rszProjectPreview).
  • AC7b («Толщина») — значение не применяется, тост с названием правила, план байт-неизменен (смок).
  • AC1/AC3/AC5a — смоком, с проверкой, что тост называет конкретное число (не просто безмолвный отказ).
  • AC2/AC4 — юнитами на обеих сторонах (фронт+бэк), включая T-стык.
  • AC6 — см. выше, golden + юниты на контурах и кольце.
  • AC8 (миграция/импорт/restore не блокируются) — проверено ЧТЕНИЕМ, не исполнением: validate_junction_limits вызывается только из ws_config_set; ни ws_import_apply, ни ws_files_migrate его не вызывают. На фронте _junctionLimitsIntroduced вызывается только из _commitPhysicalGeometry и _rszProjectPreview — оба это операции редактирования, не импорта/миграции.
  • AC9 (паритет) — исполнено: test_parity_with_the_frontend_checks реально прогоняет TS через node --eval и сравнивает с питоновским результатом на 8 фикстурах, 7/7 pass.
  • Backend exception plumbing в ws_plan_optimize: JunctionLimitError добавлен в except-кортеж, хотя validate_junction_limits там не вызывается (сознательно, по §5 — Optimize не должен упираться в новые правила) — код от этого не ломается, исключение просто никогда не возбуждается в этой ветке; не поднимаю до отдельной находки, так как безвредно и не вводит в заблуждение так же сильно, как M1 (нет комментария, утверждающего обратное).
  • AC5b — формула П5 (checkRoomClearance) шейп-агностична (это чистая формула площади по шнурку), поэтому квадратные фикстуры теста математически эквивалентны треугольнику из спека; но буквально «юнит с этими числами» (равносторонний 60/33 см, inradius 17.3/34.6 см) отсутствует — фиксирую как Low, не поднимаю до Medium: код, который доказывал бы обратное, был бы тем же самым.
  • Трейлеры: Issue: #329 на каждом коммите; User-Visible: yes только там, где меняется видимое поведение (214355f4), и в том же коммите правки обоих changelog — проверено git show --stat.
  • Одно число — один источник: _junctionLimitLabel — единственное место, формирующее текст отказа что для тоста рисования/Толщины, что для Resize; actual/limit берутся из одного и того же объекта JunctionLimitViolation, не пересчитываются отдельно для тоста и для ручки — проверено чтением, не вижу второго источника чисел.

Чего не проверял и почему

  • Полный python -m pytest tests_backend -q (HA-harness) — недоступен в этой среде (homeassistant не установлен, нет .venv-backend); прогнан только целевой файл задачи (полностью, 7/7) плюс попытка полного набора зафиксирована как падение импорта, не как «зелёный, но неполный».
  • Полная браузерная матрица (193 смока) — не прогонял, диф не потребовал этого по smoke-select (43 символа, порог 38 не превышен); прогнал все прямые совпадения, зарегистрированную связь и оба AC-смока (15 итого).
  • Полный scripts/mutation-gate.mjs (весь набор мутантов) — несоразмерно ревью; прогнал точечно только 4 мутанта, которые диф добавил/изменил.
  • performance_smoke — не прогонял: в AC не назван, диф не трогает горячий путь рендера (П1–П4 — O(узлы+сегменты) на запись, П5 — на commit, не на каждый кадр курсора, это подтверждается чтением: вызовы _junctionLimitViolations/_junctionLimitsIntroduced находятся в _commitPhysicalGeometry и в _rszProjectPreview, оба — не per-frame рендер-путь).
  • Ручное тестирование в браузере — не проводил (этап code-review, ручного тестирования в цикле нет по регламенту; браузерные смоки — единственная замена).
  • AC5b буквально «с этими числами» — не поднимаю до отдельного прогона, формула шейп-агностична (см. выше), различие признано как Low и не требует отдельного гейта.

Итог

Один High (H1) — реальный, воспроизведённый исполнением дефект, прямо нарушающий §3 ТЗ и обесценивающий центральное обещание задачи («легаси не блокируется») для бэкенд-пути записи. Плюс четыре Medium в скоупе (M1–M4), все чинятся в этой же задаче без отдельного issue (владелец, #202).