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

32 KiB
Raw Blame History

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