mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
@@ -0,0 +1,249 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user