# CODE-REVIEW-132-r1 - **Issue:** https://github.com/Matysh/houseplan-card/issues/132 - **Связанный bug в том же scope:** #185 (закрывается тем же кодом, по решению владельца) - **Диапазон:** `origin/dev...HEAD` на ветке `issue/132-partition-openings-v2`, коммиты `b9bf210..9f77e3e` (5 коммитов: 4 документационных + 1 продуктовый — `9f77e3e feat: support openings in independent walls`) - **ТЗ:** `docs/specs/132-partition-openings.md`, зелёное ревью `docs/reviews/SPEC-REVIEW-132-r1.md` - **Роль:** ревьюер кода (не автор), этап `S7-code-review` - **Цикл:** r1/4 ## Скоуп ревью Диапазон правит 43 файла (2961 / 603): новый резолвер хоста (`src/partition-openings.ts`), геометрию cut (`src/physical-geometry.ts`, `src/wall-thickness.ts` косвенно), размещение (`src/opening-placement.ts`, `src/align-grid.ts`), структурную топологию комнат/#185 (`src/plan-snap-overlay.ts`, `src/houseplan-card.ts`), свет/Glow/sun (`src/houseplan-card.ts`, `src/styles.ts`), HA-состояние и команды move/delete/undo (`src/houseplan-card.ts`), backend-валидацию и import/export/websocket (`custom_components/houseplan/*.py`), i18n (`src/i18n/en.json`, `ru.json`), 9 канонических документов + оба changelog, и полный слой автотестов (unit/backend/smoke). Проверялось соответствие 12 AC из ТЗ (§20), контракту `docs/SCOPE.md` (lock-инвариант, J1–J4), каноническим документам подсистем и трейлерам коммитов. ## Как проверялось Дешёвые гейты (прогнаны лично, точные команды и результат): | Гейт | Команда | Результат | |---|---|---| | Typecheck | `npx tsc --noEmit` | зелёный, без вывода | | Unit | `npm test` | `870 passed / 0 failed` | | Build + сверка бандлов | `npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js && cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` | сборка ок, обе копии побайтово совпадают | Смоки, прогнанные локально (по diff и AC — не весь набор из 141): | Смок | Результат | Почему выбран | |---|---|---| | `node demo/smoke_partition_openings.mjs` | OK, все 12 полей `true` | новый, основное доказательство AC1–AC3, частично AC5 | | `node demo/smoke_room_autoclose.mjs` | OK, все 9 полей `true` | изменён, доказательство #185/AC5 | | `node demo/smoke_opening_preview.mjs` | OK, все 39 полей `true` | изменён, placement/AC1 | | `node demo/smoke_glow.mjs` | OK | AC4, свет | | `node demo/smoke_inert_openings.mjs` | OK | AC8, passage inert | | `node demo/smoke_opening_binding.mjs` | OK | AC8, HA/security | | `node demo/smoke_opening_tunnel_fill.mjs` | OK | AC2, tunnel/cut | | `node demo/smoke_wall_junctions.mjs` | OK | AC2, junction patches | | `node demo/smoke_unified_wall_tool.mjs` | OK | AC12, регресс #173 | | `node demo/smoke_isometric_contract.mjs` | OK | AC7, Iso renderer | | `node demo/smoke_openwall.mjs` | OK | AC12, регресс | | `node demo/smoke_sun.mjs` | OK | AC14/sun regression | Остальные 129 смоков не запускались — задача не задевает все поверхности разом (см. таблицу выше — выбор покрывает placement/geometry/topology/light/ HA/regression, по одному представителю на AC-кластер). `npm run golden:verify` (после свежей сборки и копирования бандла) — запущен, поскольку diff меняет геометрию/свет/рендер. Результат: 60/62 сцен `passed`, одна `different` (`plan-snap-line-gaps-dark`), один транзиентный `error` при первом прогоне (`openings-filled-tunnel-dark`), исчезнувший при повторном прогоне на том же дереве (похоже на конкуренцию ресурсов при параллельных фоновых процессах ревью, не на дефект — см. «Чего не проверял»). Эталоны `demo/golden/baselines/**` в этом диапазоне **не менялись** (подтверждено `git diff --stat`), что соответствует процессу: обновление golden — предрелизный шаг через `golden:accept --reviewed`, не часть этого код-ревью. `python -m pytest tests_backend -q` — прогнан после ручной установки отсутствовавших в этом окружении `pytest`/`voluptuous` (`homeassistant` не установлен). Результат: **139 passed**, но это только «чистое» подмножество — `conftest.py` молча игнорирует `test_ha_*.py`, когда `homeassistant` недоступен (задокументированное в `AGENTS.md` поведение). Значит, `tests_backend/test_ha_import_export.py` (изменённый в этом диапазоне файл, +4/−…) **не выполнялся** в этом прогоне. Учитывая, что именно в `import_export.py` найден блокирующий High (см. ниже), это существенное ограничение — см. «Чего не проверял». Дополнительно организовано 7 независимых агентских разборов (каждый — отдельная поверхность: геометрия/cut, топология комнат #185, свет/Glow, HA-state/move-delete-undo, backend-валидация, качество тестов/смоков, документация/i18n), каждый со своими file:line-цитатами и с явным заданием мысленно отменить конкретную строку продукта и проверить, падает ли привязанный тест. Их находки перепроверены мной лично чтением исходников (конкретные цитаты — в разделе «Находки»); один инцидент — агент по ошибке выполнил `git checkout -- src/physical-geometry.ts` в общем чекауте поверх активной сборки и вручную восстановил строку — проверен: `git status --short` и `git diff HEAD` после инцидента пусты, рабочее дерево совпадает с HEAD, порчи нет. ## Находки ### High-1 — импорт/дублирование space с partition-hosted проёмом всегда отклоняется **Файл:** `custom_components/houseplan/import_export.py:830–845` (ремап id), согласовано с `custom_components/houseplan/validation.py:836–839` (референциальная проверка `host.id`). `build_space_merge()` — код пути «Backup → импорт одного space» (вызывается из `create_preview()` для `document["kind"] == "space"`, `import_export.py: 1153` — это реальная пользовательская фича экспорта/импорта одной комнаты/ этажа, представленная в golden-сценах `backup-*`). Цикл ремапа id (`:830–838`) проходит по коллекциям `rooms, room_drafts, partitions, wall_columns, openings, decor` и переписывает **собственный** `item["id"]` каждой записи на свежий id, но нигде не переписывает вложенную ссылку `opening["host"]["id"]`, которая указывает на **старый** id partition. Далее `merged_config = CONFIG_SCHEMA(merged_config)` (`:977`) прогоняет `_space_geometry_invariants` (`validation.py:813–853`), который для каждого `opening.host.id` требует существующий partition в том же space (`validation.py:836–839: raise vol.Invalid(...)`). После ремапа `opening.host.id` физически не может совпасть ни с одним новым id partition — значит **любой** импорт/дублирование space, содержащего хотя бы один partition-hosted проём, безусловно завершается `ImportFailure("invalid_config", ...)`. Проверено лично чтением: `grep -n "host" custom_components/houseplan/ import_export.py` даёт ровно 2 упоминания вне ремап-цикла — `:269` (простое сохранение поля при экспорте, ремапа id не требует) и `:985` (вызов `validate_partition_opening_hosts`, который проверяет только «host не исчез у уже существовавшего проёма», а не референциальную целостность после ремапа — эту проверку делает уже упомянутый `_space_geometry_invariants` через `CONFIG_SCHEMA`). Ни один call site не трогает вложенный `host.id`. **Тест:** `tests_backend/test_ha_import_export.py:876–909` (`test_space_merge_remaps_every_space_owned_id_and_room_link`) — единственный тест, гоняющий ремап id через `build_space_merge`, и его фикстура-проём (`op1`, строка ~885) намеренно не имеет `host`. Регрессия непокрыта. Этот же тестовый файл в принципе не выполнялся в доступном окружении ревью (нет `homeassistant`) — см. «Чего не проверял»; сама находка получена чтением кода и подтверждена независимым агентским разбором, не прогоном теста. **Почему High:** это прямое нарушение AC9 («host fields survive save/export/ import/optimize») и §18 ТЗ («Export/import/backup сохраняют host object и referential order»); ломается не крайний случай, а любое использование только что реализованной фичи через уже существующий, регулярно используемый путь backup/duplicate. Блокирует. **Как чинить (для автора, не мой домен):** при ремапе `openings` в этом же цикле переписывать `item.host.id = id_map[item.host.id]`, когда `host.kind === 'partition'`, аналогично тому, как уже переписывается `room.open_to` через `old_room_ids` (`:839–844`) и `marker.room_id` (`:875–876`). ## Medium-находки (заведены отдельными issue) ### Medium-1 → #186 — нет jamb safety margin `src/partition-openings.ts:41` (`jambMargin = 0`, не переопределяется ни на одном из ~9 call sites: `src/houseplan-card.ts:7290, 7751, 7783, 11094, 11393, 11402, 17301`, `src/space-render.ts:214`) и `custom_components/houseplan/validation.py:840–846` (только `1e-9` float-допуск). ТЗ §7/§16/§23 явно называет jamb safety margin отдельно от допуска на погрешность. Проём можно поставить впритык к концу перегородки без зазора на откос. Функционально не ломает (fail-dark по-прежнему работает), но расходится с принятым ТЗ. Заведено: #186. ### Medium-2 → #187 — fallback-гвард источника света не fail-dark «по построению» `src/houseplan-card.ts:14585` (`pointInOpaquePlanBody(sourcePoint, masonryGeometry, physical)`) использует в качестве fallback `physical = this._physicalBodiesR(space)` (`:14527`) — тело, вырезанное по **всем** типам hosted-проёмов без различия света, а не light-policy-фильтрованный `lightPhysical` (`:14442–14451`), который `_lightBarriers` использует для основной геометрии. Обнаружено, что параметр `physical` внутри функции больше нигде не используется (мёртвый после рефакторинга, кроме этого места). Если `wallBodiesGeometry()` (`src/wall-thickness.ts:1790–1792`) выбросит исключение, `masonryGeometry` станет `[]`, и guard будет опираться на дырявое (в т.ч. по окну) тело — контракт «boolean failure fail-dark» (ТЗ §13/§17) в этой ветке держится на случайной само-ограниченности развёртки, а не на архитектуре. Практическое проявление маловероятно (требует throw в объединении), поэтому Medium, не High. Заведено: #187. ### Medium-3 → #188 — тест junction-patch не умеет падать `test/partition-openings.test.mjs:64–82` («computed junction patches cannot bridge a hosted slot») не отражает регрессию в `src/physical-geometry.ts:206–208` (`.flatMap((body) => cutPartitionBody(body, partitionCuts, epsilon))` на join-patches): проверено мысленным (и одним агентом — фактическим) удалением этой строки — все 8 подтестов остаются зелёными, так как bbox патча в T-образной фикстуре не достигает проверочной точки `[92,0]` независимо от вырезания. Сам production-код при этом корректен — я перечитал `physical-geometry.ts:186– 211` лично и подтверждаю: патчи действительно прогоняются через `cutPartitionBody` со всеми `partitionCuts`, до объединения в `all` (`:209`), т.е. заявленное в §10 ТЗ поведение реализовано верно, только не доказано этим конкретным тестом. Заведено: #188. ## AC1–AC12 — разбор по каждому критерию 1. **AC1 (Walls workflow/placement).** Доказано: unit (`test/opening-placement.test.mjs` — партиционный candidate побеждает room-wall при точном совпадении/collinear, отклоняется при пересечении/неоднозначности; `test/partition-openings.test.mjs` — резолвер центра/угла/длины/depth) + smoke (`smoke_partition_openings.mjs`, `smoke_opening_preview.mjs`, оба зелёные). Active draft/column/virtual span как host отклоняются — подтверждено чтением `resolvePartitionOpening` (принимает только `PartitionCfg`) и юнитами placement. **Пройдено.** 2. **AC2 (geometry/composite overlap).** Full-depth cut для 1/15/100 см и diagonal — доказано unit (`test/partition-openings.test.mjs:52–73`) и смоком `smoke_opening_tunnel_fill.mjs`. Composite double-cut (partition + coincident room-wall режутся оба) — проверено **чтением, не исполнением**: `_roomWallOpeningInputs` (`houseplan-card.ts:7795–7816`) эмитит room-wall cut только при точном collinear-покрытии (`partitionOpeningHasCompositeRoomWall`), а независимое тело partition режется параллельно через `_partitionOpeningCuts`; оба пути сходятся в `wallBodiesGeometry`/`_wallUnionGeometry`. Отдельного unit/smoke именно на coincident-случай нет (см. «Чего не проверял»), но код-путь однозначен и golden-сцены `openings-thick-wall-dark`, `wall-junctions-*-dark`, `geometry-diagonal-45-opening-dark` прошли без изменений — косвенно подтверждает. Junction patches не закрывают cut заново — код корректен (см. Medium-3 про сам тест). **Пройдено**, с оговоркой Medium-3. 3. **AC3 (host lifecycle).** Rigid move — чисто трансляция, `t`/length инвариантны по построению (`_physicalUp`, `houseplan-card.ts:7266–7317`). Delete-с-confirmation: единственный путь удаления partition с hosted openings — `_deletePhysicalSelection` → `_partitionDeleteDialog` → `_confirmPartitionDelete`, список отсортирован по `t` (`:7104`), Cancel — чистый no-op, Confirm атомарен, один `_recordGeometry` вызов на всю операцию. Других путей удаления, обходящих confirmation, не найдено — `_dropLegacySegments()` чистит только структурно некорректные partitions и не трогает `openings` (в этом случае backend `_space_geometry_invariants` отклонит сохранение, а не тихо потеряет данные). Undo/Redo — единый `CommandStack` снапшот всего пространства. Доказано unit (delete/undo команды) + smoke (`smoke_partition_openings.mjs`: `deleteRequiresAccessibleListDialog`, `deleteCancelIsMutationFree`, `deleteConfirmCascadesHostAndOpening`, `deleteUndoRestoresHostAndOpening`, `deleteRedoCascadesAgain` — все `true`, и мутационно подтверждено: удаление cascade-фильтра в `_confirmPartitionDelete` заваливает именно эти два поля). **Пройдено.** 4. **AC4 (light).** Interior door/gate/passage пропускают Glow через полный composite cut, exterior/window остаются opaque, invalid host fail-dark — подтверждено чтением (`_lightBarriers`, `_roomWallOpeningInputs`, `_partitionOpeningCuts`) и смоком `smoke_glow.mjs`. **Пройдено**, с оговоркой Medium-2 (fallback-ветка) и отсутствием прямого unit-теста на cache fingerprint/composite-Glow сценарий (см. «Чего не проверял»). 5. **AC5 (#185 room closure).** Реальный фикс — не в `plan-snap-overlay.ts` (не изменился по логике), а в удалении type-agnostic `_planSnapOpeningCuts()` из вызова `buildPlanSnapGeometry()` в `houseplan-card.ts`: раньше каждый opening резал структурную ось, теперь `roomCuts` строится только из реальных `open_spans`. Подтверждено эмпирически (один из агентов подменил бандл на собранный из `origin/dev` и прогнал обновлённый `smoke_room_autoclose.mjs` — assertion `openingKeepsStructuralAutoClose` стала `false` на старом бандле и `true` на текущем: воспроизводимо красный → зелёный переход именно от этого коммита, как и требует доказательство AC5 в ТЗ). Для partition-хостов структурная непрерывность тривиальна: `buildPlanSnapGeometry` никогда не резал partition-оси opening-катами (`cuts: []` для partition-источников что до, что после). **Пройдено**, но матрица неполна: `smoke_room_autoclose.mjs` и `smoke_partition_openings.mjs` гоняют только `type: 'door'`; window/gate/ passage и полный `_wallFaceBatch`/`_roomDialog` UI-путь для partition-хоста явно не прогнаны (для partition проверена только структурная снапшот-функция, не UI-диалог). Логика type-agnostic, дефекта не вижу при чтении, но заявленная в §21 ТЗ «матрица по всем четырём типам» не покрыта тестами буквально — фиксирую как незакрытый пробел evidence, не как блокирующую находку (код прочитан и корректен). 6. **AC6 (no passive topology mutation).** `_offerWallFaces()` вызывается только из двух click-обработчиков внутри Walls-инструмента (`houseplan-card.ts:6719, 6827`), ни разу — из lifecycle/hass-сеттеров/ `_editOpening`/`_saveOpening`. Ни один из этих call sites не менялся в этом diff. **Пройдено, проверено чтением.** 7. **AC7 (render parity).** Golden прошёл на всех Plan/Iso/View-сценах, кроме одной «different» (см. ниже). `smoke_isometric_contract.mjs` зелёный. Единый resolver (`resolvePartitionOpening`) используется и в `space-render.ts`, и в `houseplan-card.ts` рендер-путях — подтверждено чтением обоих файлов. **Пройдено.** 8. **AC8 (HA/security parity).** `resolveHaBindingStatus`, `_lockAction`, `_renderOpeningInfoCard` не ветвятся по `host.kind`, только по `o.type`; `_openingsR` резолвит partition-hosted openings в тот же `RenderOpening` через spread, не параллельной реализацией. Перемещение host не трогает `entity`/`flip_*`/id (`materializePartitionOpening` переписывает только `x/y/angle`). Доказано чтением + smoke (`smoke_inert_openings.mjs`, `smoke_opening_binding.mjs`). **Пройдено.** 9. **AC9 (compatibility).** Backend-схема реально проверяет существование `host.id` среди partitions пространства, границы `t`, вписывание длины, пересечения между hosted-проёмами одного host — не поверхностно ( `validation.py:813–853`), тест на отклонение реален (падает при удалении `raise`, `tests_backend/test_validation.py`). Legacy без `host` не мигрируют. Экспорт одиночного plan/backup сохраняет `host` без ремапа id — корректно. **Не пройдено**: см. High-1 — space merge/import ломает ссылку `host.id` при ремапе, что прямо противоречит этому AC. 10. **AC10 (accessibility/touch).** Диалог удаления — `hp-dialog` с доступным списком (`deleteRequiresAccessibleListDialog: true` в smoke). Pointercancel/multi-touch код (`_stagePointerCancel`) не менялся этим diff и уже покрывал generic drag-отмену по kind+pid. **Пройдено, проверено чтением** (специализированного touch-смока с реальным multi-pointer эмулятором на partition-drag не гонял — вне моего окружения, см. «Чего не проверял»). 11. **AC11 (cache/performance).** Glow не строится в Plan-режиме, где происходит drag partition (`glowLayerVisible` завязан на `_markup`); fingerprint для `_lightBarriers` включает геометрию/cuts/` transparentHostedIds`, что эквивalентно требованиям ТЗ, хотя не дословно совпадает по списку полей. HA-only tick не трогает `_curSpaceCfg`, значит не меняет fingerprint. **Пройдено, проверено чтением**; отдельного performance-смока не гонял (в AC не назван, в diff нет изменений `demo/smoke_performance*`/`performance_smoke` файлов — см. «Чего не проверял»). 12. **AC12 (regression #173/#157).** `smoke_unified_wall_tool.mjs`, `smoke_openwall.mjs` зелёные без изменений. `PASSAGE_FORBIDDEN_FIELDS` контракт #157 не тронут (`validation.py`, не изменялся в этой части). **Пройдено.** ## Что проверено и корректно - Полный резолвер `src/partition-openings.ts` — pure, explicit host, без nearest-wall fallback, как требует §8 ТЗ; fail-dark для `resolved: null` на всех потребителях (`_partitionOpeningCuts`, `space-render.ts:212–218`). - Full-depth cut, jamb returns, diagonal/1-15-100см — реализовано и протестировано юнитами против скомпилированного (не переизобретённого) кода. - Delete/Undo/Redo — атомарность одним `CommandStack`-снапшотом, без обходных путей удаления без confirmation. - HA-состояние/actions/lock-инвариант не расширяются и не ветвятся по host kind — соответствует `docs/SCOPE.md`. - #185 действительно исправлен для legacy room-wall openings, воспроизводимо (red на `origin/dev`-бандле, green на текущем). - i18n: все шесть новых ключей есть в en и ru, без утечки английского текста в ru.json, плейсхолдеры совпадают. - Трейлеры: единственный класс-A коммит `9f77e3e` несёт `Issue: #132`, `User-Visible: yes`, и в этом же коммите правки обоих changelog (проверено `git show 9f77e3e --stat`). Документационные коммиты — `User-Visible: no`, корректно. - `docs/CONFIG-COMPATIBILITY.md` — новая секция `host` оформлена по установленному в файле паттерну (сравнение с секцией #157). - Восемь канонических документов (`CANVAS.md`, `WALL-THICKNESS.md`, `LIGHT.md`, `SUN.md`, `UX-MODES.md`, `ARCHITECTURE.md`, `USER-GUIDE.ru.md`, `TESTING.md`) обновлены содержательно и непротиворечиво друг другу. - Bundle freshness: три копии (`dist/`, `custom_components/houseplan/ frontend/`, `demo/srv/assets/`) побайтово идентичны после чистой сборки. ## Чего не проверял - **`tests_backend/test_ha_import_export.py` не выполнялся** — в этом окружении нет `homeassistant`/`.venv-backend`; `conftest.py` молча игнорирует `test_ha_*.py` без него (задокументированное поведение). Именно High-1 находится в файле, который этот тест покрывает — находка получена чтением, не прогоном; автор/CI на Linux обязаны подтвердить фикс тем же тестом с добавленным hosted-host фикстурным случаем. - Не гонял оставшиеся 129 из 141 browser-смоков — выбор 12 покрывает по одному представителю на каждый AC-кластер (placement/geometry/topology/ light/HA/regression/render), не весь набор поверхностей разом. - Не гонял `performance_smoke`/выделенные performance-профили — AC11 их не называет по имени, diff не трогает файлы `demo/smoke_performance*.mjs`; вывод по AC11 сделан чтением кеш-инвалидации. - Golden: не расследовал до пикселя причину `different` на `plan-snap-line-gaps-dark` — по превью выглядит как последствие удаления type-agnostic opening-cut из structural snap-preview (ожидаемо для #185), но точная причина не подтверждена построчно. Эталон не тронут в этом diff — обновление (если diff признают корректным) идёт стандартным `golden:accept --reviewed` на полном Linux CI артефакте, не в рамках этого ревью. Транзиентный `error` на `openings-filled-tunnel-dark` при первом прогоне не воспроизвёлся при повторном — похоже на конкуренцию ресурсов от параллельных фоновых процессов на этой машине, не расследовал глубже. - Не гонял `smoke_opening_measure.mjs` — по `AGENTS.md` он уже нестабилен на этом Chromium независимо от этой задачи (`known environment-sensitive smoke`), диагностический шум не относится к #132. - Composite (coincident partition + room-wall) double-cut подтверждён только чтением кода и косвенно golden-сценами; отдельного unit/smoke на именно этот сценарий нет (зафиксировано выше при разборе AC2, не заведено отдельным issue — граница между «стоит добавить» и «доказано чтением» решена в пользу второго, так как код однозначен). - Touch-специфичный multi-pointer сценарий для partition-drag — проверен чтением generic `_stagePointerCancel`, не эмулировал реальный multi-touch в браузере. ## Вердикт Красный. High: 1, Medium: 3 (заведены #186, #187, #188). AC9 не выполнен из-за High-1: `build_space_merge()` не ремапит `opening.host.id` при смене id partition, из-за чего экспорт/импорт (дублирование) любого space с partition-hosted проёмом безусловно отклоняется backend-валидацией. Это блокирующий дефект по прямо заявленному в ТЗ AC, а не крайний случай. Остальные 11 AC пройдены (частично — с проверкой чтением там, где отдельного теста нет, см. разбор по AC и «Чего не проверял»); дешёвые гейты и целевые смоки/golden зелёные. После исправления High-1 (ремап `opening.host.id` через тот же `id_map`, что и остальные ссылки в `build_space_merge`) и добавления регрессионного теста в `test_ha_import_export.py`, ожидаю зелёный вердикт без повторного разбора остальных AC — они не затронуты фиксом.