Files
houseplan-card/docs/reviews/CODE-REVIEW-228-r3.md
T
2026-08-22 08:14:31 +00:00

314 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-228-r3
- Issue: [#228](https://github.com/Matysh/houseplan-card/issues/228) — надёжное рисование стен и операции с готовым контуром
- ТЗ: `docs/specs/228-plan-drawing-problems.md` (ревью ТЗ зелёное, r1, #228)
- Ветка: `issue/228-plan-drawing-problems`
- Коммит на момент этого захода: `55d5a56` (HEAD; продуктовые коммиты в диапазоне — `2f96899` "fix: make plan drawing fail closed", `691cea0` "fix: close plan repair review findings")
- Заход ревью: r3 · блокирующих циклов израсходовано 1 из 4 (r1 был жёлтым и списал единицу; r2 был зелёным и бюджет не потратил, #227)
- Ревьюер: Claude (код-ревью), сессия без контекста реализации и без контекста r1/r2-ревью
## 0. Предыдущий раунд, SHA и причина полного разбора
Вердикт r2 найден в комментарии issue
([issuecomment-5379111358](https://github.com/Matysh/houseplan-card/issues/228#issuecomment-5379111358)):
зелёный · заход r2 · блокирующих циклов 1/4 · High: 0 · Medium: 0, на SHA `172d5d7`
(явно назван в шапке `docs/reviews/CODE-REVIEW-228-r2.md`). Готово к очереди на
пре-релиз.
Сразу после зелёного r2 слияние в `dev` не удалось: ветка `issue/228-plan-drawing-problems`
конфликтовала с `dev`, куда параллельно вошла работа по #233 («measure resize
labels between wall faces», коммиты `abfaae3`/`bff47f5`/…/`66ffd6f`). Задача ушла
в `S6-in-progress` не на правку кода, а на ребейз (комментарий
[issuecomment-5379112319](https://github.com/Matysh/houseplan-card/issues/228#issuecomment-5379112319)).
Автор перебазировал ветку на актуальный `origin/dev` (`66ffd6f`), вручную
объединив RU/EN changelog и пересобрав три копии бандла; продуктовый TypeScript
слился автоматически без конфликтов (комментарий
[issuecomment-5379127615](https://github.com/Matysh/houseplan-card/issues/228#issuecomment-5379127615)).
Автоматический прогон ревью после этого не отработал ([упавший workflow run](https://github.com/Matysh/houseplan-card/actions/runs/32561004646));
метка осталась на месте, что по §10.4 AGENTS.md означает сбой самого прогона, а
не вердикт — правки в задаче нет, ревью просто не состоялось. Этот заход и есть
тот прогон.
`git rev-parse HEAD` = `55d5a560adfc1da92f774b4073cbd37065de4f66`,
`git merge-base HEAD origin/dev` = `66ffd6fdda347a005993a8d396ee61c14ccc3adc` = `origin/dev` —
то есть ветка полностью содержит текущий `dev`. Старые SHA `2aaef12`/`172d5d7`
после ребейза недостижимы (переписаны, как и ожидается от `git rebase`).
**Это ребейз на ушедший вперёд `dev` — по прямому указанию задачи разбор в этом
заходе полный (`git diff origin/dev...HEAD`), а не по дельте r2→r3**: после
ребейза это, формально, другой код (§7.2 AGENTS.md), и есть конкретный риск —
и #228, и #233 правят один и тот же файл `src/houseplan-card.ts` (#233
затрагивает секцию «room resize tool» вокруг `_rszInnerSpanCms`, #228 — секции
Walls/room-markup/room-deletion/wall-thickness), поэтому нужно было убедиться,
что автослияние не потеряло и не задвоило код ни одной из задач.
## 1. Скоуп проверки — полный диапазон
`git log --oneline origin/dev..HEAD`: 6 коммитов — `e44b3c9` (ТЗ), `9a131cc`
(SPEC-REVIEW r1), `2f96899` (реализация r1, бывший `2aaef12`), `8fc3ec7`
(CODE-REVIEW r1), `691cea0` (фикс r2, бывший `172d5d7`), `55d5a56`
(CODE-REVIEW r2). Трейлеры на обоих продуктовых коммитах корректны: `Issue: #228`,
`User-Visible: yes`, оба меняют `docs/CHANGELOG.md`/`docs/CHANGELOG.ru.md` в том
же коммите.
`git diff origin/dev...HEAD --stat`: 33 файла, +3190/−356. Продуктовый код —
`src/houseplan-card.ts` (+396/−54 фактически, часть строк — импорты/поля),
`src/plan-snap-overlay.ts` (+178/−26 с учётом рефакторинга `resolvePlanSnap` в
`resolvePlanSnapResult`), новые `src/wall-face-repair.ts` (+165),
`src/room-deletion.ts` (+122), расширение `src/wall-face-graph.ts` (+36),
`src/logic.ts` (+12, `isExact45Vector`), `src/styles.ts` (+27). Тесты — четыре
файла (+166 суммарно), новый smoke `demo/smoke_plan_drawing_repairs.mjs`
(172 строки) плюс точечная правка `demo/smoke_unified_wall_tool.mjs`. Семь
файлов документации, RU/EN i18n, три синхронные копии бандла, `tsconfig.test.json`.
Прочитаны перед разбором: `docs/SCOPE.md`, `AGENTS.md`, `PROCESS.md`, тело issue
#228 и все 13 комментариев (аналитика → продуктовые вопросы → решения
владельца → «ТЗ готово» → зелёное ревью ТЗ → «Взял» → хендофф r1 → жёлтый r1 →
хендофф фикса → зелёный r2 → неудавшееся слияние/ребейз → хендофф ребейза →
сбой автопрогона), полный текст `docs/specs/228-plan-drawing-problems.md` (все
17 AC), оба предыдущих документа код-ревью (`CODE-REVIEW-228-r1.md`,
`CODE-REVIEW-228-r2.md`), затронутые канонические документы (`CANVAS.md`,
`ARCHITECTURE.md`, `WALL-THICKNESS.md`, `TOUCH-SUPPORT.md`, `USER-GUIDE.ru.md`),
а также коммиты #233 (`abfaae3` и соседние) — чтобы понять зону пересечения
файлов при автослиянии.
## 2. Как проверялось — гейты
Объём гейтов соразмерен задаче (§8 PROCESS.md): дешёвые прогнаны полностью, из
тяжёлых прогнаны именно те, что закрывают риск ребейза (пересечение с #233) и
названы в АС; остальные — предрелизный гейт.
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | зелёный, без вывода |
| Unit | `npm test` | `# tests 1036 / # pass 1036 / # fail 0` (совпадает с `npm run inventory`: Node unit 1036) |
| Build | `npm run build` | зелёный, `dist/houseplan-card.js` собран за ~12s |
| Синхронность бандла | `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` и `cmp … demo/srv/assets/houseplan-card.js` | обе копии побайтно идентичны; `git status` после сборки чист (рабочее дерево не отличается от коммита — ребейзный бандл собран верно, не просто скопирован вручную) |
| Named smoke (АС #228) | `node demo/smoke_plan_snap_overlay.mjs` | 34/34 `true`, `OK` |
| Named smoke (АС #228) | `node demo/smoke_unified_wall_tool.mjs` | 19/19 `true`, `OK` |
| Named smoke (АС #228) | `node demo/smoke_plan_drawing_repairs.mjs` | 16/16 `true`, `OK` (включая `hostedOpeningBlocksRepair` и `ambiguousLargeGapUsesWallsFlow` — оба фикса r1/r2 продолжают проходить после ребейза) |
| Smoke пересекающейся задачи (риск ребейза) | `node demo/smoke_resize_inner_dimensions.mjs` | `OK` — #233 использует тот же `src/houseplan-card.ts`; зелёный прогон подтверждает, что автослияние не задело код измерения resize-подписей |
**Чего не прогонял и почему:**
- `npm run golden:verify` — diff не меняет ни один *существующий* видимый кадр:
новые визуальные элементы (`.active-axis`, `.active-vertex`,
`.wall-repair-preview`, `.plan-snap-node.conflict`, `hp-dialog` удаления
комнаты) рендерятся только при активном инструменте/диалоге, что подтверждает
`viewHasNoEditorRepairChrome` в смоке. Полная golden-матрица (включая
forced-colors для нового `.conflict`-состояния, см. §6) — предрелизный гейт
(§14.3 ТЗ, §8 PROCESS.md), не гейт код-ревью; это же решение принято в r1/r2
и дельта его не меняет.
- `python -m pytest tests_backend` — ни один `.py`-файл не тронут ни в диапазоне
#228, ни в объединяющем ребейзе; ТЗ §16 прямо фиксирует отсутствие
backend/schema изменений, подтверждено `git diff --name-only`.
- Полный smoke-набор (163 файла по `npm run inventory`) и performance-профили —
задача не задевает все поверхности; названные в АС и связанные с диапазоном
ребейза (`smoke_resize_inner_dimensions`) прогнаны выше. Предрелизный гейт по
действующему решению владельца (§8, §11.4 AGENTS.md).
## 3. Ребейз: что именно проверено дополнительно к r1/r2
Поскольку разбор полный, ниже — не пересказ r1/r2, а свежая проверка того, что
могло сломаться именно ребейзом (автослияние `.ts`, ручное слияние changelog,
пересборка бандла), плюс сквозное чтение всего diff'а заново.
1. **Пересечение файлов с #233.** `git diff` #233 (`abfaae3`) правит
`src/houseplan-card.ts` только вокруг `_rszInnerSpanCms`/`_rszScaleLabels`
(секция «room resize tool», строки ~8248–8340 до ребейза) и новый
`src/wall-thickness.ts`. Диапазоны, которые правит #228 в `houseplan-card.ts`
(импорты, `_deleteRoomClick`/`_confirmRoomDelete` в районе строки 11053,
`_validateWallRepair`/`_applyWallRepair` в районе 12704–12780, рендер в
районе 16780/18840/19078) не пересекаются со строками #233. Это подтверждает,
почему TypeScript-слияние прошло автоматически без конфликта, а не просто
«повезло»: зоны правок дизъюнктны.
2. **Отсутствие маркеров незакрытого конфликта.** `grep -n "<<<<<<<\|=======\|>>>>>>>"` по
всем изменённым `.ts`-файлам (`houseplan-card.ts`, `wall-thickness.ts`,
`wall-face-graph.ts`, `wall-face-repair.ts`, `room-deletion.ts`,
`plan-snap-overlay.ts`, `logic.ts`, `styles.ts`) — 0 совпадений (найденные
строки `=================` — это секционные комментарии-разделители кода, не
следы конфликта).
3. **M1/M2 из r1, закрытые в r2, физически присутствуют после ребейза.**
`grep`: `repairMovesHostedPartition` определена в
`src/wall-face-repair.ts:34-42`, используется в `_validateWallRepair`
(`src/houseplan-card.ts:12717-12719`), покрыта в
`test/wall-face-repair.test.mjs`. Условие M2
(`diagnostic.kind === 'repair'`, а не `!== 'none'`) стоит на
`src/houseplan-card.ts:7685` внутри `_offerExistingWallFace`
(:7656–7695) — единственная ветка широкой (>2 см) диагностики, `ambiguous`
больше не перехватывает клик.
4. **Ручное слияние changelog не задвоило и не потеряло записи.**
`docs/CHANGELOG.md`/`docs/CHANGELOG.ru.md` содержат отдельными абзацами и
#228, и #233 (плюс #234, #230 из более раннего `dev`), без дублирования
текста и без обрыва предложений — прочитаны оба файла целиком.
5. **Бандл собран, а не просто перенесён.** Свежий `npm run build` после
ребейза даёт файл, побайтно совпадающий с уже закоммиченными тремя копиями
(§2), и `git status` после сборки чист — то есть коммит `691cea0` содержит
именно результат сборки текущего исходника, а не бандл, оставшийся от
до-ребейзного состояния.
Все пять пунктов подтверждают: ребейз чисто механический, продуктовое поведение
#228 не изменилось относительно уже дважды проверенного r1→r2 кода, и он не
повредил параллельно вошедшую работу #233.
## 4. Разбор по AC (полный, не только дельта ребейза)
- **AC1** (активная ось/узел) — `.active-axis`/`.active-vertex`
(`src/houseplan-card.ts:19012-19024`) рендерятся из `this._cursorPt` поверх
толстого preview, узел скрывается, когда есть активный snap-candidate или
конфликт (`!this._activePlanSnapCandidate && !this._activePlanSnapConflicts.length`) —
не дублирует существующий маркер. `smoke: activeSegmentShowsAxisAndNode`
зелёный. Проверено автотестом и чтением.
- **AC2** (неоднозначные endpoints) — `endpointResolution()`
(`src/plan-snap-overlay.ts:296-321`) даёt `ambiguous`, когда ≥2 разных ключа
оказались внутри `distinguishTolerance`; click-путь
(`_markupClick`, `houseplan-card.ts:7286-7295`) на `resolved.ambiguous`
показывает toast и `return` без записи точки/истории/конфига.
`test/plan-snap-overlay.test.mjs` и `smoke: closeEndpointsFailClosed`
зелёные. Проверено автотестом.
- **AC3** (strict Shift) — `resolveStrictPlanSnap`
(`src/plan-snap-overlay.ts:324-378`) проецирует на луч, кратный 45° от
`anchor`, для endpoint и line/segment intersection через параметрическое
уравнение; non-Shift путь (`resolvePlanSnapResult`) не меняет прежнее
поведение кроме ambiguity guard. Unit + `smoke:
strictShiftUsesExactRayIntersection` зелёные.
- **AC4** (угловая подпись) — `isExact45Vector`
(`src/logic.ts:1937-1946`) заменил `is45(deg, 0.5)`
(`houseplan-card.ts:18127`), сравнивает `dx`/`dy` с эпсилон, а не округлённые
градусы. `smoke: angleColourMatchesActualVector` явно проверяет 90,1° как
красный.
- **AC5/AC8** (комната из готовой области) — `findWallFaceAtPoint`
(`src/wall-face-graph.ts:571-591`) — point-in-polygon с исключением границы и
выбором наименьшей площади при равенстве по `key`; вызывается из
`_offerExistingWallFace` (`houseplan-card.ts:7656`) только при пустом `_path`,
без Shift и без snap-candidate (условие на `houseplan-card.ts:7316`). Decline
(Keep/Cancel) для одного `existing`/`repair`-кандидата — явный no-op
(`houseplan-card.ts:12624-12631`, ничего не пишет в `sp`). Smoke:
`existingFaceOffersRoom`, `keepExistingFaceIsNoop`,
`shiftBypassesExistingFaceOffer`, `createExistingFaceKeepsPartitions` —
зелёные.
- **AC6/AC7** (repair ≤2 см, атомарность, негативная матрица) —
`planWallFaceRepair` (`src/wall-face-repair.ts:101-165`) — чистая функция,
двигает только independent endpoint (`movable()` исключает
`static:room|`), выбирает детерминированный mover по `stableEndpointMover`
(grid-canonicality, затем геометрический tie-break, не record order).
`_validateWallRepair` (`houseplan-card.ts:12704-12720`) ревалидирует
source/target по актуальному графу и отклоняет repair, двигающий partition с
hosted opening, через `repairMovesHostedPartition`. Негативная матрица (>2 см,
two-gap→ambiguous, room-vertex immovable, hosted opening) покрыта unit-тестами
в `test/wall-face-repair.test.mjs` и smoke-сценариями
`hostedOpeningBlocksRepair`/`ambiguousLargeGapUsesWallsFlow`/
`smallGapOffersRepair`. Обе находки r1 (M1/M2) остаются закрыты — см. §3.4.
- **AC9** (диалог удаления) — `_renderRoomDeleteDialog`
(`houseplan-card.ts:19078-19100`) использует `<hp-dialog>` (не `confirm()`),
primary «Delete room, keep walls», danger «Delete room and walls»,
`@hp-close` на Cancel/X/Escape. `smoke: deleteUsesAccessibleChoiceDialog`
подтверждает отсутствие `confirm` и наличие `hp-dialog`. `hp-dialog` — уже
принятый переиспользуемый компонент (тот же, что и
`_renderPartitionDeleteDialog`), его focus-trap/Escape-контракт вне diff'а —
проверено чтением, не переисполнено отдельно (как и в r1).
- **AC10/AC11** (Keep/Delete walls) — `planRoomDeletion`
(`src/room-deletion.ts:73-112`) — чистая функция: `exclusive` фильтрует
`kind === 'outer' && !open && cm > 0` (позитивная толщина, не shared, не
open span); `containsInterval` переиспользует совпадающую partition по
углу+толщине+покрытию, не создавая дубликат. Openings с
`host.kind === 'partition'` исключены из переассоциации заранее — уже
привязанный проём никогда не становится «комнатным». Caller
(`_confirmRoomDelete`, `houseplan-card.ts:11065-11123`) считает
capacity (`MAX_PARTITIONS`) по **новым** (не reused) партициям **до**
`_geometrySnapshot()`/мутации (`houseplan-card.ts:11089`) — fail-before-mutation
соблюдён. Smoke `keepWallsMaterializesAndRehosts` /
`deleteWallsCascadesExclusiveOpening` зелёные.
- **AC12** (один Undo/атомарность) — `_recordGeometry(...)` вызывается один раз
в конце `_confirmRoomDelete` (после `_commitOpenSpans()`/нормализации стен) и
один раз в конце `_applyWallFaceBatch`; в обоих путях `_validateWallRepair`/
capacity-проверка выполняются до захвата `before`-снапшота. Проверено чтением
(нет отдельного Undo/Redo unit-теста на этот путь, структура идентична уже
принятому `_applyWallFaceBatch` из #173).
- **AC13** (существующие рендер-потребители) — `git diff --name-only` не
содержит ни одного изо/sun/light-файла; новые SVG-элементы гейтятся текущим
инструментом. `viewHasNoEditorRepairChrome` подтверждает отсутствие лишнего
DOM в View.
- **AC14** (touch/gesture safety) — новых touch-путей нет;
`_offerExistingWallFace` вызывается из того же `_markupClick`, что уже
проходит через непереписанный suppressed-click/pointercancel guard.
Проверено чтением, не исполнением (как и в r1 — отдельного targeted-смока на
жест именно для existing-face/repair нет).
- **AC15** (perf) — `grep` подтверждает: `findWallFaceAtPoint`/
`planWallFaceRepair` вызываются только из `_offerExistingWallFace` (idle
click, `:7316`) и `_offerWallFaces` (после принятого сегмента, `:7588`); ни
одного вызова из `_svgPointerMove`/hover-пути. Проверено чтением;
performance-профиль — предрелизный гейт по ТЗ §14.3.
- **AC16** (backend/schema) — `custom_components/**/*.py` не тронут ни в
диапазоне #228, ни объединяющим ребейзом; новых persisted-полей нет.
- **AC17** (гейты/i18n/changelog/документация) — таблица §2; en/ru ключи
парные (`btn.delete_room_*`, `history.delete_room_*`,
`confirm.delete_room_*`, `toast.plan_snap_ambiguous`,
`toast.wall_repair_*`) — сверено `git diff` обоих `i18n/*.json` целиком, для
каждого нового en-ключа найдена ru-пара и наоборот; оба changelog правлены в
`User-Visible: yes` коммитах `2f96899`/`691cea0`; `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` обновлены и не
противоречат коду ни в одном прочитанном месте.
## 5. Что проверено и корректно (сверх раздела 4)
- Ключи `static:*`/`active:*`/`static:draft|<id>:<index>` строятся в одном
месте и корректно парсятся обратно в `_applyWallRepair`
(`houseplan-card.ts:12724-12772`) для всех трёх веток (`active:`,
`static:partition|`, `static:draft|`), включая сдвиг индекса `+1` для
`endpoint === 'b'`. Не нашёл рассинхронизации.
- `stableEndpointMover`/`movable()` (`wall-face-repair.ts:44,68-81`) исключает
пары, где обе стороны — room-vertex, и не даёт room-полигону становиться
«двигателем» даже когда он геометрически удобнее.
- `repairMovesHostedPartition` вызывается на обоих путях подтверждения repair —
`_applyWallRepair` (одиночный) и через `_validateWallRepair` внутри
`_applyWallFaceBatch` (`houseplan-card.ts:12820-12823`, `repairs[0]`) — guard
не обходится ни одним из них.
- Три копии бандла после чистой пересборки побайтно идентичны, рабочее дерево
чисто после сборки — обязательное условие §8 AGENTS.md выполнено этим
прогоном заново, на текущем (пост-ребейзном) SHA, а не унаследовано с r1/r2.
- `docs/reviews/CODE-REVIEW-228-r1.md` и `-r2.md` в дереве соответствуют
описанным в них SHA (`2aaef12`→`2f96899`, `172d5d7`→`691cea0` после ребейза);
их аналитика по неизменным файлам (snap-overlay, strict-Shift, delete-room
dialog, room-deletion) остаётся применимой, что и подтверждено сквозным
чтением в §4 этого документа заново, а не голой ссылкой.
## 6. Чего не проверял
- Golden/визуальную матрицу (active axis, conflict-узел, red repair preview,
delete-dialog, включая forced-colors-контраст нового
`.plan-snap-node.conflict`, который не входит в текущий forced-colors smoke
`demo/smoke_plan_snap_overlay.mjs:242-254` — тот проверяет только базовый
`.plan-snap-node`/`.plan-snap-line`) — предрелизный гейт, не гейт код-ревью;
это то же самое решение, что и в r1/r2 (там же явно зафиксировано «просмотрено
только по исходнику styles.ts, не по скриншоту»), дельта ребейза его не
меняет и новых визуальных классов не добавляет.
- Полный HA backend harness — не тронут ни один `.py`-файл.
- Performance-профиль 60-room/60-partition (AC15) — не запускал профилировщик;
вывод сделан чтением вызовов (§4, AC15). Предрелизный гейт по ТЗ §14.3.
- Touch-специфичные жесты (pinch/pan/pointercancel) именно для
existing-face/repair путей — не находил отдельного targeted smoke под палец;
полагался на то, что путь проходит через общий `_markupClick` gesture-guard,
не тронутый диапазоном #228 (то же основание, что в r1).
- Многопользовательский конкурентный сценарий на delete/repair — не
воспроизводил вручную; по чтению кода план строится заново из текущего
`space` в момент подтверждения, а не из снимка на момент открытия диалога.
- Полный smoke-набор (163 файла) сверх пяти прогнанных выше (три названных в
АС #228, один общий forced-colors и один — `smoke_resize_inner_dimensions` —
специально ради риска ребейза с #233); остальные 158 не запускал, диапазон
их не задевает.
## 7. Вердикт
Ребейз на ушедший вперёд `dev` потребовал полного разбора вместо разбора по
дельте (§2.10/§7.2). Полный разбор не нашёл новых находок: обе Medium-находки
r1 остаются закрыты тем же кодом и тестами, что подтвердил r2, автослияние с
параллельной задачей #233 не пересекается по строкам и не повредило ни одну из
двух задач (подтверждено гейтами, целевыми и кросс-задачными smoke-прогонами и
сквозным чтением всего diff'а). High — 0, Medium — 0.
Вердикт: зелёный · заход r3 · блокирующих циклов 1/4 · High: 0 · Medium: 0
Готово к очереди на пре-релиз (`S8-merged`).