Files
houseplan-card/docs/reviews/CODE-REVIEW-313-r1.md
T
2026-08-26 05:28:07 +00:00

294 lines
28 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-313-r1
Issue: #313 · этап: код (code) · трек: `small` (лёгкий, лимит циклов код-ревью — 2) ·
заход: r1 · блокирующих циклов израсходовано: 0 из 2 · SHA материала ревью: `5e5dad277cbdc238a5af899ff6727aed60d604b1`
## Скоуп
Ветка `issue/313-wallthick-standalone`, один коммит `5e5dad27` поверх `dev`.
Конвейер привёл ветку к `dev` до ревью (§7.2/#257): поверх лёг 1 коммит `dev`
(`docs: review document for #313`, `ffe0f0c7` — сам SPEC-REVIEW-313-r1, уже
принятый и слитый). Это не смысловой ребейз продуктового кода — единственный
родительский коммит между старым и новым состоянием документ-only, диапазон
`origin/dev...HEAD` по-прежнему ровно один коммит `5e5dad27`. Разбор в любом
случае полный: это первый заход код-ревью (`r1`), раздел «Унаследовано из
r(N-1)» неприменим — предыдущего раунда код-ревью не было.
Реализует ТЗ из тела issue #313 (light-трек, SPEC-REVIEW-313-r1 зелёный):
инструмент «Толщина» резолвит/пишет также перегородки (`space.partitions`) и
сегменты сохранённых черновиков (`space.room_drafts[]`), с приоритетом
независимой кладки при точном наложении, без кнопки «на всю комнату» для неё,
с отказом нуля/пусто для независимой кладки.
Диф: `src/houseplan-card.ts` (+109/-…), новый смок
`demo/smoke_wallthick_standalone.mjs`, второй патч мутанта
`wall-thickness-writer-bypasses-common-barrier` в `scripts/mutation-gate.mjs`,
оба CHANGELOG, `docs/USER-GUIDE.ru.md`, `docs/images/screenshots.json`
(только fingerprint), три копии бандла.
## Как проверялось
Читал: `docs/SCOPE.md`, `AGENTS.md`, `PROCESS.md` (§1–§12), тело issue #313 и
все 3 комментария (аналитика с тремя решениями владельца, зелёный вердикт
SPEC-REVIEW, хендофф автора), `docs/WALL-THICKNESS.md` целиком (§1, §6, §9
особенно), срез `docs/USER-GUIDE.ru.md` по «Толщине», `SPEC-REVIEW-313-r1.md`.
Код читался на `HEAD @ 5e5dad27`, полный `git diff origin/dev...HEAD`. Ключевые
утверждения не приняты на слово, а **исполнены**:
### Гейты — прогнал
| Гейт | Команда | Результат |
|---|---|---|
| typecheck | `npx tsc --noEmit` | зелёный, без вывода |
| unit | `npm test` | 1335 pass / 1 skip / 0 fail (1336 total) — совпадает с хендоффом |
| build + bundle sync | `npm run build && npm run bundle:sync` | `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` → совпадают; `cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` → совпадают |
| docs fingerprint | `node scripts/check-docs.mjs` (diff трогает `src/**`, гейт обязателен) | «Documentation checks passed (7 files, 10 external links)» |
| выборка смоков | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | 25 «прямое совпадение»; из них прогнал 5 по существу диффа (ниже) |
| `demo/smoke_wallthick_standalone.mjs` (новый, AC1–AC4) | `node demo/smoke_wallthick_standalone.mjs` | OK — **и подтверждено, что тест умеет падать**: собрал бандл с того же файла на `origin/dev` (`ffe0f0c7`, чистый worktree, свой `node_modules` симлинком) — без правки `_wallThickHit`/`_wallThickApply`/`_wallDialog.source` смок бросает необработанное исключение (`TypeError` в `_wallThickApply`), а не тихо проходит |
| `demo/smoke_wall_thickness.mjs` (AC4, комнатные стены) | `node demo/smoke_wall_thickness.mjs` | OK, все под-проверки true |
| `demo/smoke_wallthick_hover_width.mjs` (AC4) | `node demo/smoke_wallthick_hover_width.mjs` | OK |
| `demo/smoke_resize_wall_thickness.mjs` (AC4) | `node demo/smoke_resize_wall_thickness.mjs` | OK |
| `demo/smoke_optimize_coincident_partition.mjs` (тематически смежный с AC3 — #308-сценарий, два прямых совпадения символов `_wallDialog`/`_wallThickApply`, не «слабая связь») | `node demo/smoke_optimize_coincident_partition.mjs` | OK, все под-проверки true, включая `thicknessToolSelectsCanonicalWall`/`thicknessChangesSingleBody` |
| мутация (точечно, не полный набор) | `node scripts/mutation-gate.mjs --id=wall-thickness-writer-bypasses-common-barrier` | «поймано 1 из 1» — но см. находку Medium ниже: результат обманчив |
| инварианты модели | `npm run invariants -- --config test/fixtures/276-coincident-partition.json` | 1 нарушение — **предсуществующее и ожидаемое** (та же fixture #276/#308: перегородка поверх ребра комнаты; не следствие этого диффа — `plan-geometry-preflight.ts`/`near-axis.ts`/`coordinate-canonicalization.ts`, которые видит скрипт, диффом не тронуты). Не запускал отдельный export с реальной записью толщины независимой кладки: диф не меняет `wall_segments[]`/ключи/`open_spans`, пишет только `partition.cm`/`draft.segments[i].cm` тем же способом, что уже существующий `_savePhysicalDialog` (houseplan-card.ts:8287-8300, select-инструмент) — не новая форма мутации модели |
Остальные 20 «прямых совпадений» от smoke-select — не прогонял: связь через
широко переиспользуемые символы (`_activeDraftId`, `_geometrySnapshot`,
`_showToast`, `wallIntervals`) без содержательного пересечения с изменённым
кодом (`_wallThickHit`/`_wallThickApply`/`_wallDialog`/шаблон диалога); полный
прогон — предрелизная обязанность, не гейт этого ревью.
`golden`/`pytest tests_backend`/perf — не запускал: диф не меняет визуальный
результат (нет новых компонентов рендера — существующая полоса hover и диалог
переиспользуются как есть) и не трогает `custom_components/**/*.py` или
performance-чувствительные пути; в AC они не названы.
### AC — как доказаны
- **AC1** (перегородка: hit → диалог → запись `partition.cm` → тело
перерисовывается → Undo). Доказано смоком `smoke_wallthick_standalone`
(hit/диалог/запись) плюс моей отдельной проверкой исполнением — Undo смок
**не тестирует** (комментарий в файле сам это признаёт: «server history is
async-free here» без единого вызова undo). Я воспроизвёл отдельно:
`_wallThickClick` → правка на 35 → `_wallThickApply` → `Control+z` → значение
вернулось к исходным 20, `_geometryHistory.size === 1` (один Undo, как того
требует контракт). AC1 закрыт.
- **AC2** (сегмент драфта: диалог с cm сегмента, запись `draft.segments[i].cm`,
активный драфт не виден). Смоком подтверждена happy-path запись (12 → 18) и
исключение активного драфта — по коду (`draft.id === this._activeDraftId
continue`, houseplan-card.ts:11971-11973). **Не подтверждена находкой ниже** —
см. High: для `segments[i].cm === 0` диалог показывает не cm сегмента, а
выдуманное значение. Формально AC2 просит «диалог с cm сегмента» — для этого
конкретного, документами же допускаемого состояния контракт не выполнен.
- **AC3** (приоритет наложения; ноль/пусто отказ). Смок подтверждает обе части
на #308-фикстуре (перегородка `cm:30` поверх ребра комнаты `left`) и отказ
нуля с сохранением значения и открытым диалогом. Дополнительно прогнал
`smoke_optimize_coincident_partition` — тот же топологический класс
(«перегородка поверх ребра») с независимой (не игрушечной) фикстурой,
`thicknessToolSelectsCanonicalWall`/`thicknessChangesSingleBody` зелёные.
AC3 закрыт.
- **AC4** (комнатные стены не регрессируют). Три названных смока +
`npm test` (1335/1335, включая ~200 юнитов #278/#282 wall-model-barrier) —
все зелёные, golden не переприёмывался (диф не меняет рендер-геометрию комнат
— только резолвер инструмента и рендер диалога). Код `wallIntervals(...)` в
комнатной ветке резолвера байтово не менялся (только обёрнут в `offer(...,
false)` — при отсутствии независимых кандидатов `offer` воспроизводит старое
сравнение `d < best.d` строго, тай-брейк по независимости не участвует). AC4
закрыт.
## Находки
### High — 1
**Смешение «сегмент отсутствует» и «cm сегмента равен нулю» в резолвере
драфтов; следствие — тихая порча данных при клике «Применить» без правки
поля.**
`src/houseplan-card.ts:11976`:
```ts
open: false, cm: Number(draft.segments[i]?.cm) || 15,
```
`RoomDraftCfg.segments[i].cm` — обязательное `number` (`src/types.ts:60`), 0 —
легитимное для проекта значение того же самого поля: `docs/WALL-THICKNESS.md`
§6 прямо документирует «Empty / 0 leaves the new wall thin» для сегментов
черновика, и соседняя функция `validCm` (`src/houseplan-card.ts:13447-13448`,
`value >= 0 ? value : null`) в этом же файле трактует 0 как валидное **текущее**
значение того же поля при трассировке уже сохранённой независимой кладки —
разработчик уже один раз учёл это же поле как «0 — не отсутствие», просто в
другом методе. `||` в новом коде не различает «сегмента с таким индексом нет»
(законная защита от рассинхрона `points`/`segments`) и «сегмент есть, cm = 0»
— оба варианта схлопываются в `15`.
**Воспроизведение (исполнением, не догадкой)** — синтетический конфиг,
`page.evaluate` на собранном бандле HEAD (`demo/serve.mjs`):
```js
room_drafts: [{ id: 'thin-draft', points: [[0.6,0.5],[0.9,0.5]], segments: [{ cm: 0 }] }]
// tool = 'wallthick'
_wallThickHit([0.75*NORM_W, 0.5*NORM_W]).cm // → 15 (истинное значение — 0)
_wallThickClick(...)
_wallDialog.value // → "15" (должно быть "" — cmToField(0,…) === '')
_wallThickApply(false) // пользователь просто закрывает диалог, ничего не меняя
serverCfg.spaces[0].room_drafts[0].segments[0].cm // → 15 (было 0!)
```
Результат — ровно то, что напечатал скрипт:
`{"hitCm":15,"trueStoredCm":0,"dialogValue":"15","cmAfterApplyWithoutEdit":15}`.
**Сценарий отказа.** Черновик, нарисованный «тонким» (без толщины) — состояние,
которое канон подсистемы называет существующим («Empty / 0 leaves the new wall
thin»), не рисуется в редакторе как обычная стена по толщине, но геометрически
существует. Владелец открывает «Толщину», наводит на этот сегмент, видит
полосу подсветки шириной 15 см (реального 0 не видно — тоже следствие того же
`hit.cm`), кликает, видит в поле «15», нажимает Enter/Apply, думая, что просто
закрывает диалог или подтверждает увиденное — сегмент необратимо (без второго
явного намерения) становится физической стеной 15 см. Это ровно класс «одно
число — один источник» (PROCESS.md §8): подсветка инструмента, поле диалога и
итоговая запись показывают одно и то же неверное число, полученное из одного
неверного источника (`_wallThickHit`), а не три независимых.
Партиционная ветка той же строки (`Number(partition.cm) || 0`, houseplan-card.ts:11963)
не страдает: 0 — единственное значение, при котором фолбэк вообще участвует, и
он совпадает с истинным (для `PartitionCfg` нет задокументированного «тонкого»
состояния и нет пути создания `cm: 0`, в отличие от драфтов).
**В скоупе ли находка.** Да: это тот же файл, тот же метод, что ТЗ описывает
(«Диалог: для перегородки/драфта — cm сегмента»), правка — одна строка.
### Medium — 1, в скоупе
**Второй патч мутанта `wall-thickness-writer-bypasses-common-barrier` не
проверяет то, что заявлено в коммите; заявление «краснота обоих проверена
исполнением» не подтверждается.**
Коммит `5e5dad27` добавляет второй `patches[]`-элемент в
`scripts/mutation-gate.mjs:1221-1227`, целящий именно новую точку коммита
(`_wallThickApply`, независимая кладка) — и заявляет в теле коммита: «Мутант
расширен вторым патчем на новую точку коммита (#278-гвард), краснота обоих
патчей проверена исполнением». Проверил раздельно, применяя патчи мутанта
по одному к `src/houseplan-card.ts` (с восстановлением файла после каждой
проверки, `git status` чист):
- только 1-й патч (легаси-точка, `history.wall_thickness` в комнатной ветке)
применён → `node --test --test-name-pattern="production source routes
physical writers" test/wall-union-isolation.test.mjs` **красный** (как
ожидается);
- только 2-й патч (новая точка, независимая кладка) применён, легаси-точка
цела → тот же тест **зелёный** — не ловит.
Причина: гвард-тест (`test/wall-union-isolation.test.mjs:107`) — это
регэксп-поиск текста `_commitPhysicalGeometry\(this\._t\('history\.wall_thickness'`
по всему исходнику одной строкой (без `s`/multiline-режима на `.`), а не
проверка конкретного места. Он совпадает **только** с легаси-вызовом
(написан в одну строку), потому что новый вызов
(houseplan-card.ts:12064-12066) обёрнут переносом строки сразу после
`_commitPhysicalGeometry(` — до `this._t(...)` в исходнике перевод строки,
через который движковый `.` без флага `s` не проходит. Поэтому при
`--id=wall-thickness-writer-bypasses-common-barrier` (оба патча вместе,
как в проверил и в реальном CI-запуске мутанта) тест краснеет **целиком за
счёт первого патча**; второй патч можно откатить, ничего не изменится в
результате — «поймано 1 из 1» в отчёте `mutation-gate.mjs` не отличает эти
случаи.
Проверил и поведенчески — не только по регэкспу, но и по факту: применил
только 2-й патч, пересобрал бандл, прогнал сам новый
`demo/smoke_wallthick_standalone.mjs` — **тоже зелёный**. Мутация
(`_recordGeometry` вместо `_commitPhysicalGeometry`) не даёт `_saveConfig()`
(houseplan-card.ts:7402, единственное место записи на бэкенд в
`_commitPhysicalGeometry`) — смок читает мутированное поле прямо из
`_serverCfg` (объект уже изменён присваиванием `partition.cm = cmRaw` до
вызова коммита), поэтому не видит разницы между «записано с проверкой и
уходом на сервер» и «переживено локально, история есть, на бэкенд ничего не
ушло». Практическое следствие обхода барьера в этой ветке — не гипотетическое:
`_commitPhysicalGeometry` пропускаетfail-closed валидацию геометрии
(`_checkSpacePhysicalGeometry`) и обязательный `_saveConfig()`; будущий
рефакторинг, случайно подставивший «облегчённый» путь записи именно в этой
новой ветке, ни один из двух существующих гейтов не поймает.
**В скоупе ли находка.** Да: и файл (`scripts/mutation-gate.mjs`, класс B, но
через issue этой задачи — AGENTS.md таблица классов, «может использовать issue
того изменения, которое покрывает»), и утверждение (текст коммита #313) —
целиком продукт этой задачи.
## Что проверено и корректно
- Приоритет независимой кладки при точном наложении (`offer(...)`,
houseplan-card.ts:11940-11946): тай-брейк `independent && !best.independent
&& d <= best.d + 1e-9` — корректно предпочитает независимую кладку только
при равном/лучшем расстоянии, не переопределяет явно более далёкий
кандидат. Порядок перебора (комнаты → перегородки → драфты) не создаёт
систематического смещения: тай-брейк явный, не полагается на порядок
вставки для двух разных источников.
- `sp` (`_curSpaceCfg`, сырой конфиг) и `space` (`_spaceModel()`, отмасштабированная
проекция) — разные объекты, но `id` партиций/драфтов сохраняются 1:1 через
проекцию (`src/space-geometry.ts:167-180`); поиск по id в `_wallThickApply`
корректен, тот же паттерн, что уже использует `_wallThickClick`→`_wallDialog`
для комнатных стен и `_savePhysicalDialog` для партиций/драфтов
(houseplan-card.ts:8287-8300) — не новая, а переиспользованная связка.
- Диалоговая кнопка «на всю комнату» скрывается корректно по
`d.source.kind === 'room'` (houseplan-card.ts:12636), без нового UX-контракта
— остальной диалог тот же компонент.
- Валидация диапазона (1–100, отказ 0/пусто для независимой кладки) идёт
ДО ветвления по источнику и применяется к обеим веткам одинаково; после
ветвления для независимой кладки — отдельный явный отказ на 0/пусто,
соответствует решению владельца №2. Тост переиспользован
(`toast.physical_range`), новый ключ `toast.wallthick_set` уже существовал в
обоих `src/i18n/en.json`/`ru.json:535` до этого коммита (проверил — не
добавлен этим диффом, i18n не тронут, как и заявлено в аналитике «новых
ключей не требуется»).
- Оба CHANGELOG (`docs/CHANGELOG.md`/`.ru.md`) правлены в том же коммите,
трейлеры `Issue: #313`/`User-Visible: yes` на месте — соответствует
AGENTS.md.
- `docs/USER-GUIDE.ru.md` дополнен двумя строками таблицы («Значение», «Какие
стены») терминологией, уже принятой в этом документе (не изобретает новых
слов), корректно описывает и отказ нуля, и приоритет наложения.
- `docs/images/screenshots.json`: изменился только `sourceFingerprint`/
`sourceSha256` (совпадает с текущим `src/**`, проверено `check-docs.mjs`),
`imageSha256` не тронуты — ни один из 10 задокументированных сценариев не
показывает Plan-редактор с инструментом «Толщина», пересъёмка не требовалась;
фингерпринт обязан был устареть при любой правке `src/**` и был обновлён
правильно.
- Мутант `wall-thickness-writer-bypasses-common-barrier` при обоих патчах
вместе (как запускает CI) действительно ловит регресс — см. Medium выше,
почему это не то же самое, что «оба патча по отдельности эффективны».
## Чего не проверял
- Golden/скриншоты, `pytest tests_backend`, performance — не запускал: диф не
меняет визуальный рендер и не трогает Python/бэкенд; не названы в AC. Если
ошибаюсь и где-то в full/static рендере тело диалога/полосы отличается от
существующего компонента — это откроет полный прогон перед бетой.
- Полный `smoke-select` список (остальные 20 из 25 «прямых совпадений») —
осознанно не прогонял, см. таблицу выше; риск считаю низким, но это решение,
а не факт.
- Столбы (`space.wall_columns`) инструментом «Толщина» — вне ТЗ и вне этого
диффа, как и зафиксировал ревьюер ТЗ; не проверял, потому что не должен быть
затронут (не проверял чтением, что резолвер их не подхватывает — это уже не
добавлено кодом, а не «пропущенная проверка»).
- Не проверял поведение при коллинеарной перегородке РЯДОМ (не точно на
ребре) с контуром комнаты, вне #308-фикстуры — тот же пробел, что явно
оставил открытым ревьюер ТЗ («это забота код-ревью... или находка для
отдельного цикла»); не нашёл в этом диффе признаков, что общий случай
(не точное наложение, а близкое соседство) обрабатывается иначе, чем раньше
— резолвер использует то же `distToSegment`/`pull`-радиус, что и для
комнатных стен исторически, поведение не новое для этого диффа. Оставляю
как открытый вопрос для будущей задачи, не завожу отдельный issue: это не
находка ПРОТИВ #313, а недоказанное свойство, унаследованное из старого
кода.
- Не проверял `#306` (замена 0 на виртуальную стену) — вне скоупа, ТЗ прямо
говорит, что этой задаче нужен только шов (switch по источнику), а не
реализация; шов на месте (houseplan-card.ts:12052-12056, единственная точка
ветвления по `d.source.kind`).
## Вердикт
Красный. Единственная High-находка — конкретный, воспроизведённый исполнением
дефект в коде, который сама задача добавляет (`_wallThickHit`, драфт-ветка):
диалог «Толщины» лжёт о текущей толщине сегмента черновика, когда она равна 0
(легитимное, задокументированное в `docs/WALL-THICKNESS.md` §6 состояние), и
безusловный клик «Применить» без правки поля молча превращает такой сегмент из
0 в 15 см. Это прямое нарушение AC2 («диалог с cm сегмента») для конкретного
достижимого состояния модели, а не гипотетическое. Medium-находка (обманчивая
мутационная защита новой точки коммита) в скоупе той же задачи, чинится там же.
Обе находки локальны (одна строка кода на High; тестовый патч + при
необходимости сам тест на Medium) — переписывания ТЗ/скоупа не требуют.