Files
houseplan-card/docs/reviews/CODE-REVIEW-132-r1.md
T
2026-08-18 22:28:09 +00:00

383 lines
32 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-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 — они не затронуты фиксом.