mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 05:08:53 +00:00
330 lines
25 KiB
Markdown
330 lines
25 KiB
Markdown
# CODE-REVIEW-228-r1
|
||
|
||
- Issue: [#228](https://github.com/Matysh/houseplan-card/issues/228) — надёжное рисование стен и операции с готовым контуром
|
||
- ТЗ: `docs/specs/228-plan-drawing-problems.md` (ревью ТЗ зелёное, r1, #228)
|
||
- Ветка: `issue/228-plan-drawing-problems`
|
||
- Коммит реализации: `2aaef12` (`fix: make plan drawing fail closed`, `Issue: #228`, `User-Visible: yes`)
|
||
- Заход ревью: r1 · блокирующих циклов израсходовано 0 из 4 (первый заход, дельта не разбирается — весь диапазон `origin/dev...HEAD`)
|
||
- Ревьюер: Claude (код-ревью), сессия без контекста реализации
|
||
|
||
## 1. Скоуп проверки
|
||
|
||
Диапазон `git log --oneline origin/dev..HEAD`: три коммита —
|
||
`dca4ed2` (ТЗ), `7eedd7e` (ревью ТЗ), `2aaef12` (реализация). Весь
|
||
продуктовый код и тесты сосредоточены в одном коммите `2aaef12`.
|
||
Диапазон `git diff origin/dev...HEAD --stat`: 31 файл, +2600/-356,
|
||
из них продуктовый код — `src/houseplan-card.ts` (+397/-90),
|
||
`src/plan-snap-overlay.ts` (+153/-25), новые `src/wall-face-repair.ts`
|
||
(+150) и `src/room-deletion.ts` (+122), `src/wall-face-graph.ts` (+36),
|
||
`src/logic.ts` (+12), `src/styles.ts` (+27); тесты — 146 строк в
|
||
четырёх файлах; один новый smoke (`demo/smoke_plan_drawing_repairs.mjs`,
|
||
143 строки) плюс точечная правка существующего
|
||
(`demo/smoke_unified_wall_tool.mjs`); i18n en/ru; семь файлов
|
||
документации; три синхронные копии бандла.
|
||
|
||
Прочитаны перед разбором: `docs/SCOPE.md`, `AGENTS.md`, `PROCESS.md`,
|
||
тело issue #228 и все семь комментариев (аналитика → продуктовые
|
||
вопросы → решения владельца → «ТЗ готово» → зелёное ревью ТЗ → «Взял» →
|
||
хендофф разработчика), полный текст
|
||
`docs/specs/228-plan-drawing-problems.md` (все 17 AC), затронутые
|
||
канонические документы (`CANVAS.md`, `ARCHITECTURE.md`,
|
||
`WALL-THICKNESS.md`, `TOUCH-SUPPORT.md`, `USER-GUIDE.ru.md`).
|
||
|
||
## 2. Как проверялось — гейты
|
||
|
||
Объём гейтов соразмерен задаче (§8 PROCESS.md): дешёвые гейты
|
||
прогнаны полностью, тяжёлые — по необходимости, определяемой diff'ом
|
||
и AC.
|
||
|
||
| Гейт | Команда | Результат |
|
||
|---|---|---|
|
||
| Typecheck | `npx tsc --noEmit` | зелёный, без вывода |
|
||
| Unit | `npm test` | `# tests 1031 / # pass 1031 / # fail 0` |
|
||
| Build | `npm run build` | зелёный, `dist/houseplan-card.js` собран за 12.6s |
|
||
| Синхронность бандла | `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` и `cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` | обе копии побайтно идентичны, `git status` после сборки чист |
|
||
| Named smoke | `node demo/smoke_plan_snap_overlay.mjs` | все 34 проверки `true`, `OK` |
|
||
| Named smoke | `node demo/smoke_unified_wall_tool.mjs` | все 19 проверок `true`, `OK` |
|
||
| Named smoke | `node demo/smoke_plan_drawing_repairs.mjs` | все 14 проверок `true`, `OK` |
|
||
|
||
Эти три smoke named в §14.2 ТЗ и покрывают ровно те поверхности,
|
||
которые задевает diff. Расхождение с хендоффом автора (у него
|
||
«1030 pass, 1 skipped», у меня «1031 pass, 0 fail») не влияет на
|
||
вердикт — оба прогона зелёные, разница объясняется средой (моя
|
||
локальная копия не показывает ни одного skipped теста); я не стал
|
||
разбирать её отдельно, так как результат в обоих случаях «всё
|
||
проходит».
|
||
|
||
**Чего не прогонял и почему:**
|
||
|
||
- `npm run golden:verify` — diff не меняет ни один *существующий*
|
||
видимый кадр (новые визуальные элементы — `active-axis`,
|
||
`active-vertex`, `wall-repair-preview`, `.conflict` узел,
|
||
`hp-dialog` удаления комнаты — по коду не рендерятся, пока
|
||
инструмент неактивен; `viewHasNoEditorRepairChrome` в smoke это
|
||
подтверждает). Полная golden-матрица — предрелизный гейт (§14.3
|
||
ТЗ, §8 PROCESS.md), не гейт код-ревью.
|
||
- `python -m pytest tests_backend` — ни один файл
|
||
`custom_components/**/*.py` не тронут; ТЗ §16 прямо фиксирует, что
|
||
Python/schema изменений не ожидается, и это подтверждено diff'ом.
|
||
- Полный smoke-набор (127 файлов) и performance-профили — задача не
|
||
задевает все поверхности; названные в АС и связанные с diff смоки
|
||
прогнаны выше. Это предрелизный гейт по действующему решению
|
||
владельца (§8, §11.4).
|
||
|
||
## 3. Разбор по AC
|
||
|
||
Ревью кода отвечает за вопрос «оно вообще работает»; там, где нет
|
||
автотеста, ниже явно написано «проверено чтением, не исполнением».
|
||
|
||
- **AC1** (активная ось/узел) — `smoke: activeSegmentShowsAxisAndNode`
|
||
зелёный; код рендерит `.active-axis`/`.active-vertex` только когда
|
||
снап не выдал candidate/conflict (houseplan-card.ts:18970-18977,
|
||
`_drawSvg`), что не дублирует активный snap-маркер. Проверено
|
||
автотестом.
|
||
- **AC2** (неоднозначные endpoints) — `test/plan-snap-overlay.test.mjs`
|
||
покрывает ambiguous/resolved-после-zoom; `smoke:
|
||
closeEndpointsFailClosed` зелёный; click-путь
|
||
(`_markupClick`, houseplan-card.ts:7286-7295) не пишет точку и
|
||
показывает toast. Проверено автотестом.
|
||
- **AC3** (strict Shift) — `resolveStrictPlanSnap` в
|
||
`plan-snap-overlay.ts` реализует forward-ray проекцию для endpoint и
|
||
line/ray intersection; unit-тест «strict Shift accepts only points…»
|
||
и smoke `strictShiftUsesExactRayIntersection` зелёные. Прочитан код
|
||
геометрии луча (`selectedRay`, `pointOnForwardRay`) — пересечение
|
||
считается через параметрическое уравнение, non-Shift путь
|
||
(`resolvePlanSnapResult`) не тронут кроме ambiguity guard, как и
|
||
требует §8.3.5. Проверено автотестом.
|
||
- **AC4** (угловая подпись) — `isExact45Vector` (logic.ts) заменяет
|
||
широкий `is45(deg, 0.5)`; вызывается с geometry epsilon
|
||
`this._gridPitch * 0.0002`, тем же порядком величины, что и везде в
|
||
файле. Smoke `angleColourMatchesActualVector` явно проверяет
|
||
90,1° как красный. Проверено автотестом.
|
||
- **AC5/AC8** (комната из готовой области) — `findWallFaceAtPoint`
|
||
(wall-face-graph.ts) исключает точки на границе и выбирает
|
||
наименьшую площадь; `_offerExistingWallFace` вызывается только при
|
||
пустом `_path`, без snap-candidate и без Shift (houseplan-card.ts:7295).
|
||
Smoke проверяет `existingFaceOffersRoom`,
|
||
`keepExistingFaceIsNoop`, `shiftBypassesExistingFaceOffer`,
|
||
`createExistingFaceKeepsPartitions` (после Create партиции остаются
|
||
4 — существующий контур не тронут). Проверено автотестом.
|
||
- **AC6/AC7** (repair ≤2 см, атомарность, негативная матрица) —
|
||
`wall-face-repair.ts` реализован как чистая функция,
|
||
immutable-инвариант проверен тестом (`structuredClone`
|
||
snapshot-сравнение). Позитив (1,2 см и ровно 2,0 см → repair; 2,01 →
|
||
none), негатив «multiple valid closures fail closed» и «room vertex
|
||
never moves» (assert на `!sourceKey.startsWith('static:room|')`)
|
||
покрыты unit-тестами; commit-only-with-Create и bit-equivalent
|
||
Cancel/Keep — smoke `repairCommitsOnlyWithRoom`. **Не покрыто**:
|
||
см. находку M1 ниже — hosted-opening negative case из
|
||
ТЗ §14.1 п.8 не имеет ни unit, ни smoke доказательства, хотя AC7
|
||
явно называет `unit + smoke` способом доказательства.
|
||
- **AC9** (диалог удаления) — `_renderRoomDeleteDialog` использует
|
||
`hp-dialog` (не `confirm()`), primary «Keep walls», danger «Delete
|
||
walls», `@hp-close` на Cancel/X/Escape. Smoke
|
||
`deleteUsesAccessibleChoiceDialog` подтверждает отсутствие
|
||
`confirm` и наличие `hp-dialog` в DOM. `hp-dialog` — переиспользуемый
|
||
компонент (тот же, что и `_renderPartitionDeleteDialog`), его
|
||
focus-trap/Escape-контракт не часть этого diff — проверено чтением,
|
||
не переисполнено отдельно.
|
||
- **AC10/AC11** (Keep/Delete walls, эксклюзив/shared/hosted) —
|
||
`room-deletion.ts` — чистая функция; unit-тесты покрывают
|
||
reuse compatible masonry, virtual/shared/zero-thickness исключение,
|
||
exclusive rehost vs shared/hosted survival,
|
||
`parameterOnPartition` для двух направлений полигона. Smoke
|
||
`keepWallsMaterializesAndRehosts` и
|
||
`deleteWallsCascadesExclusiveOpening` зелёные. Проверено чтением
|
||
вызывающего кода `_confirmRoomDelete`: capacity-проверка
|
||
(`MAX_PARTITIONS`) выполняется до `_geometrySnapshot()`/мутации —
|
||
соответствует «fail before mutation». Проверено автотестом плюс
|
||
чтением оркестрации.
|
||
- **AC12** (один Undo/атомарность) — `_recordGeometry(...)` вызывается
|
||
один раз в конце `_confirmRoomDelete` и один раз в конце
|
||
`_applyWallFaceBatch`; проверка `_validateWallRepair` выполняется до
|
||
захвата `before`-снапшота и до записи в `sp`. Проверено чтением, не
|
||
исполнением (нет отдельного Undo/Redo unit-теста для этого пути, но
|
||
структура кода идентична уже принятому паттерну `_applyWallFaceBatch`
|
||
из #173, который эту гарантию уже несёт).
|
||
- **AC13** (существующие рендер-потребители не регрессируют) — diff не
|
||
трогает Iso/Glow/sun рендер; новые SVG-элементы гейтятся текущим
|
||
инструментом/режимом. `viewHasNoEditorRepairChrome` подтверждает
|
||
отсутствие чрезмерной рендер-цепочки в View. Проверено чтением плюс
|
||
smoke.
|
||
- **AC14** (touch/gesture safety) — код не вводит новых touch-путей;
|
||
`_offerExistingWallFace` вызывается из того же `_markupClick`, что
|
||
уже проходит через существующий suppressed-click/pointercancel guard
|
||
(не тронут этим diff). Явного нового targeted-smoke на touch-жесты
|
||
для repair/existing-face нет, но `smoke_plan_snap_overlay`
|
||
(`panPinchCancelDoNotCommit`, не в этом diff, но покрывает тот же
|
||
путь) и общий контракт `_markupClick` дают достаточное покрытие
|
||
чтения. Проверено чтением, не исполнением.
|
||
- **AC15** (perf) — `findWallFaceAtPoint`/`planWallFaceRepair`
|
||
вызываются только из `_offerExistingWallFace` (idle click) и
|
||
`_offerWallFaces` (после принятого сегмента); ни один вызов не стоит
|
||
в `_svgPointerMove`/hover-пути (тот использует только
|
||
`resolvePlanSnapResult`/`resolveStrictPlanSnap`, O(E) над кэшированной
|
||
геометрией). Проверено чтением, не исполнением; performance-профиль
|
||
— предрелизный гейт по ТЗ §14.3.
|
||
- **AC16** (backend/schema) — `custom_components/**/*.py` не тронут;
|
||
новых persisted-полей нет, `partitions`/`openings` используют
|
||
существующую схему (`cm`, `host.kind: 'partition'`). Проверено
|
||
чтением diff'а.
|
||
- **AC17** (гейты, i18n, changelog, документация) — см. таблицу гейтов
|
||
выше; en/ru ключи парные (`btn.delete_room_keep_walls` и т. д.);
|
||
`docs/CHANGELOG.md`/`docs/CHANGELOG.ru.md` правлены в том же
|
||
коммите `2aaef12` с `User-Visible: yes`; `docs/CANVAS.md`,
|
||
`docs/ARCHITECTURE.md`, `docs/WALL-THICKNESS.md`,
|
||
`docs/TOUCH-SUPPORT.md`, `docs/USER-GUIDE.ru.md`, `docs/TESTING.md`,
|
||
`docs/STATUS.md` обновлены и соответствуют реализации (см. §4 ниже
|
||
про место, где текст документации разошёлся с точным поведением
|
||
кода). Проверено чтением всех перечисленных диффов.
|
||
|
||
## 4. Находки
|
||
|
||
### M1 (Medium, в скоупе) — негативный сценарий «hosted opening» не доказан автотестом
|
||
|
||
**Файл:** `src/houseplan-card.ts:12673-12677` (`_validateWallRepair`),
|
||
соотнесено с `docs/specs/228-plan-drawing-problems.md` AC7 и §14.1 п.8.
|
||
|
||
**Описание:** AC7 явно требует доказательства `unit + smoke` для
|
||
негативной матрицы, включающей «invalid hosted opening»: repair,
|
||
который сдвинул бы partition, на которой уже висит проём, обязан быть
|
||
отклонён. Guard в коде существует
|
||
(`if (proposal.sourceKey.startsWith('static:partition|')) { … if
|
||
(openings.some(host.id === id)) return false; }`) и по чтению кода
|
||
верен (правильно парсит `static:partition|<id>|…` через уже
|
||
существующий формат ключа из `plan-snap-overlay.ts:sourceKey`). Но ни
|
||
`test/wall-face-repair.test.mjs` (проверяет только чистую геометрию,
|
||
не знает о проёмах), ни `demo/smoke_plan_drawing_repairs.mjs`
|
||
(единственный `opening` в смоке участвует только в сценариях удаления
|
||
комнаты, не в сценарии 2-см repair) не воспроизводят этот случай.
|
||
|
||
**Как проявится:** если этот guard будет случайно ослаблен или удалён
|
||
в будущей правке (например, при рефакторинге `_validateWallRepair`),
|
||
ни один тест не покраснеет — repair сможет сдвинуть стену, на которой
|
||
висит проём, произведя проём с некорректным host/geometry, то есть
|
||
нарушив инвариант ТЗ «Repair не остаётся вычисляемой ‘почти связью’» и
|
||
«Проём не остаётся orphan». Разбор чтением не заменяет здесь автотест,
|
||
потому что сам процесс требует именно `unit + smoke` для этой строки
|
||
AC7.
|
||
|
||
**Что нужно:** один unit-тест на уровне `houseplan-card` (или
|
||
эквивалентный целевой smoke-сценарий в
|
||
`demo/smoke_plan_drawing_repairs.mjs`) с существующей room-wall или
|
||
independent-partition, несущей hosted opening в зоне ≤2 см от
|
||
незамкнутого face, подтверждающий, что `_offerExistingWallFace`/
|
||
`_applyWallFaceBatch` не создают repair либо отклоняют его на Create с
|
||
понятным сообщением.
|
||
|
||
### M2 (Medium, в скоупе) — неоднозначный «gap > 2 см» диагностируется как единственный
|
||
|
||
**Файл:** `src/houseplan-card.ts:7675-7687` (`_offerExistingWallFace`),
|
||
соотнесено с `docs/specs/228-plan-drawing-problems.md` §8.6, последний
|
||
абзац.
|
||
|
||
**Описание:** ТЗ дословно: «Если gap больше 2 см, но около click
|
||
существует **единственный** диагностируемый разрыв в screen
|
||
snap-range, он подсвечивается и room dialog не открывается.
|
||
**Неоднозначный** или недиагностируемый открытый набор остаётся
|
||
обычным Walls flow». Код второй (более широкой) диагностики не
|
||
различает `diagnostic.kind === 'repair'` и `diagnostic.kind ===
|
||
'ambiguous'`:
|
||
|
||
```ts
|
||
if (diagnostic.kind !== 'none') {
|
||
this._wallRepairDiagnostic = diagnostic.kind === 'repair'
|
||
? diagnostic.proposal : diagnostic.proposals[0] || null;
|
||
this._showToast(this._t('toast.wall_repair_too_large'));
|
||
return true;
|
||
}
|
||
```
|
||
|
||
При `kind === 'ambiguous'` код всё равно подсвечивает один
|
||
произвольный `proposals[0]`, показывает toast и **перехватывает клик**
|
||
(`return true`), вместо того чтобы вернуть `false` и позволить обычный
|
||
Walls flow (начать новую цепочку с этой точки), как того требует
|
||
дословный текст ТЗ.
|
||
|
||
**Как проявится:** три-четыре стены, образующие незамкнутый контур с
|
||
разрывом 3 см, у которого в пределах 12 CSS px существует более одного
|
||
структурно равноценного способа замкнуть face (например, две разные
|
||
комбинации independent-стен) — обычный клик внутри контура вместо
|
||
обычного начала новой стены покажет «Разрыв больше 2 см» и подсветит
|
||
один из вариантов, будто это единственный. Пользователь не может
|
||
продолжить рисовать стену с этой точки без дополнительного действия
|
||
(Shift+click), хотя контракт обещает ordinary flow.
|
||
|
||
**Что нужно:** при `diagnostic.kind === 'ambiguous'` в этой ветке
|
||
вернуть `false` (не перехватывать клик, не подсвечивать, без toast),
|
||
как это уже сделано для случая `result.kind === 'ambiguous'` в
|
||
основной (≤2 см) ветке — там как раз есть выделенный
|
||
`toast.wall_repair_ambiguous`, здесь дополнительной обработки не
|
||
требуется вовсе, только не путать `ambiguous` с `repair`.
|
||
|
||
Обе находки Medium и **в скоупе задачи** (AC7 и §8.6 — прямая часть
|
||
ТЗ #228): по решению владельца (2026-08-19, #202) отдельный issue не
|
||
заводится, правка делается в этом же issue, фикс проходит повторный
|
||
цикл ревью.
|
||
|
||
## 5. Что проверено и корректно (сверх раздела 3)
|
||
|
||
- Ключи `static:*`/`active:*`/draft `id` формата `<draftId>:<index>`
|
||
строятся в одном месте (`plan-snap-overlay.ts:sourceKey`,
|
||
`houseplan-card.ts:_activeWallSourceKey`) и корректно парсятся
|
||
обратно в `_applyWallRepair`/`_validateWallRepair` для всех трёх
|
||
веток (`active:`, `static:partition|`, `static:draft|`) — трассировка
|
||
индекса `/:(\d+)$/` и `lastIndexOf(':')` соответствует формату
|
||
генератора ключа, включая случай `endpoint === 'b'` (сдвиг индекса
|
||
на +1). Не нашёл рассинхронизации.
|
||
- `stableEndpointMover`/`movable()` в `wall-face-repair.ts` корректно
|
||
исключает пары, где оба конца — room-vertex (обе стороны
|
||
immovable), и не позволяет room выступать «двигателем» даже когда
|
||
он геометрически «удобнее» (grid-canonical) — проверено чтением и
|
||
подтверждено тестом на `!sourceKey.startsWith('static:room|')`.
|
||
- Капасity-проверка в `_confirmRoomDelete` считает именно новые (не
|
||
переиспользуемые) партиции через `Set` по `interval.key` **до**
|
||
снятия геометрии — соответствует «Capacity … fail before mutation»
|
||
(ТЗ §8.7/инвариант 8).
|
||
- Три копии бандла после чистой пересборки побайтно идентичны, рабочее
|
||
дерево чисто после сборки — обязательное условие §8 AGENTS.md
|
||
выполнено этим прогоном.
|
||
- i18n: каждый новый en-ключ имеет ru-пару и наоборот (`btn.*`,
|
||
`history.*`, `confirm.*`, `toast.*`), термины «независимая стена»,
|
||
«увеличить масштаб» соответствуют `docs/USER-GUIDE.ru.md`.
|
||
- Документация обновлена в затронутых канонических файлах и не
|
||
противоречит коду ни в одном прочитанном месте, кроме находки M2,
|
||
где именно **код** расходится с уже верно написанным текстом ТЗ (не
|
||
наоборот).
|
||
|
||
## 6. Чего не проверял
|
||
|
||
- Golden/визуальную матрицу (active axis, conflict-узел, red repair
|
||
preview, delete-dialog) — предрелизный гейт, не гейт код-ревью;
|
||
новые CSS-классы `active-axis`/`.plan-snap-node.conflict`/
|
||
`.wall-repair-preview` просмотрены только по исходнику styles.ts, не
|
||
по скриншоту.
|
||
- Полный HA backend harness — не тронут ни один `.py`-файл, пропуск
|
||
обоснован ТЗ §16.
|
||
- Performance-профиль 60-room/60-partition (AC15) — не запускал
|
||
профилировщик, вывод сделан чтением вызовов (см. §3, AC15).
|
||
Предрелизный гейт по ТЗ §14.3.
|
||
- touch-специфичные жесты (pinch/pan/pointercancel) именно для
|
||
existing-face/repair путей — не находил отдельного smoke-сценария
|
||
под палец/эмуляцию тача для новой функциональности; полагался на то,
|
||
что она проходит через тот же `_markupClick`, что и остальной Walls
|
||
flow, чей gesture-guard не тронут этим diff.
|
||
- Многопользовательский конкурентный сценарий (структурный
|
||
fingerprint при открытом диалоге удаления комнаты, пока другой
|
||
клиент меняет геометрию) — не воспроизводил вручную; по чтению кода
|
||
`_confirmRoomDelete` план заново строится из текущего `space` в
|
||
момент подтверждения, а не из снимка на момент открытия диалога, что
|
||
делает застаревание маловероятным, но это не проверено тестом.
|
||
|
||
## 7. Вердикт
|
||
|
||
Обе находки — Medium, в скоупе задачи, без High. По §2.7/§4
|
||
PROCESS.md это жёлтый вердикт: задача возвращается автору на правку в
|
||
рамках issue #228, фикс проходит повторный цикл код-ревью (бюджет
|
||
цикла — 1 из 4, поскольку жёлтый вердикт цикл расходует).
|
||
|
||
Вердикт: жёлтый · заход r1 · блокирующих циклов 1/4 · High: 0 · Medium: 2 → в задаче
|