Files
houseplan-card/docs/reviews/SPEC-REVIEW-447-r1.md
T
2026-09-04 05:39:54 +00:00

254 lines
22 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.
# SPEC-REVIEW-447-r1
- Issue: https://github.com/Matysh/houseplan-card/issues/447
- Этап: `spec` (PROCESS.md §2.4), полный трек (метка `S4-spec-review`, без `small`)
- Материал: `docs/specs/447-exterior-furniture-snap-keyboard-nudge.md` на коммите
[`a1fbea07`](https://github.com/Matysh/houseplan-card/commit/a1fbea07d2876d51afac3a9df12bfa756424df99)
(docs-only, класс C: `docs/specs/447-...md` + `docs/specs/README.md`, `git show --stat a1fbea07`)
- Заход: r1 · блокирующих циклов до этого раунда: 0/4
## Скоуп ревью
ТЗ описывает две независимые части одной issue:
1. наружная физическая поверхность для внешней стены (`roomFurnitureWallSurfaces`,
`snapFurnitureToWall` в `src/furniture-wall-surface.ts` / `src/furniture-placement.ts`);
2. Arrow-сдвиг выбранного объекта декора на одну ячейку сетки в Редакторе подложки
(`_keyHandler`, `_decorSel`, `_decorList` в `src/houseplan-card.ts` /
`src/houseplan-editor-runtime.ts`).
Продуктовых вопросов к владельцу нет: тело issue и оба комментария владельца
(04.09.2026) уже закрывают Shift-шаг, подложку плана, точную величину шага и связь
с #41. Проверялась не полнота согласования, а исполнимость и однозначность
контракта и AC.
## Как проверялось
- Прочитаны `docs/SCOPE.md`, `AGENTS.md`, `PROCESS.md` (§1–§10), тело issue #447 и
все три комментария, ТЗ целиком.
- Прочитан код, который ТЗ описывает как текущее поведение и на который опирается
контракт: `src/furniture-wall-surface.ts`, `src/furniture-placement.ts`, фрагмент
`_keyHandler` вокруг `src/houseplan-card.ts:3018`, `_decorMoveUpdate`
(`src/houseplan-editor-runtime.ts:4336-4376`), `_gridPitch`/`_cellCm`
(`src/houseplan-card.ts:7336-7348`), константы `GRID_PITCH`/`GRID_STEP_N`/`NORM_W`
(`src/space-geometry.ts:12,212-216`), `segmentCm` (`src/logic.ts:63-66`),
`decorCmToUnits`/`decorUnitsToCm` (`src/editors/decor/geometry.ts:76-114`).
- Сверены утверждения ТЗ о текущем баге (`sideScore` как тай-брейк, а не фильтр) с
реальным кодом `snapFurnitureToWall` — подтверждены построчно.
- Прочитаны `docs/CANVAS.md` §9 (grid/snap contract), `docs/FURNITURE.md`,
`docs/USER-GUIDE.ru.md` (разделы «14. Редактор подложки», термин «Декор»,
таблица инструмента «Выбрать») на предмет терминологии и уже
зафиксированного поведения.
- Сверена связь с #41 — прочитан текст issue и `docs/specs/041-keyboard-object-editing.md`
(`origin/dev`): формулировка «arrows move = один grid node» в #41 не называет
конкретную единицу измерения, так что #447 не наследует оттуда готовое решение
предмета находки ниже, это самостоятельная неточность #447.
- Проверено существование файлов, на которые ссылается план тестирования и карта
реализации: `docs/FURNITURE.md`, `docs/DECOR-EDITOR.md`, `demo/smoke_furniture.mjs`,
`demo/smoke_decor*.mjs` — все существуют.
- Проверены `DecorKind`/`DecorShape` (`src/editors/decor/types.ts:8-88`) — ровно
шесть видов `line | rect | ellipse | text | furniture | image`, как и в ТЗ.
### Гейты
Диапазон изменений на этом SHA — docs-only (класс C по AGENTS.md: только
`docs/specs/**` и `docs/specs/README.md`), `src/**` не тронут. Условия,
включающие `typecheck`/`test`/`build`/`check-docs`/`model-invariants`/смоки
(«если менялся `src/**`», «если менялась геометрия»), не выполнены — ни один из
них не применим к этому диффу, гонять их бессмысленно: они бы просто
подтвердили, что `dev`-код (не тронутый) уже проходит эти гейты, и ничего не
скажут о качестве самого ТЗ. Явно не прогонялись: `npx tsc --noEmit`, `npm test`,
`npm run build`, `node scripts/check-docs.mjs`, `node scripts/model-invariants.mjs`,
любые `demo/smoke_*.mjs`, `npm run golden:verify`, `pytest tests_backend` — все по
причине «diff их не касается», не «пропущено».
## Находки
### H1 — AC4 и §3 путают единицы «шага сетки»: render-space `gridPitch` vs normalised `GRID_STEP_N`, что при буквальной реализации ломает саму суть Part 2
**Файл:** `docs/specs/447-exterior-furniture-snap-keyboard-nudge.md`, разделы
«3. Сдвиг выбранного декора стрелками» (таблица «Дельта render-координат») и
«AC4. Каждый вид выбранного декора двигается на одну клетку», плюс комментарий
владельца от 04.09.2026 («В коде это ровно один `gridPitch`, и никакого деления
на `cell_cm` не нужно»).
**В чём проблема.** ТЗ и комментарий владельца называют величину шага буквальным
именем кодовой константы `gridPitch` и требуют применять её к «позиционным
полям» decor-объекта (`x`, `y`, `x1`, `y1`, `x2`, `y2` — то, что реально лежит в
`space.decor[]`). Но в текущем коде это два **разных** масштаба, и это не
случайность, а осознанная граница, задокументированная в самом коде:
- `GRID_PITCH = NORM_W / GRID_N = 1000 / 240 ≈ 4.1667` — шаг сетки в
**render-единицах** (`src/space-geometry.ts:212-214`, geometry в масштабе
холста 0…1000). Используется там, где считают на уровне пикселей/SVG:
`space-render.ts`, `snapR()`, `_gridPitch` getter в `houseplan-card.ts:7336-7342`
(«NORM_W / GRID_N и ничего больше»).
- `GRID_STEP_N = 1 / GRID_N ≈ 0.0041667` — тот же шаг в **нормализованных
единицах** (`src/space-geometry.ts:216`, комментарий «what the config and the
layout store»). Именно в этом масштабе хранятся `x`/`y` room-полигонов, стен,
ключей и — что здесь решает — decor: `houseplan-card.ts:9061` прямым текстом
«Keys are always stored in normalised space (`GRID_STEP_N`)».
Что фактически хранится в `space.decor[].x/y` подтверждается кодом уже
существующего mouse-drag: `_decorMoveUpdate`
(`src/houseplan-editor-runtime.ts:4336-4376`) считает якорь в render-единицах, а
затем **делит на `NORM_W`** (для x) или на `this.host._decorH` (для y), прежде
чем записать `x: o.x + dx` — то есть `dx`, попадающий в конфиг, на три порядка
меньше `GRID_PITCH` (`dx = (anchor[0] - ax0) / NORM_W`, `NORM_W = 1000`,
`src/space-geometry.ts:12`).
Спецификация же формулирует AC4 буквально как «меняют… позиционные поля на
`gridPitch`» без единого слова о делении на `NORM_W`/`_decorH` или о разнице
между координатой в момент пересчёта (render) и координатой на диске
(normalised). Комментарий владельца ещё и explicit отрицает необходимость
какого-либо деления («никакого деления на `cell_cm` не нужно») — верно для
`cell_cm`, но по факту скрывает необходимое деление на `NORM_W`, о котором ни в
ТЗ, ни в комментарии не сказано ни слова.
**Почему это блокирует, а не техническая мелочь на усмотрение реализации.**
Раздел «Принятые предположения» разрешает менять «техническую раскладку pure
helper», но не сам числовой контракт — а именно числовой контракт здесь и
сформулирован неоднозначно/неверно в AC. Если разработчик реализует AC4 так, как
она написана — прибавит экспортированную `GRID_PITCH` (≈4.1667) напрямую к
нормализованному `x` (обычно в диапазоне порядка 0…2–3 для видимого плана) — то
одно нажатие стрелки сдвинет объект на ~4 ширины холста. Это даже не упрётся в
`clampCanvasN`/`CANVAS_LIMIT = 5000` (`src/space-geometry.ts:205,240-241`) —
предмет просто окажется далеко за пределами видимой области без единой ошибки в
консоли, то есть эффект прямо противоположен заявленному «сдвиг ровно на одну
клетку сетки, а не число, которое надо помнить». Хуже: если модульный тест по
AC4 будет (как и написано в тексте AC) утверждать «поле изменилось на
`GRID_PITCH`», такой тест зафиксирует именно неверную величину и молча пройдёт —
ровно тот сценарий, о котором предупреждает сам процесс ревью («тест,
спрашивавший то, чего не проверял», #423) и принцип «одно число — один
источник»: видимая величина шага (то, что рисует сетку на экране, в
render-масштабе) и сохранённая величина (то, что пишется в конфиг, в
normalised-масштабе) обязаны быть одним и тем же физическим шагом, посчитанным
через одно преобразование, а не два независимых числа с одинаковым именем в
тексте ТЗ.
**Как воспроизвести рассуждение.** Взять любой существующий decor-объект,
например `x = 0.4` (типичное значение для видимого плана). Применить
буквально AC4: `x -= gridPitch` → `x = 0.4 - 4.1667 = -3.7667`. Сравнить с
ожидаемым «на одну клетку сетки» — при `cell_cm = 5` перемещение на одну клетку
означает сдвиг на 5 см реального плана, что в normalised-единицах равно
`GRID_STEP_N ≈ 0.0041667`, а не `4.1667`. Разница — ровно `NORM_W` (1000×).
**Что нужно исправить.** Явно указать в контракте (§3 и AC4), что дельта
считается в render-координатах (как и озаглавлена таблица), а на «позиционные
поля» decor записывается **после** того же преобразования, которым уже
пользуется `_decorMoveUpdate` — деление на `NORM_W` (для x/x1/x2) и на
высоту холста по Y (для y/y1/y2), либо прямо назвать нормализованный шаг
`GRID_STEP_N` как то, что фактически прибавляется к `x`/`y`. Это техническое по
природе уточнение (никакая видимая пользователю величина не меняется — «одна
клетка» остаётся «одной клеткой» в сантиметрах), поэтому не требует вопроса
владельцу и решается прямо в тексте ТЗ.
**Категория:** correctness / однозначность AC. **Серьёзность: High** — как
написано, AC4 не исполним корректно; часть 2 задачи (клавиатурная доводка)
буквально по тексту ломается на первом же нажатии стрелки.
Других High или Medium в скоупе задачи находок нет.
## Что проверено и корректно
- **Диагноз части 1 подтверждён построчно.** `roomFurnitureWallSurfaces`
(`src/furniture-wall-surface.ts:41-82`) действительно строит только одну
грань на атом — `axis + inwardNormal * half` — и не создаёт наружного
кандидата; `normal` явно документирован как «points from masonry into the
owning room» (строка 14). В `snapFurnitureToWall`
(`src/furniture-placement.ts:148-153`) `sideScore` участвует в выборе только
при `Math.abs(candidate.dist - best.dist) <= tieEps`, первичный критерий —
`candidate.dist < best.dist - tieEps`; при единственном кандидате в радиусе
сторона не проверяется вовсе. Оба факта, на которых стоит вся первая часть
ТЗ, воспроизведены чтением, а не с чужих слов.
- **Контракт части 1 закрывает найденный баг по существу, а не косметически.**
Требование «предмет не переносится через ось на сторону, противоположную
точке намерения» — это явный перевод `sideScore` из тай-брейка в фильтр (один
из двух вариантов, которые сама issue считает приемлемыми), а не просто
добавление наружного кандидата поверх старой логики отбора по `dist` — то
есть ТЗ не оставляет прежний баг между старой внутренней и новой внешней
поверхностью.
- **Инварианты общей/нулевой/independent стены названы явно и с AC/mutation**
(AC2), включая нечувствительность к порядку комнат и winding — учтён риск,
под который заведён отдельный пункт в «Рисках».
- **Виды decor согласованы с типами.** `DecorKind` (`src/editors/decor/types.ts:10`)
— ровно `line | text | rect | ellipse | furniture | image`; ТЗ перечисляет
все шесть и не добавляет несуществующих.
- **Guard-условия Arrow (§3, AC8) переиспользуют уже существующий паттерн.**
Текущий `_keyHandler` (`src/houseplan-card.ts:2978-3060`) уже отличает
`inField` (composedPath по `input, textarea, select, [contenteditable]`) и
`inEditorSecondary` (класс `editor-secondary`) для Delete/Backspace в этом же
режиме — ТЗ требует того же для Arrow, что не вводит новый непроверенный
guard-паттерн.
- **Раздельные пути истории названы верно.** `history.decor_move`
(`src/i18n/ru.json:124`, использование — `houseplan-card.ts:7018`) —
существующий ключ, переиспользуемый и mouse-drag, и клавиатурой; новых
i18n-строк ТЗ не требует.
- **Обязательные разделы §7.1 все присутствуют**: сценарий, что человек увидит
до/после, проблема, скоуп/не-скоуп, контракт поведения, UX, данные/миграция/i18n,
производительность, крайние случаи, AC1…AC9 с методом доказательства, план
автотестов, риски, откат, release-артефакты, принятые предположения. Каждый
AC называет способ доказательства (`unit`/`smoke`/`review`).
- **Трек и лимит согласованы.** Full track обоснован (сложность/риск >3, два
независимых поведенческих контракта, новый клавиатурный UX-контракт) —
критерии `small` (PROCESS.md §5) действительно не выполнены, названо явно в
аналитике; лимит ревью ТЗ для полного трека — 4 цикла, что соответствует
«блокирующих циклов 0/4» на входе.
- **Терминология сверена с USER-GUIDE.ru.md.** «Редактор подложки» — верное имя
всего режима (раздел 14, таблица «Декор | Элемент подложки»), не только
картинки плана; путаницы между «Редактор подложки» и «декор» в ТЗ нет.
- **Rollback и release-артефакты адекватны масштабу**: чистый revert
frontend/tests/docs/bundle, нет схемы/миграции для отката; changelog RU+EN,
`docs/FURNITURE.md`, `docs/DECOR-EDITOR.md`, screenshot fingerprint через
`src/**` — корректный список для класса A правки такого объёма.
## Чего не проверял
- Не прогонялись `typecheck`/`test`/`build`/`check-docs`/`model-invariants`/смоки
— диапазон изменений на этом SHA docs-only и их не касается (см. «Гейты»
выше); они станут релевантны на код-ревью, когда появится диапазон
`origin/dev..HEAD` с изменениями в `src/**`.
- Не проверял, что owner-группировка атома («ровно одной комнате») реализуема
за один проход без учёта направления рёбер и winding — это заявлено как
требование к реализации (AC1/AC2 + permutation/winding tests), а не как
готовый алгоритм в самом ТЗ, поэтому это предмет код-ревью, а не спек-ревью.
- Не проверял `docs/DECOR-EDITOR.md` и `docs/FURNITURE.md` построчно на предмет
того, что именно там придётся дописать — только подтвердил, что оба файла
существуют и являются подходящим местом для новых абзацев (release-артефакты
ТЗ их и называют).
- Не оценивал производительность фактическим профилированием — раздел
«Производительность» ТЗ содержит качественное, проверяемое утверждение (линейный
рост числа кандидатов, отсутствие новых imports в View graph), количественная
проверка (`bundle:budget`) относится к код-ревью.
## Вердикт
ТЗ полное, обязательные разделы на месте, диагноз части 1 подтверждён чтением
кода, риски и инварианты части 1 закрыты явно. Но AC4 (и опирающийся на неё
раздел 3) содержит невыполнимое буквально требование из-за смешения
render-масштаба (`gridPitch` ≈4.1667) и normalised-масштаба (`GRID_STEP_N`
≈0.0041667) constant, в котором фактически хранятся decor-координаты — при
реализации «как написано» вторая часть задачи (клавиатурная доводка) ломается
на первом нажатии стрелки, и это ровно то расхождение, которое несколько раз
уже стоило продукту дефекта («одно число — один источник»). Находка технической
природы, снимается автором правкой текста ТЗ без обращения к владельцу.
**Вердикт: красный · заход r1 · блокирующих циклов 1/4 · High: 1 · Medium: 0 →
в задаче**
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/447-exterior-snap-keyboard`, коммит `a1fbea07d287` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `dd1a85b41c1caba79488df331dd813f5b7522dd0`
```
git log --all --format='%H %T' | grep dd1a85b41c1c
```
- ТЗ `docs/specs/447-exterior-furniture-snap-keyboard-nudge.md`, блоб `5003f5995be9dd06e296a8c0056d63c1d21a8fc6`
```
git log --all --find-object=5003f5995be9dd06e296a8c0056d63c1d21a8fc6 -- docs/specs/447-exterior-furniture-snap-keyboard-nudge.md
```