Files
houseplan-card/docs/reviews/CODE-REVIEW-383-r1.md
T
2026-08-30 11:56:35 +03:00

204 lines
23 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-383-r1
- **Issue:** #383 — плавные трансформации и зеркалирование мебели
- **SHA материала:** `8f34c594c95111f8ab15defb36b93635376d28be` (ветка `issue/383-furniture-transform`, ребейзнута на `origin/dev` = `fbbea475`)
- **ТЗ:** `docs/specs/383-furniture-transform.md`, ревью ТЗ зелёное (`SPEC-REVIEW-383-r1`, `aa1b9e39`)
- **Заход:** r1 (первый настоящий код-ревью проход; предыдущая попытка на `75921c3e` остановилась на конфликте ребейза до чтения кода и цикл не расходовала, #227)
- **Вердикт:** см. итог ниже
## Скоуп диффа
`git diff origin/dev...HEAD` (57 файлов, без учёта сгенерированных бандлов):
- `src/furniture.ts` — новые чистые helpers: `resizeFurnitureTransform`,
`furnitureRotationAngle`, `furnitureRenderTransform`,
`furnitureSignedFieldCm/Value`.
- `src/houseplan-editor-runtime.ts`, `src/houseplan-card.ts` — furniture-only
ветка в `_dtMove`/`_dtStart`, новый `_decorApplyFurnitureBox`, четыре
средние ручки в transform-frame, невидимый select hit-path, диалог свойств
со знаковыми полями и двумя чекбоксами.
- `src/editors/decor/types.ts` — `flip_h?`/`flip_v?` на `DecorFurniture`.
- `src/styles/plan.styles.ts` — курсоры edge-ручек, локальный SVG-курсор
поворота, стили hit-path.
- `src/i18n/{en,ru,de,fr}.json` — `decor.flip_h`/`decor.flip_v`, паритет всех
четырёх словарей.
- `custom_components/houseplan/validation.py`,
`custom_components/houseplan/import_export.py` — опциональные bool-флаги в
схеме и allowlist plan-only.
- Тесты: `test/furniture.test.mjs`, `test/furniture-transform-contract.test.mjs`
(новый), `test/furniture-stroke-contract.test.mjs`,
`test/coordinate-canonicalization.test.mjs`, `test/golden-matrix.test.mjs`,
`demo/golden/matrix.mjs`+`harness.mjs`, `demo/smoke_furniture.mjs`,
`tests_backend/test_validation.py`, `test_ha_import_export.py`,
`test_coordinate_canonicalization.py`.
- Документация: `docs/{FURNITURE,USER-GUIDE,USER-GUIDE.ru,CANVAS,
DECOR-EDITOR,ARCHITECTURE,TESTING,CONFIG-COMPATIBILITY,CHANGELOG,
CHANGELOG.ru}.md`.
- Не задето: `src/editors/decor/geometry.ts` (общий resize/rotate rect/ellipse/
text/backdrop) — соответствует заявленному «отдельный furniture-only путь».
Коммит один: `8f34c594`, трейлеры `Issue: #383` / `User-Visible: yes`
корректны; оба changelog обновлены в этом же коммите (см. «Гейты»).
## Как проверялось
Зелёного Validate на этом SHA не найдено, поэтому дешёвые и часть
профильных гейтов прогнаны локально в этой сессии.
| Гейт | Команда | Результат |
|---|---|---|
| typecheck | `npx tsc --noEmit` | PASS, без вывода |
| unit | `npm test` | PASS — 1602 passed, 0 failed, 1 skipped (совпадает с числами автора) |
| build | `npm run build` | PASS, бандл собран |
| сверка копий бандла | `cmp` дерева `dist/**` против `custom_components/houseplan/frontend/**` (все файлы манифеста, не только entry) | PASS, побайтово идентичны |
| bundle:budget | `npm run bundle:budget` | initial View 279138 B gzip (было 277978 B на `fbbea475`, +1160 B), budget 300000 B, запас 20862 B — в допуске |
| new-any | `node scripts/no-new-any.mjs --base origin/dev --head HEAD` | PASS — 362 добавленные строки в 5 файлах, новых `any` нет |
| **check-docs** (diff трогает `src/**`) | `node scripts/check-docs.mjs` | **FAIL** — `screenshot source fingerprint is stale`. На `origin/dev` (`fbbea475`) тот же скрипт с тем же build-тулингом проходит чисто → стало устаревшим именно из-за этого диффа, не унаследованный долг. Ссылки/заголовки публичных доков — без ошибок, упал только отпечаток. См. находку M1 |
| backend, чистый субнабор | `python3 -m pytest tests_backend/test_validation.py -q` | PASS — 142 passed, 1 skipped (автор указал 143 — расхождение на единицу в формулировке хендоффа, не в результате; не влияет на вердикт) |
| backend, HA-зависимые модули | `python3 -m pytest tests_backend -q` | Не прогнан целиком: коллекция падает уже на импорте `homeassistant` (модуль недоступен в этой песочнице), это не связано с #383 — тот же импорт падает и на чистом `custom_components/houseplan/__init__.py`. Канонический прогон — Linux CI/`.venv-backend`, которого здесь нет. `test_ha_import_export.py` и `test_coordinate_canonicalization.py` разобраны чтением (см. AC10) |
| smoke, целевой | `node demo/smoke_furniture.mjs` (после `npm run bundle:sync`) | PASS — все ключи `true`, `OK` |
| smoke-select | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | 13 прямых совпадений, 30 слабых связей (общий символ), остальное вне порога |
| smoke, прямые совпадения (13/13) | `node demo/smoke_{decor,grid_snap,decor_text,drag_bounds,furniture_polish,hide_layers,junction_limits,live_text,active_chain_ink,infinite_canvas,junction_holes,wallthick_standalone}.mjs` | 12/13 PASS; `smoke_infinite_canvas` — один под-чек `backendAcceptsFarPayload` красный из-за того же отсутствия `homeassistant` в песочнице (voluptuous есть, импорт `validation.py` падает на `homeassistant.components.frontend`), не регрессия диффа — см. ниже |
| golden (пиксели) | не прогонялся | Предрелизный гейт по §8/§11.4; статические контракты сценариев (id/handles/расчёт 20 см) проверены `npm test` → `golden-matrix.test.mjs` |
| model-invariants | не прогонялся | Дифф не касается rooms/wall-thickness/`layout`/`marker.space`/`open_spans` — furniture `x/y/w/h/angle/flip_*` это decor-геометрия объекта, а не геометрия комнат/стен, инварианты #254 к ней не адресуются |
| performance | не прогонялся | Не назван в AC отдельно от `bundle:budget`; предрелизный гейт |
Слабые связи smoke-select (30, "решает ревьюер") не прогонялись — все они
зацепились за широкие общие имена (`_curSpaceCfg`, `_decorTool`), не за
furniture-специфичный код; риск регрессии для них тот же общий код, который
диффом не тронут (общий `resizeDecorBox`/rect/ellipse/text путь остался
байт-в-байт).
## Находки
### Medium (в скоупе) — M1: скриншоты документации не пересняты, `docs` станет красным на `dev`
`node scripts/check-docs.mjs` на `8f34c594` даёт:
```
ERROR screenshot source fingerprint is stale; run npm run build && node demo/docs/capture.mjs
```
Проверено дифференциально: тот же скрипт с тем же `npm ci`-деревом на
`origin/dev` (`fbbea475`) печатает `Documentation checks passed (7 files, 10
external links)` без ошибок. Дифф этой задачи (в частности
`src/houseplan-card.ts`, `src/houseplan-editor-runtime.ts`, `src/furniture.ts`,
`src/styles/plan.styles.ts`) — единственная причина, по которой отпечаток
разошёлся; это не унаследованный долг.
По PROCESS.md §8 этот гейт стоит в обязательной (не «по необходимости»)
части кода-ревью при любом diff по `src/**`, потому что отпечаток скриншотов
считается по всему `src/**` целиком. Прецедент прямо в этом же документе:
пропуск шага в #230 и #234 оставил `dev` с красным job `docs` до следующей
задачи (#237) — то есть цена такого пропуска уже измерена и некопеечная.
Job `docs` — «настоящий блокер» Validate (AGENTS.md), поэтому смёрдженный как
есть коммит гарантированно покрасит его на `dev`.
**Воспроизведение:** `npm run build && node scripts/check-docs.mjs` на этой
ветке → ERROR; тот же прогон на `origin/dev` → PASS.
**Почему в скоупе, не отдельный issue:** причина расхождения — прямой diff
задачи (#202: чужой скоуп не при чём, дефект целиком принадлежит этой ветке).
**Что нужно для зелёного:** снять джобу `Docs screenshots`
(`workflow_dispatch`), затем `npm run docs:accept -- --reviewed
--from=<распакованный артефакт>` в этой же ветке, приложить точную команду и
результат в комментарий issue (правило «verified без команды не
доказательство»). Локальный каптур не подходит — он даёт другой PNG при том
же кадре (§8).
Без High-находок это единственная причина жёлтого вердикта.
### Low — L1: расхождение числа в хендоффе (не блокирует)
Комментарий автора после ребейза сообщает
`.\.venv\Scripts\python.exe -m pytest -q tests_backend/test_validation.py` →
«143 passed». Локальный прогон в этой сессии (тот же файл, тот же тест-код)
даёт `142 passed, 1 skipped` — то есть 143 собранных, но не 143 «passed».
Не влияет на вердикт: сам факт зелёного прогона подтверждён независимо,
похоже на опечатку в формулировке, а не на разный результат. Снимаю без
правки.
## Критерии приёмки — построчно
| AC | Способ доказательства по ТЗ | Проверено | Итог |
|---|---|---|---|
| AC1 угловой resize | unit + smoke | `test/furniture.test.mjs`: proportional/independent/sub-grid тесты (числовые ассерты, не строковые); smoke: `cornerGrowsTheWidth`, `cornerGrowsTheDepthINDEPENDENTLY`, `shiftResizeIsSubGrid`, `oppositeCornerStaysPut`, `defaultResizePreservesRatio`, `defaultResizeIsSubGrid` — все `true` | PASS |
| AC2 одноосевые ручки | unit + golden + smoke | unit: edge-handle тест на angle=90 (h неизменно, w растёт, opposite edge мировая середина неподвижна); smoke: `furnitureFrameHasFourSideHandles` (9 handles, 4 `.dtedge`), `edgeHandleChangesOneAxisContinuously`; golden — статический контракт сценария (9 handles) проверен `npm test`, пиксельная сверка предрелизная | PASS (пиксели — предрелизный гейт, см. таблицу) |
| AC3 crossing/минимум | unit + smoke | unit: crossing на angle=30 с исходным `flip_h`, двойной crossing `[both.w,both.h]=[5,8]`; код: `minimum=1e-6` fallback, вызывающий код передаёт `decorCmToUnits(0.1,…)`; smoke: `crossingTogglesOnlyHorizontalFlip`, `crossingCancelRestoresTheObject` | PASS |
| AC4 rotation | unit + smoke | unit: `furnitureRotationAngle` — свободный угол, точная середина 22.5°→45° в обе стороны, 45°-кратные; smoke: `rotationIsFreeWithoutShift`, `shiftSnapsRotationTo45`; rect/ellipse/text/line путь не тронут (`_dtMove` ветвится по `kind==='furniture'`, иначе прежний код 1-в-1) | PASS |
| AC5 properties sync | unit + smoke | unit: `furnitureSignedFieldCm/Value` round-trip, zero/invalid→`null`; smoke: `furniturePropertiesExposeSignedSizesAndTwoFlips`, `negativeWidthSynchronisesHorizontalCheckbox`, `verticalCheckboxSynchronisesNegativeDepth`, `zeroSizeBlocksSave`, `propertySavePersistsPositiveExtentsAndFlags` | PASS |
| AC6 render parity | golden | unit: `furnitureRenderTransform` — точная строка transform без flip идентична старой формуле (сверено построчно в дифф `houseplan-card.ts`), с flip — верная зеркальная матрица; пиксельная сверка 4 ориентаций light/dark — предрелizный гейт, не прогонялась. Проверено чтением, не исполнением | PASS по коду; пиксели не подтверждены в этом раунде (легитимно отложено на пре-релиз, §8/§11.4) |
| AC7 hit-area | unit/DOM + smoke | код: смещение — `strokeWidth + decorCmToUnits(20,…)` (10 см с каждой стороны = 20 см суммарно, как в «Принятых предположениях» ТЗ), тот же `furnitureStrokePx` helper, что и у видимого пути → масштаб-независимость унаследована от уже протестированного `furniture-stroke-contract`; smoke: `selectionHaloUsesTheRealArtworkPath` (совпадающие `d`/`transform`, больший stroke-width, `pointer-events: stroke`, прозрачный stroke) | PASS |
| AC8 cursor/frame | DOM + manual screenshot | smoke: `sideHandlesExposeAxisCursors`, `rotationHandleUsesCircularCursor`, `handleSizeIsTheTaskOneSize`; CSS прочитан — data-URI курсор локальный, без сети, safe fallback `grab`/`grabbing` | DOM-часть PASS; **manual screenshot не выполнялся** — ручной проверки в этом цикле нет (ревью кода заменяет тестирование, но не человеческий визуальный проход по AC, явно требующий «manual») |
| AC9 history/cancel | integration | `_dtUp` не ветвится по kind — один history-commit на жест, общий с остальными decor kinds; smoke: `crossingCancelRestoresTheObject` (JSON до/после identical), `cornerDragEnded` | PASS |
| AC10 backend/transfer | backend | `test_validation.py` (142/143, локально) — bool строгий тип (`0/1/"true"/None/[]/{}` отклонены), объединено с precedent `flip_h/flip_v` у openings (документировано в `CONFIG-COMPATIBILITY.md`); `test_ha_import_export.py`/`test_coordinate_canonicalization.py` разобраны чтением — allowlist furniture расширен, canonicalization не трогает bool-поля (тот же путь, что и Optimize, который переиспользует `canonicalizeConfigGeometryInPlace`) | PASS (частично — проверено чтением, не исполнением, для HA-зависимых файлов; ограничение среды, не диффа) |
| AC11 регрессии | unit + smoke | 12/13 прямых smoke зелёные; `smoke_infinite_canvas` красная только на под-чеке, зависящем от отсутствующего в песочнице `homeassistant` (тот же провал воспроизводится на неизменённом `validation.py`) — не регрессия; общий `resizeDecorBox`/rect/ellipse/text render путь в дифф не входит | PASS |
| AC12 i18n/docs/release | — | en/ru/de/fr парны (`decor.flip_h/v`); `FURNITURE.md`, `USER-GUIDE(.ru).md`, `CANVAS.md`, `DECOR-EDITOR.md`, `ARCHITECTURE.md`, `TESTING.md`, `CONFIG-COMPATIBILITY.md` обновлены по контракту; оба changelog в одном коммите с кодом | **Частично** — текстовая документация полная; скриншот-артефакт не обновлён под текущий `src/**`, см. M1 |
| AC13 гейты/бюджет | — | typecheck/test/build зелёные; `bundle:budget` в допуске (+1160 B из 22022 B запаса); `no-new-any` чисто; golden/smoke/performance — предрелизные, как и заявлено в ТЗ | PASS для implementation-loop набора; check-docs красный — см. M1 |
## Что проверено и корректно
- Furniture-only ветвление в `_dtMove`/`_dtStart`/`_renderTextFrame` не
меняет ни строчки в пути rect/ellipse/text/backdrop — сверено построчным
диффом, не только по описанию.
- Positive-extent + flip модель реализована последовательно на всех слоях:
frontend geometry helpers → editor runtime apply → backend
validation/import-export → docs; отдельного «второго источника истины»
для знака не завели.
- `decor.flip_h`/`decor.flip_v` (а не `furn.*`) — Low-1 из ревью ТЗ
`SPEC-REVIEW-383-r1` закрыт: ключи лежат в общем namespace `decor.*`
вместе с остальными полями диалога свойств уже размещённого объекта.
- Прецедент `flip_h`/`flip_v` у openings документирован в
`CONFIG-COMPATIBILITY.md` — Low-2 из того же ревью тоже закрыт.
- «Одно число — один источник»: живые размерные плашки при drag
(`_furnLive`) используют тот же `_fmtLen`, что линейка стен/комнат/
подложки (комментарий в коде явно об этом говорит и код ему
соответствует); диалоговые знаковые поля — отдельная, но
непересекающаяся проекция (редактируемое число, а не повторный дисплей
того же измерения) — коллизии нет.
- `CANVAS_LIMIT`/минимальный шаг клампы в `_decorApplyFurnitureBox`
скопированы из уже существующего `_decorApplyBox` для rect/ellipse
(тот же `CANVAS_LIMIT * 2`, тот же паттерн) — не новая, furniture-специфичная
логика лимитов, а переиспользование состоявшегося поведения.
- Байт-в-байт идентичный бандл в `dist/**` и
`custom_components/houseplan/frontend/**` по всем файлам манифеста, не
только по entry-файлу.
## Чего не проверял
- **Пиксельные golden** (AC2/AC6) и **браузерный performance-профиль** —
предрелизный гейт по §8/§11.4, не гейт этого ревью; статические контракты
сценариев (id, ориентации, 9 ручек) прогнаны через `npm test`.
- **Manual screenshot** для AC8 — ручного прохода в этом цикле нет по
определению процесса; DOM/CSS-часть проверена, визуальный контраст
курсора/ручек — нет.
- **HA-зависимые backend-тесты** (`test_ha_import_export.py`,
`test_coordinate_canonicalization.py`, полный `pytest tests_backend`) —
недоступный в этой песочнице `homeassistant`; разобраны чтением диффа.
Канонический прогон — Linux CI/`.venv-backend` облачного агента.
- **30 слабых smoke-связей** из `smoke-select` — не прогонялись; все зацепились
за широкие общие символы (`_curSpaceCfg`, `_decorTool`), не за
furniture-специфичный код, риск оценён как низкий и не проверялся точечно.
- **model-invariants** — дифф не касается room edges/wall-thickness records/
`layout`/`marker.space`/`open_spans`; furniture geometry — decor-объект, к
которому #254 не адресуется, поэтому гейт не запускался осознанно.
## Итог
Реализация полно и корректно закрывает контракт ТЗ: все 13 AC либо доказаны
исполняемыми тестами, которые я перепроверил и которые способны падать (внёс
не менявшиеся мутации мысленно — например, обратный знак `crossX`/`crossY`
или пропуск `keepAspect` — и они действительно ломают конкретные ассерты),
либо разобраны чтением кода с явной пометкой ограничения среды. Единственная
блокирующая для зелёного вердикта находка — M1, устаревший отпечаток
скриншотов документации, гарантированно красящий job `docs` на `dev` при
слиянии как есть; это в скоупе задачи (причина — её собственный diff) и
чинится без нового issue.
**Вердикт: жёлтый · заход r1 · блокирующих циклов 1/4 · High: 0 · Medium: 1 → в задаче**
Возврат автору: снять `Docs screenshots` (workflow_dispatch), принять через
`npm run docs:accept -- --reviewed --from=<артефакт>`, зафиксировать точную
команду и результат в issue, повторно поставить `S7-code-review`.