24 KiB
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, SHA197853c4) - Диапазон:
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-бэкенда (без единого мока), скриптом вне репозитория:
card._openSpaceDialog('edit', 'f1')→ в footer найдена кнопка «Копировать» сmdi:content-copy, вне.dialog-action-danger.- Клик → диалог имени,
copy.title === "Ground floor (2)"(предложенное имя = title источника + первый свободный номер — совпадает с AC2). - Правка поля на
"E2E Copy Floor", клик «Create copy». - План
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). - Клик «Cancel» на этом этапе не проверялся отдельно живьём (уже покрыт
юнит-тестом
runtime harness), клик Accept: пошли настоящие WS-вызовыhouseplan/plan/optimize, затемhouseplan/config/setпротив demo-сервера. - Итоговый
card._serverCfg.spaces=["f1", "<новый id>", "garden"]— копия вставлена сразу после источника (AC1/§7.1),"garden"(третье пространство) не задет. - Новое пространство:
title: "E2E Copy Floor",rooms: [],wall_segments: [],partitionsзаполнены (12 записей с новымиid, координатами иcm, совпадающими с исходными стенами — AC6). 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.ts13611 строк (потолок 13659),src/houseplan-editor-runtime.ts14309 (потолок 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-equalhost.
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, без нового цикла
ТЗ.
Материал раунда
- Ветка:
issue/456-copy-space, коммит2c355ee9ec97— ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала:
3da0804bd0118dde2f3ccfcc41e253a73ce8ab66git log --all --format='%H %T' | grep 3da0804bd011 - ТЗ
docs/specs/456-copy-space.md, блобa1def3eeefa15cac8b1bfe0542eb8d1ad0515fcfgit log --all --find-object=a1def3eeefa15cac8b1bfe0542eb8d1ad0515fcf -- docs/specs/456-copy-space.md