# CODE-REVIEW-383-r2 - **Issue:** #383 — плавные трансформации и зеркалирование мебели - **SHA материала:** `b1b1e7439b5e0e20bfa47c316065c814ec534070` (ветка `issue/383-furniture-transform`, ребейзнута на `origin/dev` = `95115de2b...` (`docs: review document for #385`)) - **ТЗ:** `docs/specs/383-furniture-transform.md`, ревью ТЗ зелёное (`SPEC-REVIEW-383-r1`, `aa1b9e39`) - **Заход:** r2 · блокирующих циклов израсходовано 1/4 - **Вердикт:** зелёный (см. итог) ## Почему разбор в этом заходе ПОЛНЫЙ, а не только по дельте M1 Формально между r1 и r2 в задаче случилось два события: 1. Автор закрыл M1 (устаревший отпечаток скриншотов) отдельным коммитом `b1b1e743` — это чистая, локальная дельта (только `docs/images/**`). 2. Но перед этим ветка была **повторно ребейзнута** на ушедший вперёд `dev`: r1 проверял `8f34c594` (база `fbbea475`), а текущая ветка стоит на `95115de2` — между базами легли `fbbea475→…→95115de2`, в том числе два реальных fix-коммита по #385 (`223f499e`, `e561e5fc`), трогающие `src/houseplan-editor-runtime.ts` и `custom_components/houseplan/import_export.py` — те же файлы, что и #383. Инструкция прямо называет это одним из случаев, где сокращать анализ до дельты нельзя: «ребейз на ушедший вперёд dev — после ребейза это другой код». Поэтому я не ограничился строкой «M1 закрыт» и таблицей соответствий, а заново прочитал весь диф #383 к `origin/dev` и заново прогнал гейты на текущем SHA. Ниже — это полный разбор; таблицы «Закрытие r1» и «Унаследовано из r1» идут отдельными разделами в конце, как требует формат. ## Скоуп диффа (`git diff origin/dev...HEAD`, без сгенерированных бандлов) Список файлов не изменился относительно r1 (я сверил построчно): один логический коммит с продуктовым кодом (`686557c5`, после ребейза), плюс докс/ревью-коммиты и коммит `b1b1e743` (только `docs/images/**` + `docs/images/screenshots.json`). - `src/furniture.ts` — `resizeFurnitureTransform`, `furnitureRotationAngle`, `furnitureRenderTransform`, `furnitureSignedFieldCm/Value` (прочитано целиком заново в этом раунде). - `src/houseplan-editor-runtime.ts`, `src/houseplan-card.ts` — furniture-ветка в `_dtMove`, новый `_decorApplyFurnitureBox`, четыре средние ручки, невидимый select hit-path, диалог свойств. - `src/editors/decor/types.ts` — `flip_h?`/`flip_v?`. - `src/styles/plan.styles.ts` — курсоры, hit-path. - `src/i18n/{en,ru,de,fr}.json`. - `custom_components/houseplan/validation.py`, `import_export.py`. - Тесты: `test/furniture.test.mjs`, `furniture-transform-contract.test.mjs`, `furniture-stroke-contract.test.mjs`, `coordinate-canonicalization.test.mjs`, `golden-matrix.test.mjs`, `demo/golden/{matrix,harness}.mjs`, `demo/smoke_furniture.mjs`, `tests_backend/{test_validation, test_ha_import_export,test_coordinate_canonicalization}.py`. - Документация: `docs/{FURNITURE,USER-GUIDE,USER-GUIDE.ru,CANVAS, DECOR-EDITOR,ARCHITECTURE,TESTING,CONFIG-COMPATIBILITY,CHANGELOG, CHANGELOG.ru}.md`, плюс `docs/images/**` (M1). - Не задето: `src/editors/decor/geometry.ts` — подтверждено повторно. **Проверка на взаимодействие с рёбейзнутыми коммитами.** Прочитал оба привнесённых `dev`-коммита (`223f499e`, `e561e5fc`, оба про маркер-диалог #385) построчно: правки лежат внутри `_renderMarkerDialog` (радио/кандидаты привязки, `value_badge`/`value_source`), furniture-код — в `_dtMove`, `_decorApplyFurnitureBox`, диалоге свойств decor и `_DECOR_KIND_FIELDS`/ `DECOR_SCHEMA`. Пересечения по функциям и по строкам нет; ребейз прошёл без конфликтов в исходниках (конфликтовали только сгенерированный бандл и changelog — подтверждено автором и это видно по `git log`: коммит `0d5d2896` — публикация ревью r1 — стоит поверх `686557c5`, то есть исходники не переписывались вручную). Риск «новая подсистема» снят. ## Как проверялось **Важная оговорка о CI-сигнале.** В контексте этого раунда было заявлено, что Validate на `b1b1e743` зелёный и поэтому `tsc`/`test`/`build` можно не перегонять. Я проверил это через `gh run view` и обнаружил, что заявление формально верно (`conclusion: success`), но вводит в заблуждение по существу: job'ы `Фронтенд`, `Golden`, `Смоки`, `Backend`, `Hassfest`, `HACS` в этом раннe — **`skipped`**, а не `success`. Причина механическая: `changes` job классифицирует файлы диапазоном `BEFORE_SHA..HEAD_SHA`, где `BEFORE_SHA` — это `0d5d2896` (предыдущий тип этой же ветки, коммит с текстом ревью r1), а не `origin/dev`. Пуш `b1b1e743` добавил только `docs/images/**`, поэтому `changes.outputs.frontend=false`, и `frontend`/`golden`/`smoke`/`backend` получили каскадный skip как зависимые от `frontend`/`changes` job'ы (в GitHub Actions `needs`-зависимость от skipped-job делает зависимую job тоже skipped). Реально прогнали и **успешно завершили** свежий фронтенд-набор только для `8f34c594` (прошлый SHA, run `33301868911`, `Фронтенд: success`, но `conclusion: failure` из-за `docs`+`golden`), а его исходники, по пункту выше, идентичны текущим `686557c5`. Единственный **реально свежий** гейт на `b1b1e743` — `preflight` (docs+provenance+process-gate) — тот самый шаг, который закрывает M1, и он зелёный. Итог: раз «зелёного Validate именно по нужным job'ам на этом SHA» нет, я прогнал дешёвые и целевые гейты сам — это ровно предписанный процессом fallback, не сверх него. | Гейт | Команда | Результат | |---|---|---| | typecheck | `npx tsc --noEmit` | PASS, без вывода (5.9s) | | unit | `npm test` | PASS — 1604 passed, 0 failed, 1 skipped (совпадает с хендоффом автора) | | build | `npm run build` | PASS; `git status --porcelain` пуст после сборки — комитнутый `dist/**` уже побайтово актуален | | bundle:sync (3 копии) | `npm run bundle:sync` | PASS; `git status --porcelain` пуст после — `dist/`, `custom_components/houseplan/frontend/`, `demo/srv/assets` синхронны | | cmp entry | `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` | идентичны | | bundle:budget | `npm run bundle:budget` | initial View 279147 B gzip, budget 300000 B, запас 20853 B — в допуске (то же число, что видел r1: +1160 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` | PASS — `Documentation checks passed (7 files, 10 external links)`. Плюс независимое подтверждение через CI `preflight` на `b1b1e743` — реально исполнялся, не переиспользован | | backend, чистый субнабор | `python3 -m pytest tests_backend/test_validation.py -q` (после `pip install pytest voluptuous`, т.к. песочница голая) | PASS — 142 passed, 1 skipped | | backend, HA-зависимые модули | `python3 -c "import homeassistant"` → `ModuleNotFoundError` | Недоступны в этой песочнице; воспроизвёл, что тот же импорт падает и на неизменённом `custom_components/houseplan/__init__.py` (`from homeassistant.components.frontend import add_extra_js_url`) — ограничение среды, не диффа. `test_ha_import_export.py`/`test_coordinate_canonicalization.py` разобраны чтением (см. AC10 ниже) | | smoke, целевой | `node demo/smoke_furniture.mjs` (после `bundle:sync`) | PASS — все ключи `true`, `OK` | | smoke-select | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | 13 прямых совпадений, 30 слабых связей — идентично r1 (исходники не менялись) | | smoke, прямые совпадения (12 из 13 сверх `furniture`) | `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` | 11/12 PASS; `smoke_infinite_canvas` — один под-чек `backendAcceptsFarPayload` красный по той же причине отсутствия `homeassistant` (проверено: тот же провал воспроизводится на неизменённом `validation.py`) | | golden (пиксели) | не прогонялся | Предрелизный гейт по §8/§11.4, как и в r1; статические контракты сценариев (id, 9 handles, 4 `dtedge`) проверены `npm test` → `golden-matrix.test.mjs` (PASS, 44/44) и новым ассертом в `demo/golden/harness.mjs` (`decorSelection` → проверка 9/4 хендлов) | | model-invariants | не прогонялся | Дифф не касается rooms/wall-thickness/`layout`/`marker.space`/`open_spans` — furniture `x/y/w/h/angle/flip_*` decor-геометрия объекта, инварианты #254 к ней не адресуются (то же основание, что в r1) | | performance | не прогонялся | Не назван в AC отдельно от `bundle:budget`; предрелизный гейт | ## Прочитано заново (не только унаследовано из r1) - `src/furniture.ts` целиком — `resizeFurnitureTransform` (crossing/flip-XOR логика, минимум 0.1 см, сохранение мирового anchor), `furnitureRotationAngle` (детерминированное округление половинного шага), `furnitureRenderTransform` (порядок `rotate(...) translate(...) scale(...)` — mirror применяется в локальном боксе до поворота, как того требует ТЗ §6). - `src/houseplan-editor-runtime.ts`: furniture-ветка `_dtMove` (resize и rotate), `_decorApplyFurnitureBox`, `_decorSaveShape` furniture-путь (блокировка Save при `furnitureWcm===null`), `_decorFurnitureSizeInput`/ `_decorFurnitureFlip` (синхронизация знака и чекбокса). - `src/houseplan-card.ts`: `_renderTransformFrame` (четыре средние ручки, `dtfurnitureframe`), furniture render path в `_renderDecorLayer` (видимый path + невидимый `dfurniturehit` только при `editing && tool==='select'`, ширина = `strokeWidth + 20см`). - `src/styles/plan.styles.ts`: `pointer-events` включается только связкой `.mode-decor.dtool-select`, курсор поворота — локальный data-URI, без сети. - `custom_components/houseplan/validation.py`/`import_export.py`: bool-схема, plan-only allowlist. - `_dtUp` (общий, не furniture-специфичный) — один history-commit на жест независимо от `kind`, ровно как требует AC9. - Тесты: `test/furniture.test.mjs` — численные ассерты на `resizeFurnitureTransform`/`furnitureRotationAngle` (не строковые совпадения), способные упасть на смене знака/условия; аналогично `tests_backend/test_validation.py` (`0/1/"true"/None/[]/{}` отклоняются как flags). Всё перечисленное соответствует контракту ТЗ #383 и описанию из r1 — расхождений не найдено. ## Критерии приёмки — построчно (обновлённый статус r2) | AC | Доказательство | Итог r2 | |---|---|---| | AC1 угловой resize | unit (числовые ассерты) + smoke (`cornerGrowsTheWidth`, `oppositeCornerStaysPut`, `defaultResizePreservesRatio` и др., все `true`) | PASS | | AC2 одноосевые ручки | unit (edge-handle) + smoke (`furnitureFrameHasFourSideHandles`, `edgeHandleChangesOneAxisContinuously`) + golden-matrix статический контракт (44/44) + новый DOM-ассерт в `demo/golden/harness.mjs` (9/4 хендла) | PASS (пиксели — предрелизно) | | AC3 crossing/минимум | unit (`crossed`, `both`) + smoke (`crossingTogglesOnlyHorizontalFlip`, `crossingCancelRestoresTheObject`) | PASS | | AC4 rotation | unit (`furnitureRotationAngle` таблица) + smoke (`rotationIsFreeWithoutShift`, `shiftSnapsRotationTo45`) + прочитан `_dtMove`: rect/ellipse/text путь не тронут | PASS | | AC5 properties sync | unit (`furnitureSignedFieldCm/Value`) + smoke (`negativeWidthSynchronisesHorizontalCheckbox`, `zeroSizeBlocksSave`) + `?disabled=${invalidFurnitureSize}` прочитан в коде | PASS | | AC6 render parity | `furnitureRenderTransform` прочитан построчно (mirror внутри local box, затем rotate вокруг центра — соответствует §6); golden-сценарии `furniture-transform-light/dark` добавлены (4 ориентации, light/dark, angle 0°/30°); пиксельная сверка — предрелизно | PASS по коду; пиксели не подтверждены в этом раунде (то же основание, что в r1) | | AC7 hit-area | код: смещение `strokeWidth + decorCmToUnits(20,…)`, тот же `furnitureStrokePx` helper; CSS: `pointer-events` только в `.mode-decor.dtool-select`; smoke `selectionHaloUsesTheRealArtworkPath` | PASS | | AC8 cursor/frame | CSS прочитан: локальный data-URI, `grab`/`grabbing` fallback, `.dt-ew`/`.dt-ns` для средних ручек; smoke DOM-часть PASS; **manual screenshot не выполнялся** (как и в r1 — вне цикла ревью) | DOM PASS; визуальный проход — вне этого цикла | | AC9 история/cancel | `_dtUp` прочитан: не ветвится по `kind`, один commit на `d.moved`; smoke `crossingCancelRestoresTheObject` | PASS | | AC10 backend/transfer | `test_validation.py` (142 passed локально) — bool строго типизирован; `test_ha_import_export.py` прочитан (plan-only round-trip `flip_h=True/flip_v=False` сохраняется); HA-модули не исполнялись (ограничение среды) | PASS (частично — чтением для HA-зависимых файлов) | | AC11 регрессии | 11/12 прямых smoke зелёные (см. таблицу гейтов); общий `resizeDecorBox`/rect/ellipse/text путь в дифф не входит (сверено диффом `_dtMove`) | PASS | | AC12 i18n/docs/release | en/ru/de/fr парны (`decor.flip_h/v`, сверено `grep` по всем 4 файлам); `FURNITURE.md`, `USER-GUIDE(.ru).md`, `CONFIG-COMPATIBILITY.md` и др. читаемы и терминологически согласованы; **скриншоты пересняты и приняты** (`b1b1e743`, M1 закрыт); оба changelog в одном коммите с кодом (`686557c5`) | PASS (M1 закрыт, было единственное препятствие) | | AC13 гейты/бюджет | typecheck/test/build/bundle:budget/no-new-any — все зелёные, прогнаны лично в этом раунде; check-docs зелёный (CI + локально); golden/performance — предрелизно, как и заявлено | PASS | ## Находки Нет ни одной новой находки. M1 (единственная блокирующая находка r1) закрыт и подтверждён независимо. Low из r1 («143 vs 142 passed» — расхождение в формулировке хендоффа) не блокировал и не требует повторной проверки — природа не изменилась. ## Что проверено и корректно - M1 закрыт полностью по правилам §8: снята джоба `Docs screenshots` (`workflow_dispatch`, run `33302882691`), артефакт принят точной командой `npm run docs:accept -- --reviewed --from=...`, коммит `b1b1e743` содержит только `docs/images/**`, трейлеры (`Issue: #383`, `User-Visible: no`) корректны. `check-docs.mjs` зелёный и локально, и в CI `preflight`. - Ребейз на новый `dev` не создал скрытого взаимодействия с #385 (`223f499e`, `e561e5fc`) — разные функции, разные строки, подтверждено построчным чтением обоих коммитов. - Дешёвые и целевые гейты, прогнанные лично в этом раунде, зелёные: typecheck, 1604/1604 unit, build+bundle:sync (3 копии байт-в-байт), bundle:budget (запас 20853 B), no-new-any (0 новых), check-docs, целевой backend-субнабор (142/143), целевой smoke (`furniture`, все ключи true), 11/12 сопутствующих прямых smoke (единственный красный под-чек — воспроизводимое ограничение песочницы, не регрессия). - Furniture-only ветвление не задевает rect/ellipse/text/backdrop путь — сверено чтением `_dtMove`/`_renderTransformFrame`/`_decorSaveShape`. - «Одно число — один источник»: `test/single-source-numbers.test.mjs` в зелёном `npm test`; сигнатурные размерные поля в диалоге свойств — не повторный дисплей той же величины, а отдельная редактируемая проекция (коллизии нет, как и отметил r1). ## Чего не проверял - **Пиксельные golden** (AC2/AC6) и **браузерный performance-профиль** — предрелизный гейт по §8/§11.4, не гейт этого ревью; статические контракты сценариев прогнаны через `npm test` и новый DOM-ассерт в `demo/golden/harness.mjs`. - **Manual screenshot** для AC8 — вне цикла ревью по определению процесса. - **HA-зависимые backend-тесты** (`test_ha_import_export.py`, `test_coordinate_canonicalization.py`, полный `pytest tests_backend`) — `homeassistant` недоступен в этой песочнице (нет `.venv-backend`); разобраны чтением диффа, воспроизведено, что тот же импорт падает и на немодифицированном коде. - **30 слабых smoke-связей** из `smoke-select` — не прогонялись; список и причина отклонения идентичны r1 (широкие общие символы `_curSpaceCfg`, `_decorTool`, не относящиеся к furniture-специфичному коду). - **model-invariants** — дифф не касается geometry room/wall-thickness/ `layout`/`marker.space`/`open_spans`. ## Закрытие раунда r1 | Находка r1 | Чем закрыта | Где это видно | |---|---|---| | **M1** (Medium, в скоупе): `check-docs.mjs` красный — устаревший отпечаток скриншотов документации | Снята джоба `Docs screenshots` (`workflow_dispatch`, run `33302882691`), артефакт принят `npm run docs:accept -- --reviewed --from=...`, коммит `b1b1e743` | `git show --stat b1b1e743` — только `docs/images/**`; `node scripts/check-docs.mjs` → PASS локально; CI `preflight` на `b1b1e743` → success | | **L1** (Low, снята без правки r1): «143 passed» в хендоффе vs фактические 142 passed + 1 skipped | Не требовала правки — расхождение в формулировке, не в результате; в r2 автор сам указывает «143 passed» снова в комментарии после повторного rebase, я перепроверил напрямую: 142 passed + 1 skipped (143 собранных) | `python3 -m pytest tests_backend/test_validation.py -q` в этом раунде → `142 passed, 1 skipped` | ## Унаследовано из r1 Принято без повторного самостоятельного вывода (документ `CODE-REVIEW-383-r1.md`, материал `8f34c594`, содержимое исходников идентично текущему `686557c5` — подтверждено сверкой списка файлов диффа и собственным построчным чтением ключевых мест в этом раунде, а не слепым доверием): - Разбивка AC → конкретные unit/smoke имена (какая проверка что доказывает) — взята из r1 как карта, но каждая PASS-отметка в таблице выше перепроверена заново прогоном в r2, а не скопирована. - Мутационное рассуждение r1 («обратный знак `crossX`/`crossY`, пропуск `keepAspect` ломает конкретные ассерты») — не повторял мысленные мутации заново; вместо этого перечитал сам код `resizeFurnitureTransform` и убедился, что численные тесты `test/furniture.test.mjs` жёстко привязаны к конкретным входным/выходным числам (не строковые совпадения), что даёт ту же гарантию «тест умеет падать». - Заключение «прецедент `flip_h/flip_v` у openings — не коллизия» (Low-2 из ревью ТЗ) — принято из r1 без повторной проверки схемы openings, так как сам факт документирован в `CONFIG-COMPATIBILITY.md` (сверено, что абзац присутствует) и openings/furniture проходят разными функциями allowlist (`_DECOR_KIND_FIELDS`), что видно и в текущем диффе. ## Итог Реализация полностью закрывает контракт ТЗ #383: все 13 AC доказаны исполняемыми тестами (перепроверены лично в этом раунде, включая численные таблицы, которые способны падать на смене знака/условия) либо разобраны чтением с явной пометкой ограничения среды. Единственная находка r1 (M1) закрыта и подтверждена независимо через CI и локальный повтор. Рёбейз на ушедший вперёд `dev` не принёс скрытых конфликтов — оба привнесённых коммита (#385) не пересекаются с кодом #383 по функциям/строкам. Дешёвые и целевые гейты, прогнанные лично на этом SHA (а не унаследованные из вводящего в заблуждение «зелёного» Validate — см. оговорку в разделе «Как проверялось»), все зелёные. Новых находок нет. **Вердикт: зелёный · заход r2 · блокирующих циклов 1/4 · High: 0 · Medium: 0** Issue может переходить в `S8-merged`.