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

293 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`
```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 на обеих реальных
фикстурах.