mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 19:58:50 +00:00
committed by
Sergey Matyunin
parent
2f968996b1
commit
8fc3ec783b
@@ -0,0 +1,329 @@
|
||||
# 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 → в задаче
|
||||
Reference in New Issue
Block a user