Files
houseplan-card/docs/reviews/CODE-REVIEW-186-r1.md
T
claude[bot] a0c4c1cdd8
Validate / docs (push) Failing after 27s
Validate / provenance (push) Successful in 46s
Validate / process-gate (push) Failing after 46s
Validate / changes (push) Successful in 40s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / smoke (push) Skipped
Validate / golden (push) Skipped
Validate / performance_smoke (push) Skipped
Validate / backend (push) Skipped
docs: code review r1 for #186
Issue: #186
User-Visible: no
2026-08-19 13:00:16 +00:00

236 lines
21 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-186-r1 — Безопасный остаток стены у торцов партиционного проёма
Issue: [#186](https://github.com/Matysh/houseplan-card/issues/186)
ТЗ: `docs/specs/186-partition-opening-jamb-margin.md` (зелёное ревью,
`docs/reviews/SPEC-REVIEW-186-r2.md`)
Диапазон: `origin/dev..HEAD` — единственный продуктовый коммит
`57ba75b` «fix: keep jamb margin on partition openings»
(`Issue: #186`, `User-Visible: yes`, оба changelog в том же коммите).
Ветка: `issue/186-partition-jamb-margin`.
## Скоуп
Введён физический jamb safety margin (половина фактической толщины
независимой стены) у обоих торцов hosted-проёма (`door`/`window`/`gate`/
`passage`): разделены compat/read (`resolvePartitionOpeningCompat`, margin=0)
и strict/write (`resolvePartitionOpeningStrict`) резолверы, placement и drag
клэмпятся к новой границе, dialog/rebind объясняют отказ локализованным
сообщением, backend `config/set`/`optimize` применяют тот же контракт как
semantic delta validation, а structural schema и full backup/restore остаются
на прежней zero-margin границе, чтобы не терять уже сохранённые near-end
проёмы. Соответствует J4/J6 (`docs/SCOPE.md`) и не расширяет скоуп ТЗ.
## Как проверялось
| Гейт | Результат |
|---|---|
| `npx tsc --noEmit` | green |
| `npm test` | green, 906/906 |
| `npm run build` + сверка трёх копий бандла (`dist`, `custom_components/.../frontend`, `demo/srv/assets`) | green, побайтово идентичны |
| `node demo/smoke_partition_openings.mjs` (назван в AC2/AC3, поверхность прямо тронута) | green, все поля результата `true`, включая 5 новых (`placementClampsToHalfThicknessJamb`, `directDragStopsAtSameJambBoundary`, `dialogShowsJambSpecificGuidance`, `invalidLengthWritesNeitherConfigNorHistory`, `tooShortPartitionHasNoPreviewAndExplainsWhy`) |
| `python -m pytest tests_backend/test_validation.py -q` (только этот файл тронут напрямую) | green, 132/132 (совпадает с хендоффом автора) |
| `python -m pytest tests_backend -q` (полный офлайн-прогон, без `homeassistant`) | green, 165 passed; `test_ha_import_export.py`/`test_ha_websocket.py` молча пропущены `conftest.py` — HA не установлена в среде ревью, это ожидаемо (`AGENTS.md`: канонический прогон — Linux CI) |
| `npm run golden:verify` (полный набор, Linux) | green, все сценарии `passed`, включая `opening-placement-door-thick-wall-dark`, `opening-placement-passage-thick-wall-{dark,light}`, `openings-thick-wall-dark`, `openings-filled-tunnel-dark`, `openings-hidden-view-dark` — прогнан сверх минимума, т.к. diff меняет clamp/placement геометрию, а не только текст |
| `node demo/smoke_opening_measure.mjs` (побочная проверка того же placement-пути) | green (в этой среде извечная pixel-precision особенность из `AGENTS.md` не воспроизвелась) |
### Не проверялось (и почему)
- `tests_backend/test_ha_import_export.py`, `tests_backend/test_ha_websocket.py` —
новые тесты в этих файлах **не исполнены**: в среде ревью нет `homeassistant`
и нет `.venv-backend` (он есть только на облачных агентах по `AGENTS.md`).
Разобраны построчным чтением (см. AC4/AC6 ниже) — «проверено чтением, не
исполнением».
- Полный browser smoke suite (127 файлов) — не прогонялся; diff не задевает
весь фронтенд, только `partition-openings`/`opening-placement`/opening
dialog. Прогнаны целевой smoke плюс побочный `smoke_opening_measure`.
- `performance_smoke` — не прогонялся; AC не называет perf-эффект, а ТЗ §13
обосновывает `O(1)` без новых проходов геометрии; изменение ограничено
сравнением чисел на уже существующих объектах.
- Ручного тестирования в UI не было (по процессу его нет в цикле).
## Проверка AC
### AC1 — единая физическая граница
**Доказано автотестом, тест умеет падать.** `test/partition-openings.test.mjs`
(«strict jamb policy follows thickness, scale and host direction») перебирает
`cm ∈ {1,15,100}`, `(cellCm,gridPitch) ∈ {(5,5),(2.5,8)}` и оба направления
хоста, вычисляет `exactT` **независимо** от кода через ту же явную формулу и
проверяет: точная граница — resolved, `exactT - 1e-5` — `does-not-fit-jamb`.
Backend-зеркало — `tests_backend/test_validation.py::test_partition_opening_jamb_margin_exact_boundary_and_epsilon`,
параметризован по тем же `cm`/`cell_cm`, обоим направлениям и всем четырём
типам проёма; исполнен зелёным (132/132 включает эти кейсы). Независимая
проверка чтением: `partitionOpeningJambMargin` (`src/partition-openings.ts:36-40`)
и backend `margin = cm/2/cell_cm/240` (`validation.py:141-143`) — та же формула,
что зафиксирована в ТЗ §4, `240` вынесена в именованную константу
`NORMALIZED_CANVAS_CELLS` с комментарием о синхронизации с `GRID_STEP_N`/`NORM_W`.
Тест на равенство границы **умеет падать** — если margin/эпсилон перепутать
знаком или множителем, `exactT`/`exactT-1e-5` разойдутся с реальным резолвером
(значения жёстко вычислены в тесте, а не взяты из его же вывода).
### AC2 — placement и drag не создают плохую geometry
**Доказано автотестом + smoke, тест умеет падать.**
`resolveOpeningPlacementResult` (`src/opening-placement.ts:258-296`) требует
`renderedLength + 2·physicalHalfWidth <= hostLength` для eligibility и
клэмпит `along` в `[half, length-half]`, где `half = renderedLength/2 + jambMargin`
(строки 320-338) — совпадает с ТЗ §7.2 дословно. `test/opening-placement.test.mjs`
проверяет конкретные числа (`x=25`/`x=75`, `t=0.25`/`t=0.75`,
`labels[i].distance=10`) для partition-хоста и **отдельно** нулевой jamb для
room-wall (`distance=0`) — тест на регрессию §7.2 «room-wall placement
сохраняет прежнюю границу». Смок `demo/smoke_partition_openings.mjs`
подтверждает end-to-end через реальный DOM/pointer events: клик у торца
клэмпится (`placementClampsToHalfThicknessJamb`), drag существующего проёма к
тому же торцу останавливается на той же границе
(`directDragStopsAtSameJambBoundary`), слишком короткая partition не даёт
preview и объясняет причину через toast (`tooShortPartitionHasNoPreviewAndExplainsWhy`).
Прочитан код клика/drag (`src/houseplan-card.ts:11298-11321` и `:11482-11500`)
— оба пути используют один и тот же resolved-кандидат (`jambMargin =
partitionOpeningJambMargin(...)`, `shoulder = half + jamb`), других мест
клэмпа для partition-хоста не найдено.
### AC3 — dialog и rebind объясняют отказ
**Доказано smoke + разбором кода.** `dialogShowsJambSpecificGuidance` и
`invalidLengthWritesNeitherConfigNorHistory` в `smoke_partition_openings.mjs`
воспроизводят реальный сценарий: `_editOpening` → увеличение длины до 600 см
→ banner с RU/EN текстом margin → `_saveOpening()` не меняет ни `space.openings`
(сравнение `JSON.stringify` до/после), ни счётчик записей конфига (`writes`),
ни `_geometryHistory`. Тест умеет падать: до правки banner показывал бы
`opening.host_partition` вместо margin-текста, а `_saveOpening()` записал бы
конфиг — оба факта проверяются прямым сравнением, а не полагаются на
побочный эффект другого поля. Разбор кода (`src/houseplan-card.ts:17641-17694`)
подтверждает, что dialog различает `orphan` (missing host) и `jambInvalid`
(does-not-fit-jamb) отдельными флагами и не путает их тексты; `lengthTouched`
(`:1342`, `:11635-11636`, `:17650-17655`) корректно не опто́м вводит длину
округлённого legacy-значения в strict-проверку при binding-only правке (type/
contact/lock/invert/flip) — независимая проверка по коду совпадает с ТЗ §7.3 и
принятым предположением №2 (§16). Rebind: `orphanRebindReplacesHostWithoutDuplicate`
в том же smoke подтверждает успешный rebind на валидный host (существующий
сценарий #132, не задет регрессией).
### AC4 — legacy compatibility
**Доказано автотестом (frontend) + разбором кода и backend-тестами (не
исполнены).** Frontend: «strict writes reserve half the real wall depth while
compat reads remain visible» (`test/partition-openings.test.mjs`) явно строит
near-end проём (`t=0.224`, внутри jamb) и проверяет, что `resolvePartitionOpening`
(compat, margin=0) всё ещё резолвит его для всех четырёх типов, тогда как
`resolvePartitionOpeningStrict` возвращает `does-not-fit-jamb` — то есть тест
умеет различать «остался видимым» и «отклонён при прямой правке» и упал бы,
если бы кто-то заменил compat-путь на strict в рендере. `_physicalUp` (rigid
translation, `:7472-7480`) и все read-пути (`space-render.ts:214`,
`houseplan-card.ts:7940,7973,11324,11667`) используют `resolvePartitionOpeningCompat`
— построчно проверено, что бывший «голый» вызов `resolvePartitionOpening` с
неявным `jambMargin=0` (сама находка issue) нигде не остался: `grep` по всем
вызовам показывает только `Compat`/`Strict`, внутренний `resolvePartitionOpening`
вызывается лишь изнутри этих двух обёрток.
Backend delta-валидатор (`custom_components/houseplan/validation.py:110-152`)
включает strict-проверку только если `host.id`/`host.t`/`opening.length`/
`partition.cm`/`span` изменились относительно `previous` — **прочитано и
независимо пересчитано**: rigid-перенос (одинаковый `span`, одинаковый `cm`,
одинаковые `host.id/t/length`) не триггерит strict, посторонняя правка (смена
`title`) тоже. `tests_backend/test_validation.py::test_partition_opening_jamb_delta_preserves_legacy_and_checks_direct_geometry`
(исполнен, зелёный) покрывает четыре ветки: unrelated-edit, type-only, rigid
translation — allow; и четыре прямых геометрических изменения (`t`, `length`,
`cm`, `span` через новое `b`) — reject. Тест умеет падать: если `strict`
неверно посчитать как `False` для любой из четырёх мутаций, `pytest.raises`
не сработает.
Full import/restore: **прочитано, не исполнено** —
`custom_components/houseplan/import_export.py:1324-1334` (`prepare_apply`,
`kind == "full"`) не проходит через `build_space_merge`/
`validate_partition_opening_hosts` вообще, только через структурную
`CONFIG_SCHEMA` на выходе (та же неизменная `does-not-fit` проверка,
`validation.py:906-925`, unrelated diff). Значит full restore физически не
может получить `PartitionOpeningJambMarginError` — соответствует decision #4
и §8 ТЗ. Новый тест `test_full_preview_preserves_legacy_near_end_partition_opening`
(`tests_backend/test_ha_import_export.py`, не исполнен: нет HA) параметризован
по `current_kind ∈ {"empty","other"}`, что покрывает decision #4 дословно
(«на пустой и на другой инсталляции»); по чтению кода `create_preview`
(`import_export.py:1141-1181`) не зависит от `current_config` для ветки
`kind != "space"`, так что ожидание «зелёный на обоих current_kind» логически
устойчиво, но сам прогон не подтверждён исполнением — фиксирую как честный
пробел, не блокирующий (см. «чего не проверял»).
### AC5 — overlap и остальные host не меняются
**Проверено чтением, не исполнением, плюс существующий регресс-набор.**
`hostedOpeningIntervalsOverlap` не встречается в диффе (`git diff` пуст по
этой функции в обоих языках); overlap-проверка backend
(`validation.py:920-924`) и структурная fit-проверка (`:917-919`) — те же
строки, что до PR, jamb margin в них не подмешан. Существующие regression-тесты
#132/#157/#193 остаются в `npm test`/`pytest` без изменений и зелёные.
### AC6 — server parity и error code
**Доказано backend/websocket-тестами.** `config/set` и `plan/optimize`
(`websocket_api.py:1258-1263`, `:1373-1378`) оборачивают один и тот же вызов
`validate_partition_opening_hosts` и один except-список с новым
`PartitionOpeningJambMarginError`; `tests_backend/test_ha_websocket.py::test_config_writers_reject_partition_opening_without_jamb_atomically`
параметризован по обоим эндпоинтам и проверяет атомарность (`config/get` и
`layout/get` после отказа возвращают исходные `rev`/данные) — **не исполнен**
в этой среде (нет HA), разобран по коду: оба хендлера действительно вызывают
валидатор до записи (`await`/`store.async_save` после except-блока в обоих
местах, построчно сверено) и `send_error(msg["id"], err.code, str(err))`
формирует `error.code == "invalid_partition_opening_jamb_margin"` и
сообщение, содержащее `space=…; opening=…; margin=…; margin_cm=…`
(`validation.py:59-68`) — совпадает с тестовыми ассертами дословно. Structural
schema (`SPACE_SCHEMA`, unchanged `does-not-fit`) не участвует в этой цепочке
и не может «сломать» round-trip неизменённого конфига — совпадает с §8.
## Что проверено и корректно (сверх AC)
- **Формула единиц исполнена согласованно.** `wallCmToUnits(cm, cellCm,
gridPitch) = clampWallCm(cm)/cellCm*gridPitch` — линейна по `gridPitch`,
поэтому обратное преобразование `(margin/gridPitch)*cellCm` в
`houseplan-card.ts:12816-12819` алгебраически сводится ровно к `cm/2`;
проверено пересчётом руками, не только чтением.
- **Единственное расхождение в способе получения `distance` для диалогового
banner** (`src/houseplan-card.ts:17669-17671`, `jambDistance =
formatLength(hostPartition.cm / 2, …)` — напрямую через `cm`, а не через
`partitionOpeningJambMargin` + обратная конвертация, как везде в других
местах) **не является дефектом**: математически идентично при валидном
`partition.cm`; расхождение возможно только если `partition.cm` вне
`[WALL_MIN_CM, WALL_MAX_CM]` до `clampWallCm`, а ТЗ §16.4 прямо говорит, что
malformed `cm` вне scope #186 и сохраняет действующий schema/fail-dark
contract — сохранённый `partition.cm` уже проходит schema. Отмечаю как
**Low, снята без правки**: чисто стилистическая дупликация формулы, не
влияет ни на один AC и не воспроизводима в валидном состоянии данных.
- **Wall-thickness edit partition.cm вне opening dialog** (`_physicalDialog`,
`houseplan-card.ts:7257-7259`) не проходит через
`partitionOpeningNeedsStrictValidation` на фронтенде — и не должно: ТЗ §7.4
прямо поручает этот случай backend-strict-границе («Изменение длины host или
его `cm`… должно пройти strict backend boundary»), и backend-валидатор
действительно триггерит `strict` при изменении `partition.cm`/`span`
(проверено выше, AC4/AC6). Отказ backend будет показан пользователю через
тот же generic error-mapping (`_mapConfigError`-подобный путь,
`houseplan-card.ts:12812-12820`), это тот же механизм, что уже работает для
`invalid_partition_opening_host`/overlap с #132 — не новый риск.
- **i18n** (`opening.partition_jamb_margin`) добавлен синхронно en/ru и
дословно совпадает с формулировкой ТЗ §7.3.
- **Документация**: `docs/CHANGELOG.md`/`.ru.md`, `docs/USER-GUIDE.md`/`.ru.md`,
`docs/CONFIG-COMPATIBILITY.md`, `docs/ARCHITECTURE.md`, `docs/TESTING.md`
обновлены в том же коммите, терминология («независимая стена», «торец»,
«откос»/jamb) соответствует `docs/USER-GUIDE.ru.md`, не изобретена заново.
- **Трейлеры и changelog-требование**: единственный продуктовый коммит несёт
`Issue: #186` и `User-Visible: yes`, оба changelog правлены в нём же —
соответствует PROCESS.md §2.6/§10.2.
## Находки
Нет High. Нет Medium — ни в скоупе, ни вне скоупа. Один Low, снят без правки
(см. выше, «Единственное расхождение…») — чисто стилистический, не
воспроизводится при валидных данных, не влияет ни на один AC.
## Вердикт
Зелёный. Все шесть AC либо доказаны исполненным автотестом с проверенной
способностью упасть, либо разобраны построчным чтением с явной пометкой
«проверено чтением, не исполнением» там, где локальный HA-harness недоступен.
Формула margin идентична на frontend и backend, все исходные call sites из
диагноза issue закрыты явным compat/strict выбором, legacy near-end проёмы
и full backup/restore подтверждённо не регрессируют, golden-набор (полный,
Linux) зелёный без нового baseline.