docs: review document for #447

Issue: #447
User-Visible: no
This commit is contained in:
claude[bot]
2026-09-04 05:39:54 +00:00
parent a1fbea07d2
commit 6c668d4bfe
+253
View File
@@ -0,0 +1,253 @@
# 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
```