Files
houseplan-card/docs/reviews/CODE-REVIEW-290-r1.md
T
2026-08-24 16:30:25 +03:00

24 KiB
Raw Blame History

CODE-REVIEW-290-r1

  • Issue: https://github.com/Matysh/houseplan-card/issues/290
  • Ветка: issue/290-near-axis-authoring-repair
  • Диапазон: origin/dev (523190d8) → HEAD (5aae905a)
  • ТЗ: docs/specs/290-near-axis-authoring-and-repair.md, ревью ТЗ зелёное на r2 (docs/reviews/SPEC-REVIEW-290-r2.md)
  • Заход: r1 (первый код-ревью раунд, дельта-раздел не нужен)
  • Вердикт: красный · High: 1 · Medium: 2

1. Скоуп диффа

41 файл, +1966/−340. Продукт: src/near-axis.ts (новый общий классификатор/repair), src/houseplan-card.ts (authoring hover/click/closure, Optimize dialog/report), src/resize.ts (exact-axis postcondition safe Resize), src/plan-optimizer.ts (lossy Optimize pass, report, идемпотентность), src/wall-thickness.ts (алиас на общий порог). Тесты: test/near-axis.test.mjs (новый), test/model-invariants.test.mjs, test/plan-optimizer.test.mjs, demo/smoke_near_axis_optimize.mjs (новый), demo/smoke_plan_drawing_repairs.mjs, scripts/mutation-gate.mjs, scripts/model-invariants.mjs, scripts/smoke-links.mjs. Документация: CANVAS/RESIZE/WALL-THICKNESS/ARCHITECTURE/CONFIG-COMPATIBILITY/ USER-GUIDE(.ru)/TESTING/CHANGELOG(.ru) — все ссылаются на #290 и соответствуют принятым в ТЗ формулировкам. dist/custom_components/.../frontend — класс D, байт-в-байт совпадают с пересборкой (проверено).

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

