32 KiB
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 — разбор по каждому критерию
- 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. Пройдено. - 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. - 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заваливает именно эти два поля). Пройдено. - 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 сценарий (см. «Чего не проверял»). - 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— assertionopeningKeepsStructuralAutoCloseсталаfalseна старом бандле иtrueна текущем: воспроизводимо красный → зелёный переход именно от этого коммита, как и требует доказательство AC5 в ТЗ). Для partition-хостов структурная непрерывность тривиальна:buildPlanSnapGeometryникогда не резал partition-оси opening-катами (cuts: []для partition-источников что до, что после). Пройдено, но матрица неполна:smoke_room_autoclose.mjsиsmoke_partition_openings.mjsгоняют толькоtype: 'door'; window/gate/ passage и полный_wallFaceBatch/_roomDialogUI-путь для partition-хоста явно не прогнаны (для partition проверена только структурная снапшот-функция, не UI-диалог). Логика type-agnostic, дефекта не вижу при чтении, но заявленная в §21 ТЗ «матрица по всем четырём типам» не покрыта тестами буквально — фиксирую как незакрытый пробел evidence, не как блокирующую находку (код прочитан и корректен). - AC6 (no passive topology mutation).
_offerWallFaces()вызывается только из двух click-обработчиков внутри Walls-инструмента (houseplan-card.ts:6719, 6827), ни разу — из lifecycle/hass-сеттеров/_editOpening/_saveOpening. Ни один из этих call sites не менялся в этом diff. Пройдено, проверено чтением. - AC7 (render parity). Golden прошёл на всех Plan/Iso/View-сценах, кроме
одной «different» (см. ниже).
smoke_isometric_contract.mjsзелёный. Единый resolver (resolvePartitionOpening) используется и вspace-render.ts, и вhouseplan-card.tsрендер-путях — подтверждено чтением обоих файлов. Пройдено. - 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). Пройдено. - 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. - AC10 (accessibility/touch). Диалог удаления —
hp-dialogс доступным списком (deleteRequiresAccessibleListDialog: trueв smoke). Pointercancel/multi-touch код (_stagePointerCancel) не менялся этим diff и уже покрывал generic drag-отмену по kind+pid. Пройдено, проверено чтением (специализированного touch-смока с реальным multi-pointer эмулятором на partition-drag не гонял — вне моего окружения, см. «Чего не проверял»). - 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файлов — см. «Чего не проверял»). - 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 — они не затронуты фиксом.