Files
houseplan-card/docs/reviews/CODE-REVIEW-456-r1.md
T
2026-09-05 09:25:08 +03:00

268 lines
24 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-456-r1
- **Issue:** #456 — копирование пространства без комнат и устройств
- **Ветка:** `issue/456-copy-space`
- **SHA материала:** `2c355ee9ec97ce65fd6b9525f3f82cca3668c8e8` (сверено `git rev-parse HEAD` непосредственно перед выводом вердикта)
- **Заход:** r1 · блокирующих циклов израсходовано до этого разбора: 0/4
- **ТЗ:** `docs/specs/456-copy-space.md`, SPEC-REVIEW зелёный (`docs/reviews/SPEC-REVIEW-456-r1.md`, SHA `197853c4`)
- **Диапазон:** `git diff origin/dev...HEAD` (`origin/dev` = `6ef0804b`), 55 файлов, класс A: `src/space-copy.ts`, `src/space-copy-runtime.ts`, `src/plan-optimize-write.ts`, `src/space-dialog.ts`, правки в `src/houseplan-editor-runtime.ts`, `src/houseplan-card.ts`, i18n×4
Это первый заход (r1) с нуля израсходованных циклов — раздел «по дельте» (§2.10) не применяется, разбор полный.
## 1. Скоуп
Реализация добавляет кнопку **«Копировать»** в footer настроек существующего
пространства (edit-only, вне `dialog-action-danger`), компактный диалог имени,
предварительный whole-plan Optimize с отдельным warning-подтверждением, и
чистый модуль `src/space-copy.ts`, конвертирующий `wall_segments[]` источника и
существующие `partitions[]` в самостоятельные перегородки новой копии с
перевязкой `openings[]` и удалением `contact`/`lock`. Комнаты, устройства,
markers, layout не переносятся. После успешной записи выбирается копия,
открывается `plan` с инструментом «Стены».
## 2. Как проверялось
### 2.1 Дешёвые гейты (гоняю сам — зелёного Validate на этом SHA нет)
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | зелёный, без вывода |
| Unit/integration | `npm test` | **1973 passed, 0 failed, 1 skipped** (совпадает с хендоффом автора) |
| Build + bundle parity | `npm run build && cmp dist/houseplan-card.js custom_components/.../houseplan-card.js && cmp dist/houseplan-assets.json custom_components/.../houseplan-assets.json` | зелёный, три копии синхронны |
| `node scripts/check-docs.mjs` (обязателен — diff трогает `src/**`) | — | **КРАСНЫЙ**: `screenshot source fingerprint is stale`. См. находку M1 |
| `node scripts/mutation-gate.mjs --check` | — | зелёный (все якори патчей уникальны) |
| **Реальный прогон 11 новых мутантов #456** (не только `--check`, а `--id=<mutant>`, полный build/guard-цикл) | `node scripts/mutation-gate.mjs --id=<id>` ×11 | **все 11 «тест покраснел, как обязан»** (лог приведён в §4) |
| Backend (пpure, без HA) | `python3 -m pytest tests_backend -q` (окружение без pinned Python 3.13/HA 2026.8.3 — см. §6) | **378 passed, 4 skipped** (skip — только `test_ha_*.py`, отсутствие HA) |
| `npm run golden:verify` | — | **1 сцена «different»**: `space-room-color-popover-desktop-ru`. См. находку M2 |
| `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | — | 89 «прямых совпадений» из 222 (диффу задета большая площадь общих полей рантайма — `_frame`, `_modelCache`, `_path` и т.п.). Полный прогон — предрелизный гейт (§8), не гейт ревью. Прогнал 3 показательных смока по общим примитивам, которые Copy переиспользует: `demo/smoke_space_settings.mjs`, `demo/smoke_danger_confirmation.mjs`, `demo/smoke_optimize_geometry_preflight.mjs` — все три **зелёные**, регрессии в общих диалогах/preflight нет |
| Живой end-to-end прогон новой фичи в браузере (собственный несохранённый скрипт, `demo/serve.mjs` + продакшн-бандл после `npm run bundle:sync`, без моков `hass.callWS` — реальный demo-backend) | — | **полный сценарий сработал**: см. §3 |
### 2.2 Живой браузерный прогон новой фичи (нет закоммиченного смока — см. M3)
Поскольку ни в диффе, ни в ТЗ §17 п.6 не хватает выделенного
`demo/smoke_space_copy.mjs`, а вопрос «оно вообще работает» в этом процессе
обязан закрыть код-ревью, я прогнал реальный продакшн-бандл в Playwright
против настоящего demo-бэкенда (без единого мока), скриптом вне репозитория:
1. `card._openSpaceDialog('edit', 'f1')` → в footer найдена кнопка «Копировать»
с `mdi:content-copy`, **вне** `.dialog-action-danger`.
2. Клик → диалог имени, `copy.title === "Ground floor (2)"` (предложенное имя
= title источника + первый свободный номер — совпадает с AC2).
3. Правка поля на `"E2E Copy Floor"`, клик «Create copy».
4. План `f1` требовал Optimize → показано **реальное** warning-подтверждение:
`title: "Optimize before copying?"`, `message: "Before creating “E2E Copy
Floor”, House Plan must optimize the entire plan, including other spaces.
Continue?"`, `confirmLabel: "Optimize and copy"`, `kind: "warning"` — байт в
байт совпадает с `space.copy_optimize_*` из `src/i18n/en.json` (AC4, §6.3.5).
5. Клик «Cancel» на этом этапе не проверялся отдельно живьём (уже покрыт
юнит-тестом `runtime harness`), клик **Accept**: пошли настоящие WS-вызовы
`houseplan/plan/optimize`, затем `houseplan/config/set` против demo-сервера.
6. Итоговый `card._serverCfg.spaces` = `["f1", "<новый id>", "garden"]` — копия
вставлена сразу после источника (AC1/§7.1), `"garden"` (третье
пространство) не задет.
7. Новое пространство: `title: "E2E Copy Floor"`, `rooms: []`,
`wall_segments: []`, `partitions` заполнены (12 записей с новыми `id`,
координатами и `cm`, совпадающими с исходными стенами — AC6).
8. `card._space === <новый id>`, `card._mode === 'plan'`, `card._tool ===
'draw'`, `card._spaceDialog === null` — переход после успеха выполнен
ровно как в AC12/§10.
Это не заменяет отсутствующий закоммиченный смок (см. M3: без него следующий
разработчик может сломать сценарий незаметно), но отвечает на вопрос «оно
вообще работает» настоящим исполнением в браузере против настоящего бэкенда,
а не догадкой по коду.
## 3. Находки
Все три — **Medium, в скоупе задачи**, чинятся в этой же issue без нового
цикла эскалации (решение владельца #202). High не найдено.
### M1 — `check-docs.mjs` красный: отпечаток скриншотов устарел
**Файл:** `docs/images/screenshots.json` (не тронут диффом, хотя обязан быть)
**Симптом:** `node scripts/check-docs.mjs` → `screenshot source fingerprint is
stale; run npm run build && node demo/docs/capture.mjs`.
**Воспроизведение:** подтверждено сравнением с базой. На чистом
`origin/dev` (`6ef0804b`, отдельный `git worktree`) `check-docs.mjs` зелёный
(`Documentation checks passed`); на HEAD этой ветки (`2c355ee9`) — красный, без
других изменений окружения. Причина ровно та, что описана в PROCESS §8:
отпечаток считается по всему `src/**`, и эта задача правит `src/**`
(`space-copy.ts`, `space-copy-runtime.ts`, `plan-optimize-write.ts`,
`space-dialog.ts`, `houseplan-editor-runtime.ts`, `houseplan-card.ts`).
**Почему это не мелочь:** ровно этот пропуск оставил `dev` с красным job
`docs` в #230 и #234 до следующей задачи (#237) — тот же механизм, тот же
результат, если смержить как есть.
**Фикс:** прогнать job `Docs screenshots` (`workflow_dispatch`) и принять
результат `npm run docs:accept -- --reviewed --from=<распакованный
артефакт>` в этом же issue/коммите.
### M2 — `golden:verify` красный: footer диалога пространства изменился, эталон не обновлён
**Сцена:** `space-room-color-popover-desktop-ru` → статус `different`.
**Воспроизведение:** `npm run golden:verify` (полный прогон, ~8 минут).
Diff-изображение (`artifacts/golden/diff/space-room-color-popover-desktop-ru.png`)
показывает контур новой кнопки **«Копировать»** в footer открытого диалога
«Пространство» — ровно то место, куда её добавляет этот диф
(`houseplan-editor-runtime.ts`, `dialog-action-group` перед
`dialog-action-danger`). Ни один другой элемент кадра не отмечен диффом
предметно.
**Почему это находка, а не ожидаемое поведение:** новый видимый элемент —
осознанное и корректное изменение (это и есть AC1), но правило проекта —
«Golden-эталоны принимаются только `npm run golden:accept -- --reviewed` по
полному Linux-артефакту» (PROCESS правило 13). Эталон для этой сцены не
обновлён, значит смерженный код оставит `dev` с красным job golden ровно по
той же механике, что M1.
**Фикс:** пересъёмка полного Linux golden-артефакта в CI и
`npm run golden:accept -- --reviewed` для этой (и только этой, по итогам
прогона) сцены, в этом же issue.
### M3 — заявленный в ТЗ browser smoke для Copy отсутствует
**ТЗ §17 п.6** обещает «Production-bundle browser smoke: кнопка → имя →
no-optimize success; кнопка → optimize warning → Cancel; warning → Accept →
новая вкладка space/Plan», и AC1/AC2/AC4/AC12/AC13 называют browser smoke
одним из способов доказательства. В диффе `demo/**` не изменён вообще ни
одним файлом — выделенного `demo/smoke_space_copy.mjs` нет.
**Что это значит практически:** сам факт, что фича работает, я подтвердил
исполнением (см. §2.2) — но это одноразовый несохранённый скрипт, а не
регрессионный барьер. Следующая правка в `houseplan-editor-runtime.ts` или
`space-copy-runtime.ts` может тихо сломать сценарий, и никакой автотест
этого не заметит (unit-тесты `space-copy-runtime.test.mjs` used a fully
mocked `host`, а не реальный Lit-рендер и реальный клик).
**Фикс:** добавить `demo/smoke_space_copy.mjs`, минимум три ветки из §17.6:
clean copy (без Optimize), Optimize-required → Cancel (zero writes), Optimize
required → Accept → переход в копию.
### Low (снято решением ревьюера, без правки)
Фокус поля имени при возврате из отменённого Optimize-warning (§6.2, §11:
«при возврате из отменённого warning значение и фокус восстанавливаются») не
проверен ни автотестом, ни живым прогоном — я проверял только Accept-ветку.
Риск чисто косметический (курсор, не потеря данных), явного мутанта или AC-пункта
на это не заведено. Снимаю без правки; при следующем случайном контакте с этим
диалогом стоит проверить глазами.
## 4. Мутанты — таблица «чем краснеет» (PROCESS §2.7)
Каждый мутант прогнан по-настоящему (`node scripts/mutation-gate.mjs
--id=<id>`, не только `--check`), т.е. применён к реальному worktree, собран
бандл, запущен guard-тест и подтверждено падение:
| AC | Чем доказан (чистый прогон) | Мутант | Чем краснеет |
|---|---|---|---|
| AC2 | `test/space-copy.test.mjs` — «first free numbered» | `space-copy-title-always-reuses-two` | тест красный: имя перестаёт пропускать занятые номера |
| AC3 | `test/space-copy-runtime.test.mjs` — «no extra confirmation» | `space-copy-always-asks-to-optimize` | тест красный: confirm вызывается всегда |
| AC4 | `test/space-copy-runtime.test.mjs` — «durable before the copy» | `space-copy-skips-required-optimize` | тест красный: Optimize пропускается перед записью копии |
| AC5 | `test/space-copy-runtime.test.mjs` — «unsafe Optimize» | `space-copy-ignores-unsafe-optimize-preflight` | тест красный: preflight-отказ игнорируется |
| AC6 | `test/space-copy.test.mjs` — «complete allowed physical surface» | `space-copy-drops-existing-partitions` | тест красный: существующие partitions теряются |
| AC7 | тот же тест | `space-copy-keeps-opening-device-binding` | тест красный: `contact`/`lock` не удаляются |
| AC8 | тот же тест | `space-copy-shares-settings-object` | тест красный: `settings` копии — тот же объект, что источник |
| AC9 | тот же тест | `space-copy-leaks-room-drafts` | тест красный: `room_drafts` протекают в копию |
| AC10 | `test/space-copy.test.mjs` — «collection boundaries» | `space-copy-ignores-wall-limit` | тест красный: лимит partitions не проверяется |
| AC11 | `test/space-copy-runtime.test.mjs` — «keeps accepted Optimize» | `space-copy-rejection-keeps-local-copy` | тест красный: откат съедает принятый Optimize |
| AC12 | `test/space-copy-runtime.test.mjs` — «one config write» | `space-copy-does-not-select-result` | тест красный: копия не выбирается после записи |
AC1, AC13 — не защитные AC в смысле §2.7 (расположение элемента, бюджеты,
i18n-полнота), доказаны прямым сравнением (unit + живой браузер + `wc -l`
против ceiling в `test/core-file-budget.test.mjs`), третья колонка не
применяется.
## 5. Что проверено и корректно
- **Чистая логика (`space-copy.ts`)**: лимиты (`spaces/partitions/openings/
decor/columns`), ID-карты `wall→partition`/`partition→partition`,
детерминированный порядок, allowlist top-level ключей (неизвестные поля вроде
`future_room_bound_field` не копируются — проверено тестом и чтением),
fail-closed на некорректных host/ID. Immutable: `assert.deepEqual(input,
before)` в тесте подтверждает отсутствие мутации источника.
- **Оркестрация (`space-copy-runtime.ts`)**: порядок Optimize→confirm→commit→
copy-write соблюдён (мутант AC4); конкурентное изменение плана между confirm
и записью инвалидирует confirmation (тест «a concurrent plan change
invalidates an open Optimize confirmation»); rollback только для шага Copy,
принятый Optimize переживает отказ (AC11, мутант + живое чтение
`commitPlanOptimization`/`rollbackOptimistic`).
- **Рефакторинг `commitPlanOptimization`** (`src/plan-optimize-write.ts`) —
вынесен из align-to-grid флоу и переиспользован Copy; `git diff` показывает
align-to-grid стал короче на ту же логику — соответствует требованию ТЗ §13
«вынести не меньше, чем добавляет». `scripts/coordinate-write-barrier-guard.mjs`
и его тест обновлены на новый файл — единственный писатель
`houseplan/plan/optimize` учтён.
- **Core budgets**: `src/houseplan-card.ts` 13611 строк (потолок 13659),
`src/houseplan-editor-runtime.ts` 14309 (потолок 14323) — оба под потолком,
потолки не подняты (`test/core-file-budget.test.mjs` не менялся).
- **i18n**: en/ru/de/fr получили по 20 одинаковых новых ключей;
`test/i18n*.test.mjs` (35/35) зелёные, sentinels/dead-keys чисты.
- **Backend**: новый `tests_backend/test_validation.py::
test_issue_456_roomless_copy_with_rehosted_openings_is_valid` проходит
через настоящую `CONFIG_SCHEMA` (voluptuous), не через фейковую копию схемы;
запущен напрямую (378 passed вместе с остальными pure-тестами).
- **Дизайн `host.t` без пересчёта, `t`/геометрия openings без округления** —
проверено чтением (`{ ...clone(host), kind: 'partition', id: mapped }`) и
тестом deep-equal `host`.
## 6. Чего не проверял и почему
- **`test_ha_*.py` (полный HA-harness, `pytest-homeassistant-custom-component
0.13.357` + `homeassistant==2026.8.3`, требует Python 3.13/3.14)** — песочница
ревью несёт Python 3.12 и не имеет пакета `homeassistant`; ставить
полный пинованный стек ради одного прогона — тяжёлая, сетевая операция вне
экономии, оговорённой §8 «объём гейтов соразмерен задаче». Прогнал вместо
этого весь pure-набор (`378 passed, 4 skipped`, skip — только `test_ha_*.py`
по объявленному `conftest.py` признаку отсутствия HA). Реальный
WS-эндпойнт `houseplan/plan/optimize`/`config/set` в этом наборе не
проверялся backend-стороной — но частично компенсировано (b) живым
browser-прогоном против demo-бэкенда, который реализует ту же пару команд.
- **Полная матрица смоков (222 файла)** — предрелизный гейт (§8), не гейт
ревью; прогнал только 3 показательных с прямым совпадением плюс
собственный live E2E новой фичи (см. M3 про отсутствие закоммиченного
аналога).
- **`npm run invariants -- --config <export>`** — нет реального
`config/get`-экспорта от установки HA под рукой; вместо него — mutation-
подтверждённый unit/fixture-набор (`makeLargeHouseFixture` +
`optimizePlans` в `space-copy.test.mjs`) и упомянутый в issue #456 ручной
прогон автора ТЗ реальных backend-валидаторов (`validate_wall_model_
transition`, `validate_opening_passages`, `validate_partition_opening_
hosts`, `validate_junction_limits`) на той же форме host-remap во время
анализа задачи (см. комментарий issue до ревью ТЗ). Прямой backend-тест в
этом диффе проверяет только `CONFIG_SCHEMA`, не полный список этих
функций — риск считаю малым (форма не изменилась с момента ручного
прогона), но не «доказано автотестом этого диффа».
- **Focus restoration после Cancel предупреждения Optimize** — см. Low в §3.
## 7. Вердикт
Функционально все 13 AC выполнены и подтверждены: 11 из них — мутационным
тестированием с реальным (не только `--check`) прогоном красного состояния,
плюс независимый живой прогон в браузере против настоящего demo-бэкенда,
подтвердивший весь сценарий «кнопка → имя → Optimize warning → Accept →
чистая копия» от начала до конца. High-находок нет.
Три Medium-находки — все в скоупе задачи, все мехонически проверяемые
(`check-docs`, `golden:verify` реально красные; заявленный в ТЗ browser smoke
реально отсутствует) — не позволяют поставить зелёный вердикт: смержённый как
есть код оставит `dev` с двумя красными job (`docs`, `golden`), в точности
повторяя уже описанный в PROCESS инцидент (#230/#234/#237).
**Вердикт: жёлтый · заход r1 · блокирующих циклов 1/4 · High: 0 · Medium: 3 → в задаче**
Возврат автору: принять golden/docs эталоны через штатные шаги (`Docs
screenshots` + `docs:accept --reviewed`, `golden:accept --reviewed`) и
добавить `demo/smoke_space_copy.mjs`, всё — в этом же issue, без нового цикла
ТЗ.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/456-copy-space`, коммит `2c355ee9ec97` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `3da0804bd0118dde2f3ccfcc41e253a73ce8ab66`
```
git log --all --format='%H %T' | grep 3da0804bd011
```
- ТЗ `docs/specs/456-copy-space.md`, блоб `a1def3eeefa15cac8b1bfe0542eb8d1ad0515fcf`
```
git log --all --find-object=a1def3eeefa15cac8b1bfe0542eb8d1ad0515fcf -- docs/specs/456-copy-space.md
```