Гейт Команда Результат
typecheck npx tsc --noEmit зелёный
unit npm test 1226 passed, 1 skipped, 0 failed (совпадает с хендоффом автора)
build + bundle parity npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js идентичны
docs fingerprint node scripts/check-docs.mjs (обязателен: диф трогает src/**) «Documentation checks passed (7 files, 10 external links)»
smoke-выборка node scripts/smoke-select.mjs --base origin/dev --head HEAD см. §2.1
invariants (обязателен: диф трогает геометрию) npm run invariants -- --config <wrapped fixture> --near-axis см. §2.2; литерально --config test/fixtures/real-plan-*.json не работает без обёртки — фикстура хранит {note, space}, а не {spaces:[...]}, инвариант читается только через тест/скрипт, который её оборачивает
golden не запускал диф не добавляет и не меняет ни один golden-сценарий (demo/golden/baselines/** не тронут); автор утверждает, что полный Linux Validate с golden прошёл на точном SHA 5aae905 — не перепроверял CI-прогон отдельно, см. §5
performance не запускал не назван в AC как влияющий количественно; спецификация ограничивается «Optimize pass линейный», что подтверждается чтением (repairNearAxisRoomWalls — один проход по кандидатам без вложенных полных пересчётов)
backend pytest не запускал диф не трогает custom_components/**/*.py

2.1 Смок-выборка

node scripts/smoke-select.mjs --base origin/dev --head HEAD:

  • Прямое совпадение: smoke_decor_layer_order.mjs, smoke_room_resize.mjs, smoke_zero_divider_taper.mjs (символы roomPoly/samePoint, попавшие на изменённые строки нового near-axis.ts, — общие имена, но прогнал все три: зелёные, без регрессий.
  • Зарегистрированная связь: smoke_near_axis_optimize.mjs (новый, scripts/smoke-links.mjs) — прогнан, зелёный (см. вывод в §3).
  • Слабая связь (19, общий символ _path): smoke_plan_drawing_repairs.mjs прогнан (он и есть целевой AC2-смок, изменён в диффе) — зелёный. Остальные 18 — общий геометрический путь рисования, не про near-axis; прогнал дополнительно smoke_wall_junctions.mjs, поскольку wall-thickness.ts меняет источник константы MULTI_WALL_NEAR_ORTHOGONAL_MAX_DEGREES (ровно тот класс регресса, что #234 поймал на «непрямом» имени) — зелёный. Остальные 17 не прогонял: тема (табы, esc-диалоги, drag жесты) не пересекается с near-axis классификатором ни по AC, ни по изменённым символам за пределами общего _path.

2.2 Инварианты и AC10 — реальные планы

npm run invariants -- --config <файл> --near-axis ожидает форму {spaces:[...]}; обе test/fixtures/real-plan-*.json хранят {note, space} (как их использует test/model-invariants.test.mjs и demo/smoke_real_plan_masonry.mjs). Обернул вручную и прогнал программно (не через npm run invariants CLI буквально, поскольку буквальный вызов из AC10 не аппликабелен к формату этой фикстуры):

real-plan-first-floor.json:  near-axis before=0 after=0, wallsStraightened=0, changed(align)=true(шум координат), 2-й проход wallsStraightened=0
real-plan-second-floor.json: near-axis before=1 after=0, wallsStraightened=1, maxStraightenShiftCm≈1, maxStraightenSpace='floor', 2-й проход wallsStraightened=0

Подтверждает главный продуктовый факт: real-plan-second-floor.json — тот самый план с дефектом из тела issue — после Confirm получает near-axis=0, не задваивает счётчик, идемпотентен. Это ровно то, что AC10 требует по существу. Но это моя ручная проверка, не автотест — см. Medium M2.

3. Находки

High H1 — Resize отключается на всей комнате из-за чужой, нетронутой near-axis стены (AC3 нарушен шире заявленного)

Файл: src/resize.ts:809-811

for (let index = 0; index < next.length; index++) {
  if (classifyNearAxisSegment(next[index], next[(index + 1) % next.length])) return false;
}

Цикл проверяет все рёбра результирующего полигона next, а не только moving/side edges, как формулирует ТЗ §5.2 и AC3 («Outer и exact-shared safe drags сохраняют moving/side edges exact-axis»). next совпадает с исходным polygon везде, кроме двух передвигаемых вершин — значит любое чужое, нетронутое near-axis ребро где-либо ещё в том же полигоне комнаты валит всю проверку, и resolveSafeResize возвращает invalid-geometry для любого handle этой комнаты, не только для handle, связанного с дефектом.

Воспроизведение (пул test-build, без правок продукта):

import { resolveSafeResize } from './test-build/resize.js';
const rooms = [/* north-west, south-west, east из test/fixtures/279-near-orthogonal-junction.json */];
resolveSafeResize(rooms, [], 'north-west', 3, opts);
// → { enabled: false, reason: 'invalid-geometry' }

Ребро 3 — верхняя, точно осевая, никак не связанная с общей near-axis стеной (она — ребро 1) сторона комнаты north-west. До #290 (без нового цикла) она резалась бы штатно: остальные проверки validateSafeResize (topology, area sign, clearance, sideAxis) её не касаются, потому что prev/next для ребра 3 — рёбра 2 и 0, а не дефектное ребро 1. После #290 она заблокирована.

Это точная геометрия из тела issue #290, подтверждённая ревью ТЗ r2 как буквально присутствующая в tracked test/fixtures/real-plan-second-floor.json. Значит на реальном плане владельца — том самом, что стал причиной задачи — Resize окажется отключён на каждом ребре комнат north-west/south-west, а не только на дефектной стене, до первого запуска Optimize. Ни ТЗ, ни AC не формулируют и не согласовывали такое расширение блокировки, владелец не принимал этого решения.

Почему не пойман тестами: ни один файл в test/resize*.test.mjs не импортирует classifyNearAxisSegment/NEAR_AXIS_*; ни один demo/smoke_*resize*.mjs не использует near-axis геометрию (проверено grep по всем трём). Новый путь в validateSafeResize не покрыт вообще ни одним тестом — ни AC3, ни любой другой.

Серьёзность: High. Реальный регресс на данных, которые сама задача называет мотивирующим кейсом; не согласован с владельцем; не покрыт тестом; масштаб — вся комната, а не заявленная в AC3 пара «moving/side edges».

Medium M1 — AC9: 3 из 6 обязательных мутантов отсутствуют

Файл: scripts/mutation-gate.mjs:918-951

ТЗ AC9 требует шесть категорий мутантов: порог ниже 0.181315°, строгое < вместо inclusive, bypass authoring snap, «чинить только одного владельца общей стены», «считать копии комнат за две стены», «применять Optimize без подтверждения». Зарегистрированы только первые три (near-axis-threshold-weakened, near-axis-inclusive-boundary-disabled, near-axis-authoring-snap-bypassed); docs/TESTING.md:2828+ честно перечисляет ровно эти три, то есть сокращение осознанное, но не согласованное с принятым ТЗ.

Проверил по существу (не только по названию): обычные unit-тесты (test/near-axis.test.mjs, тест «repairs a duplicated 316x1 physical wall once») фактически ловят обе оставшиеся геометрические мутации — wallsStraightened жёстко равен 1 (не 2), и north.poly[1]/south.poly[0] сравниваются явно, так что «один владелец» и «две стены вместо одной» сломали бы существующую assertion. Функциональность цела, но формального mutation-gate доказательства, которого требует принятое AC9, нет. Мутант «Optimize без подтверждения» аналогично не зарегистрирован как патч в mutation-gate.mjs, хотя previewDoesNotWrite/cancelDoesNotWrite в smoke_near_axis_optimize.mjs проверяют именно это поведение.

В скоупе задачи — чинится добавлением трёх отсутствующих записей в scripts/mutation-gate.mjs (или явным пересмотром AC9 с владельцем, если три достаточны).

Medium M2 — AC10: нет автотеста на двух реальных фикстурах

Файлы: test/near-axis.test.mjs, test/model-invariants.test.mjs, test/plan-optimizer.test.mjs

AC10 (введён в ТЗ r2 специально для закрытия M2 предыдущего ревью ТЗ) требует доказательства на real-plan-first-floor.json и real-plan-second-floor.json: npm run invariants до/после подтверждённого Optimize, строгое уменьшение near-axis count, идемпотентность повторного прогона. В диффе нет ни одного теста, вызывающего repairNearAxisRoomWalls/optimizePlans/nearAxisProfile на этих двух фикстурах — только на минимизированной test/fixtures/279-near-orthogonal-junction.json (test/near-axis.test.mjs, test/model-invariants.test.mjs) и на синтетическом inline-конфиге (test/plan-optimizer.test.mjs).

Я проверил утверждение AC10 вручную выполнением (§2.2): на текущем коде оно верно (real-plan-second-floor.json: near-axis 1→0, wallsStraightened=1, maxStraightenShiftCm≈1, идемпотентно). Дефекта в поведении нет — дефект в покрытии: без автотеста регресс на этой конкретной реальной геометрии не поймает npm test, а именно ради этого доказательства AC10 и был добавлен в ТЗ r2. Хендофф-комментарий автора («инварианты и near-axis профиль обоих реальных планов») не называет ни точной команды, ни файла результата — то же самое несоответствие, что и отсутствие теста.

В скоупе задачи — чинится тестом вида #287-теста (checkMixedRoleRecords({ spaces: [space] })) с оборачиванием фикстуры и прогоном optimizePlans/nearAxisProfile до/после.

Low L1 — AC1 «source guard» не механизирован

AC1 требует «source guard запрещает отдельные литералы 0.25 в этих classifiers». Проверил grep -rn "0\.25" src/*.ts — сейчас нарушений нет (единственные источники — near-axis.ts и wall-thickness.ts, который импортирует константу), и test/near-axis.test.mjs проверяет равенство MULTI_WALL_NEAR_ORTHOGONAL_MAX_DEGREES === NEAR_AXIS_MAX_DEGREES. Но формального грep-гейта (по образцу test/single-source-numbers.test.mjs), который бы упал при появлении нового хардкода 0.25 в будущем, нет. Снимаю с записью: сегодня нарушения нет, риск регресса невелик и не блокирует эту задачу.

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

  • AC1 (боundary matrix) — test/near-axis.test.mjs, тест «near-axis boundary is shared, inclusive and bounded»: exact axis, 316×1, 316×2, ровно на границе (NEAR_AXIS_MAX_SLOPE), чуть выше границы, 30°-диагональ, mirrored/reversed. Тест умеет падать — прогнал мутанты near-axis-threshold-weakened и near-axis-inclusive-boundary-disabled вручную (guard-команды в mutation-gate.mjs их и запускают) и убедился, что именно этот тест их убивает.
  • AC2 (Walls не сохраняет 316×1) — demo/smoke_plan_drawing_repairs.mjs: hover preview и committed click дают 316×0, existing-node snap conflict не ворует чужой anchor (nearAxisRuleDoesNotClaimWrongEndpoint). Прогнал — зелёный. Мутант near-axis-authoring-snap-bypassed проверен по коду (_resolvePlanDrawPoint вызывает snapNearAxisEndpoint только когда есть anchor, effectiveCandidate корректно гасится при переносе точки) — проверено чтением, не исполнением отдельно от guard-команды.
  • AC4/AC5/AC6/AC7 (кроме multi-space cell_cm) — test/near-axis.test.mjs + test/plan-optimizer.test.mjs: wallsStraightened=1 на трекнутой фикстуре #279, maxStraightenShift точно 1/240, обе комнаты получают идентичный endpoint, hosted-opening конфликт корректно уходит в skipped, true diagonal (316×2, 30°) не входит ни в wallsStraightened, ни в wallsStraightenSkipped. Multi-space/разный cell_cm (AC7, вторая часть) — в тестах нет, но проверил вручную выполнением (§2.2, конструкция с cell_cm=1 и cell_cm=100 на двух копиях фикстуры): maxStraightenShiftCm и maxStraightenSpace корректно выбирают пространство с большим физическим сдвигом, а не с большим сырым grid-сдвигом — код в src/plan-optimizer.ts:448-455 масштабирует каждое пространство своим cell_cm до сравнения, ошибок не нашёл.
  • AC8 (UI/Undo/revision) — demo/smoke_near_axis_optimize.mjs: previewDoesNotWrite, cancelDoesNotWrite, applyUsesOneAtomicWrite, reloadIsIdempotent, undoRestoresExactGeometry, undoServerSnapshotIsByteExact, undoIsOneDeep — все зелёные. Отдельного golden-скрина light/dark для новой строки диалога нет (ни один golden-сценарий не использует near-axis геометрию), но текст строки — тема-независим (одна и та же Lit-разметка, разница только в CSS), поэтому функциональной проверки previewNamesLossyRepair достаточно — проверено чтением шаблона диалога (src/houseplan-card.ts:16436-16441).
  • «Одно число, один источник» — maxStraightenShiftCm считается один раз в plan-optimizer.ts (масштабирование через cell_cm конкретного пространства) и передаётся в отчёт; UI (straightenCm = Math.ceil(r.maxStraightenShiftCm * 10) / 10) только форматирует, не пересчитывает. wallsStraightened, показанный в отдельной строке диалога, и тот же счётчик, свёрнутый в агрегат m тоста gs.align_done, — это одна и та же исходная величина, просуммированная с другими подобными счётчиками (тот же паттерн, что у openingsRehosted и остальных), а не два независимых вычисления одной и той же вещи — не является нарушением правила. test/single-source-numbers.test.mjs прошёл (это механическая часть правила; смысловую разобрал вручную выше).
  • changed не даёт ложных wallsStraightened — plan-optimizer.ts:645-646 сравнивает итоговый JSON конфига/layout с исходным; wallsStraightened, wallsStraightenSkipped и maxStraightenShiftCm зависят от changed (строки 690-693), так что «исправлено N стен» не может появиться без реального изменения записи.
  • Idempotency — второй optimizePlans на уже выпрямленной геометрии даёт wallsStraightened=0, changed=false — проверено и в unit-тесте, и вручную на обоих реальных планах (§2.2).
  • Документация — CANVAS/RESIZE/WALL-THICKNESS/ARCHITECTURE/CONFIG- COMPATIBILITY/USER-GUIDE(.ru)/TESTING/CHANGELOG(.ru) читал полностью; терминология («выпрямлено стен», «максимальное перемещение») совпадает между кодом, i18n и USER-GUIDE.ru.md; трейлеры коммитов не проверял отдельно (см. §5), но User-Visible: yes подтверждён правками в обоих changelog в дифф-стате.
  • typecheck/build/bundle parity/check-docs — зелёные (§2).

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

  • Полный Linux CI Validate на SHA 5aae905 (golden, docs, hacs, hassfest, smoke, performance_smoke, backend) — не переоткрывал сам прогон через gh, доверился ссылке на зелёный run в хендоффе автора и собственному локальному повтору дешёвых гейтов + целевых смоков. Если нужна дополнительная гарантия — попросить точную ссылку на run id.
  • Полный набор demo/smoke_*.mjs (182 файла) — прогнал только выбранные скриптом smoke-select.mjs плюс smoke_wall_junctions.mjs по прецеденту #234; полный прогон — предрелизный гейт (PROCESS.md §8), не гейт ревью.
  • npm run golden:verify и performance-профили — не запускал; диф не добавляет golden-сценариев и не называет performance-бюджет в AC явно.
  • python -m pytest tests_backend — не запускал; диф не трогает Python.
  • Touch/pinch/pointercancel — не гонял вручную в браузере; проверено чтением: _resolvePlanDrawPoint/snapNearAxisEndpoint не завязаны на тип указателя, а pinch/pan обрабатываются раньше и не доходят до этого пути (ES5-уровня рефакторинг не менял pointer-разбор).
  • Коммит-трейлеры и провенанс (Issue:/User-Visible: на каждом коммите диапазона) — не проверял поштучно через git log; pre-push/provenance CI job должны были бы поймать нарушение раньше ревью.

6. Итог

High H1 — реальный, воспроизведённый регресс Resize на геометрии, ради которой заведена задача, вне заявленного в AC3 объёма и без покрытия тестом. Medium M1/M2 — согласованные в ТЗ доказательства (AC9, AC10) реализованы не полностью; функционально то, что можно проверить вручную, работает верно, но автоматическое доказательство отсутствует. Возврат автору: сузить проверку в validateSafeResize до moving/side edges (или явно вынести решение «вся комната блокируется» на согласование с владельцем, если это осознанный выбор), добавить три недостающих мутанта AC9 и тест AC10 на обеих реальных фикстурах.