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