28 KiB
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 skipstest_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).