Files
houseplan-card/docs/reviews/CODE-REVIEW-294-r1.md
T
2026-08-24 18:16:46 +00:00

250 lines
20 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-294-r1
- **Issue:** #294 — «Рисование стен: Esc отцепляет текущую цепочку без удаления последнего отрезка»
- **Заход:** r1 (первый код-ревью-цикл; ревью ТЗ прошло r1→r2 отдельно и не считается в этом бюджете)
- **Блокирующих циклов:** 0/4 израсходовано до этого захода
- **Ревьюемый коммит:** `32e8de4f` («feat: detach wall chain on Escape»), ветка `issue/294-wall-esc-detach`
- **Материал:** `git log --oneline origin/dev..HEAD`, `git diff origin/dev...HEAD`
- **ТЗ:** `docs/specs/294-wall-esc-detach.md` (редакция r2, принята зелёным SPEC-REVIEW-294-r2)
## Скоуп изменения
Диапазон `origin/dev..HEAD` — пять коммитов: аналитика/ТЗ/два ревью ТЗ уже
приняты отдельно, реализация — один коммит `32e8de4f`. Файлы этого коммита:
- `src/houseplan-card.ts` — 1 содержательная строка: `Escape` в ветке
`this._tool === 'draw' && this._path.length` теперь вызывает
`this._finishWallChain()` вместо `this._undoPoint()`;
- `src/i18n/en.json`, `src/i18n/ru.json` — `markup.hint_points` различает
Esc/Ctrl+Z;
- `demo/smoke_unified_wall_tool.mjs` — новые regression-сценарии через реальный
`window keydown` и `stage` click;
- `docs/USER-GUIDE.md`, `docs/USER-GUIDE.ru.md` — все шесть поверхностей,
перечисленных в §5 ТЗ и требуемых AC7;
- `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md` — бюллетень в том же коммите;
- `dist/houseplan-card.js`, `custom_components/houseplan/frontend/houseplan-card.js` —
синхронизированный бандл (класс D, сгенерировано `bundle:sync`).
Трейлеры коммита: `Issue: #294`, `User-Visible: yes` — оба changelog в этом же
коммите. Корректно.
## Как проверялось
### Гейты
| Гейт | Статус | Примечание |
|---|---|---|
| `npx tsc --noEmit` | **pass** | без вывода |
| `npm test` | **pass** | 1251 tests, 1250 pass, 1 skip, 0 fail — совпадает с хендоффом |
| `npm run build` + `cmp dist ↔ frontend` | **pass** | сборка идентична закоммиченным `dist/` и `custom_components/.../frontend/` |
| `npm run bundle:sync` (для стенда) | выполнен локально ревьюером | `demo/srv/assets/houseplan-card.js` не коммитится (#255), пересобран для прогона смоков |
| `node scripts/check-docs.mjs` | **FAIL** | см. Находку 1 — обязателен, диф трогает `src/**` |
| `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | выполнен | вывод ниже |
| `node demo/smoke_unified_wall_tool.mjs` (прямое совпадение) | **FAIL** (2 из 26) | см. Находку 2 |
| `node demo/smoke_wall_chain_merge.mjs` (прямое совпадение) | **pass** | 9/9, без регрессий |
| `node demo/smoke_wall_chain_thickness.mjs` (прямое совпадение) | **pass** | OK |
| `npm run golden:verify` | не прогонялся | см. «Чего не проверял» |
| `npm run invariants` / geometry model | не прогонялся отдельно | см. «Чего не проверял» |
| `python -m pytest tests_backend` | не прогонялся | диф не трогает `custom_components/**/*.py` |
Вывод `scripts/smoke-select.mjs --base origin/dev --head HEAD`:
```
Изменено файлов src/**: 1 · символов проекта на изменённых строках: 2
Матрица: 184 смоков · порог «широкого» символа: больше 36 смоков
Прямое совпадение (3):
demo/smoke_unified_wall_tool.mjs
← _finishWallChain
demo/smoke_wall_chain_merge.mjs
← _finishWallChain
demo/smoke_wall_chain_thickness.mjs
← _finishWallChain
```
Решение по строке: все три «прямых совпадения» прогнаны (таблица выше).
Остальные 181 смоков не запускались — задача меняет одну ветку клавиатурного
обработчика существующего единого инструмента «Стены», не трогает face
detection, split, decor, resize, touch и т.д.; полный прогон матрицы — гейт
предрелиза (§8), не этого ревью.
### AC — построчно
- **AC1** (`smoke`): доказан кодом и частично смоком.
`_finishWallChain()` при `this._path.length >= 2` конвертирует сегменты в
`partitions` той же функцией, что и при смене инструмента, удаляет
`room_draft`, очищает `_path/_activeDraftId/_draftSegmentCms/_closingWallCm`
и снэп-hover (`src/houseplan-card.ts:6720-6774`). Ассерт
`escapeFinishesWithoutDeletingSegments` в смоке зелёный. **Проверено чтением
+ прогоном смока.**
- **AC2** (`smoke`): код проверен чтением и подтверждён отдельным отладочным
прогоном, но штатный смок **красный** — см. Находку 2. Причина красного —
дефект теста (сравнение чисел с плавающей точкой), не продукта: код разбора
клика падает в ту же `_markupClick` → `_path.length === 0` ветку → новая
точка становится первым узлом чистого пути, а `_draftEndAt` ищет только в
`space.room_drafts`, который после `Esc`-finish пуст, поэтому старая точка
`C` не подхватывается (`src/houseplan-card.ts:7565-7591`, `7621-7636`).
**AC2 подтверждён чтением кода и точечной проверкой (см. Находку 2), но не
зелёным автотестом — до фикса теста это открытый пункт.**
- **AC3** (`smoke`): `escapeClearsOnlyTransientFirstPoint` зелёный; ветка
`this._path.length < 2` в `_finishWallChain` очищает состояние без записи
`room_draft`/`partition`/history (`6723-6730`). Подтверждено.
- **AC4** (`smoke`): `ctrlZContractStillRemovesLastSegment` зелёный;
`Ctrl/Cmd+Z` не тронут этим диффом и по-прежнему идёт через
`_undoActiveDraftPoint`/`_undoPoint` (`2588-2608`). Подтверждено.
- **AC5** (`smoke`): `firstEscapeOnlyCancelsRoomDialog` и
`secondEscapeFinishesRestoredDraft` зелёные; в `_onKey` проверка
`if (this._roomDialog) { …; return; }` (`2623-2627`) стоит раньше ветки
`draw`/`_path.length` (`2628-2635`) и не тронута диффом. Подтверждено.
- **AC6** (`smoke` + чтение): `rejectedFinishKeepsActiveDraft` зелёный;
проверка `MAX_PARTITIONS` в `_finishWallChain` (`6739-6742`) выполняется
раньше любой мутации `_path`/`sp.partitions`/history, поэтому `false` не
теряет состояние. Подтверждено.
- **AC7** (`unit` + ревью кода): все шесть поверхностей из §5 ТЗ построчно
сверены с `docs/USER-GUIDE.md`/`docs/USER-GUIDE.ru.md` — см. раздел «Что
проверено и корректно». `test/i18n-parity` часть `npm test` зелёная.
Подтверждено чтением, без старых противоречащих формулировок.
- **AC8** (`unit` + `build`): `tsc`, `npm test`, `npm run build`+bundle parity
— все pass (таблица гейтов). Подтверждено исполнением.
## Находки
### Находка 1 (Medium, в скоупе — правится в этом issue)
**Обязательный гейт `node scripts/check-docs.mjs` красный на ревьюемом SHA.**
```
ERROR screenshot source fingerprint is stale; run npm run build && node demo/docs/capture.mjs
```
Воспроизведение: на `origin/dev` (`8c036564`) тот же скрипт зелёный —
`Documentation checks passed (7 files, 10 external links)`. На `32e8de4f`
(этот диф) — красный, потому что `docs/images/screenshots.json.sourceFingerprint`
считается по всему `src/**` (#245/#246), а `src/houseplan-card.ts` изменён, и
пересъёмка/принятие скриншотов в этом коммите не выполнены.
Это ровно тот гейт, для которого правила ревью прямо говорят «выбирать нечего»
и напоминают цену пропуска: в #230 и #234 его пропустили, и `dev` простоял с
красным job `docs` до следующей задачи (#237). Оставлять его красным в этом
ревью значило бы повторить #230/#234.
**Правка:** прогнать job «Docs screenshots» (`workflow_dispatch`), принять
результат `npm run docs:accept -- --reviewed --from=<артефакт>` и закоммитить
обновлённый `docs/images/screenshots.json` (+ при необходимости PNG) вместе с
задачей, либо явно объяснить в хендоффе, почему для этого текстового изменения
пересъёмка не нужна (в текущем виде это не объяснено и гейт просто красный).
### Находка 2 (Medium, в скоупе — правится в этом issue)
**Новый таргетированный смок `demo/smoke_unified_wall_tool.mjs` падает на
двух ассертах, которые как раз доказывают AC2.**
```
FAILED (2):
- nextClickStartsIndependentChain: expected true, got false
- secondClickCreatesOnlyIndependentSegment: expected true, got false
```
Воспроизведение: `npm run bundle:sync && node demo/smoke_unified_wall_tool.mjs`
(бандл стенда не коммитится — #255 — поэтому синхронизация нужна перед прогоном).
Причина — не регрессия продукта, а сравнение координат с плавающей точкой без
допуска. Новый хелпер `clickStage(x, y)` (строки 18-28) переводит логические
координаты в `clientX/clientY` через `rect`/`view`, диспатчит настоящий DOM
`click` на `.stage`, а тест затем требует точного равенства
`card._path[0][0] === 500 && card._path[0][1] === 500`. Отдельным отладочным
прогоном подтверждено: тот же клик `(500, 500)` на чистой карте даёт
`card._path === [[500, 500.00000000000006]]` — снэп к сетке в
`_resolvePlanDrawPoint`/`_svgPoint` вносит обычную для плавающей арифметики
погрешность 6·10⁻¹⁴. Второй ассерт (`secondClickCreatesOnlyIndependentSegment`)
делит то же число на `NORM_W` и сравнивает с `0.5` — получает
`0.5000000000000001` и тоже падает. Сам продукт при этом работает верно:
`_draftEndAt` (строки 7621-7636) ищет привязку только в `space.room_drafts`,
который после `Esc`-finish пуст (черновик уже стал `partitions`), поэтому клик
по `D` действительно не подхватывает старую точку `C` — просто тест это не
может подтвердить в текущем виде.
Остальные 8 новых ассертов того же файла (включая три с реальным
`window keydown` — `escapeFinishesWithoutDeletingSegments`,
`repeatedEscapeAfterFinishIsNoop`, `rejectedFinishKeepsActiveDraft`, а также
диалоговые `firstEscapeOnlyCancelsRoomDialog`/`secondEscapeFinishesRestoredDraft`)
зелёные и доказывают AC1, AC3, AC5, AC6 честно — тест умеет падать: замена
`card._finishWallChain = () => false` перед `rejectedFinishKeepsActiveDraft`
подтверждает это на месте.
**Правка:** сравнивать с допуском (как это уже принято для координат в
проекте, например `Math.hypot(...) <= eps` в `_draftEndAt` этого же файла)
либо округлять полученную точку перед сравнением, а не требовать `=== 500`
после реального клика через трансформацию viewBox.
## Что проверено и корректно
- Единственная содержательная правка продукта — одна строка
(`src/houseplan-card.ts:2630-2633`): подмена `_undoPoint()` на
`_finishWallChain()` в ветке `Escape` при активном `draw` и непустом `_path`.
Никакой другой код `_onKey`, `_undoPoint`, `_undoActiveDraftPoint`,
`_activateMarkupTool`, приоритет `_roomDialog`/`_physicalDrag` не менялся —
риск регрессии сосредоточен ровно в этой ветке, и она разобрана по AC1-AC6
выше.
- `_finishWallChain` при отказе (`MAX_PARTITIONS`) не мутирует `_path` до
проверки лимита — active state переживает `false` (AC6), самостоятельно
подтверждено чтением строк 6720-6742 и зелёным `rejectedFinishKeepsActiveDraft`.
- Приоритет `_roomDialog` перед веткой `draw` не нарушен: строка `2623-2627`
идёт раньше `2628-2635` и не тронута диффом (AC5).
- `_resumeDraftBySpace` не сохраняет ссылку на завершённый draft: путь
`path.length < 2` (без активного `_activeDraftId`, что гарантировано
инвариантом «draft создаётся только при `path.length >= 2`», строка 7758) и
путь успешного finish (`delete this._resumeDraftBySpace[this._space]`,
строка 6767) оба корректны — прочитано и совпадает с ассертами
`!card._resumeDraftBySpace[card._space]`.
- Все шесть справочных поверхностей из ТЗ §5/AC7 действительно переписаны и
построчно сверены с `docs/USER-GUIDE.md`/`docs/USER-GUIDE.ru.md`; старых
формулировок «Esc/Ctrl+Z — убрать точку»/«cancels an unfinished path» для
контекста Walls не осталось. `markup.hint_points` в `en.json`/`ru.json`
совпадает по смыслу с этими описаниями.
- Оба changelog обновлены в том же коммите `32e8de4f`, что и продукт —
`User-Visible: yes` соблюдён.
- `dist/houseplan-card.js` и `custom_components/houseplan/frontend/houseplan-card.js`
идентичны локально пересобранному бандлу (`cmp` совпал).
- «Одно число — один источник»: этот диф не вводит и не дублирует ни одной
видимой пользователю величины (только текст подсказки/руководства); числовых
значений (толщина, площадь) правка не касается.
- Модель геометрии (рёбра, толщина, `layout`, `marker.space`, `open_spans`) не
затронута: изменена только точка вызова уже существующей и протестированной
`_finishWallChain`, а не сама геометрия/merge/лимиты. `npm test` (включающий
инварианты модели на всех моделях проекта) прошёл целиком.
## Чего не проверял
- `npm run golden:verify` — не прогонялся. Диф не меняет рендер геометрии,
стилей или слоёв (только текст подсказки и порядок вызова уже
протестированной функции), и ни один golden-сценарий не ссылается на
`hint_points`/Escape-детач; риск сочтён нерелевантным для этого изменения.
- `node scripts/model-invariants.mjs --config <…>` отдельно не запускался.
Диф не меняет генерацию/мерж геометрии — переиспользует существующий
`_finishWallChain`, уже покрытый `smoke_wall_chain_merge.mjs`/
`smoke_wall_chain_thickness.mjs` (прогнаны, зелёные) и структурными
инвариантами внутри `npm test`.
- `python -m pytest tests_backend` — не прогонялся, диф не трогает
`custom_components/**/*.py`.
- Полная матрица `demo/smoke_*.mjs` (184 файла) не прогонялась — прогнаны три
«прямых совпадения» из `smoke-select`; остальные 181 — предрелизная
обязанность (§8), задача не задевает face detection, split, decor, resize,
touch, kiosk и т.п.
- Touch/kiosk-поведение не проверялось руками — правка задевает только
desktop-обработчик `Escape`, что подтверждено чтением: единственное
добавленное ветвление стоит после `this._tool === 'draw'` в клавиатурном
обработчике, тач-жесты (`pointercancel`, pinch, pan) идут через другие ветки
того же `_onKey`/`_stagePointer*`, не тронутые диффом.
- Производительность отдельно не профилировалась — в АС и ТЗ не заявлено
влияние на perf-бюджет, а изменение сводится к перенаправлению одного
вызова уже существующей O(n) операции.
## Итог
High: 0. Medium: 2, обе в скоупе задачи (обязательный docs-гейт красный;
собственный новый смок ломается на двух ассертах из-за сравнения
floating-point координат без допуска, хотя доказываемое им поведение AC2 при
проверке чтением кода и точечным прогоном подтверждено верным). Без High это
жёлтый вердикт, возврат автору на правку в этом же issue — отдельный issue не
заводится (обе находки в скоупе, §2.7/#202).