# 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 --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` ```ts 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`, без правок продукта): ```js 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 на обеих реальных фикстурах.