Files
houseplan-card/docs/reviews/CODE-REVIEW-420-r1.md
T
2026-09-02 16:53:23 +00:00

209 lines
17 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-420-r1
Issue: [#420 — Support-пакет: value_badge.enabled/position копируются дословно в обход типизированной проекции](https://github.com/Matysh/houseplan-card/issues/420)
Ветка: `issue/420-support-badge-projection`
SHA материала ревью: `c5d9775c7a17a0ba3434fc9bc179f965f5c6455e` (сверено `git rev-parse HEAD` перед подведением итогов)
Трек: `trivial` (ТЗ — тело issue, ревью спеки — комментарий аналитика)
Заход: r1 · блокирующих циклов израсходовано 0 из 2
## Скоуп
Единственный продуктовый коммит `c5d9775c` (`fix: type-check support badge
projection`), трейлеры `Issue: #420` / `User-Visible: yes` на месте.
```
custom_components/houseplan/support_package.py | 26 +++++++++++++++++------
docs/CHANGELOG.md | 4 +++
docs/CHANGELOG.ru.md | 4 +++
tests_backend/test_support_package.py | 49 +++++++++++++++++++++++++-
```
Класс изменений: A (`custom_components/houseplan/**/*.py`) + B (тест) + C
(changelog). `src/**`, `validation.py`, `MARKER_SCHEMA`, `demo/**` не тронуты.
Геометрия/`layout`/`marker.space`/`open_spans`/толщина стен не затронуты —
проекция экспортного дифф-безопасного диагностического пакета, а не хранимой
конфигурации или геометрии.
## Что делает изменение
`_project_marker()` раньше копировал `value_badge.enabled` и
`value_badge.position` через generic `_copy_keys()` — без проверки типа
значения. Новая функция `_project_value_badge()` строит typed allowlist:
`enabled` — только точный `bool`, `position` — только строка из
`{right, bottom, left, top}`, `source` — прежняя типизированная
`_project_value_source()`. Неизвестные ключи (например будущий `future`) и
неверно типизированные значения отбрасываются на своей глубине; если после
проекции не осталось ни одного поля — `value_badge` не экспортируется вовсе.
## Как проверялось
| Гейт | Команда | Результат |
|---|---|---|
| TypeScript типы | `npx tsc --noEmit` | зелёный, без вывода |
| Frontend unit-тесты | `npm test` | `1742 passed, 1 skipped` (совпадает с заявленным автором числом) |
| Production build | `npm run build` | `tsc --noEmit && rollup -c` — зелёный, `dist` собран за 10.5s |
| Backend lint (ruff, пин 0.16.5 из `tests_backend/requirements.txt`) | `ruff check custom_components/houseplan/support_package.py tests_backend/test_support_package.py` | `All checks passed!` |
| Backend mypy strict | не прогонялся | `support_package` не входит в strict-allowlist `pyproject.toml` (`const`, `projection`, `coordinate_canonicalization`, `frontend_asset_manifest`, `junction_limits`, `plans`) — гейт неприменим к этому файлу |
| `python -m pytest tests_backend/test_support_package.py` (пинned `pytest-homeassistant-custom-component==0.13.357`) | не прогонялся штатно | пакет `custom_components.houseplan` при импорте тянет реальный `homeassistant` через `__init__.py`; пинned-набор из `tests_backend/requirements.txt` в этой песочнице не устанавливался (тяжёлая установка HA/phcc). Вместо этого — прямое исполнение модуля, см. ниже |
| `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | зелёный вывод инструмента | `Исполняемого frontend-диффа нет (src/**/*.ts не тронут). Browser-smoke этим диффом не выбираются` — это «выбирать нечего», не пропуск |
| `node scripts/check-docs.mjs` | не прогонялся | diff не трогает `src/**` — условие прогона не выполнено |
| `npm run golden:verify` | не прогонялся | diff не меняет рендер/геометрию/стили — не применимо |
| `npm run invariants` | не прогонялся | diff не трогает рёбра комнат, толщину, `layout`, `marker.space`, `open_spans` — не геометрическое изменение |
### Прямое исполнение модуля (заменяет недоступный pytest-прогон)
`support_package.py` намеренно не импортирует Home Assistant (собственный
докстринг модуля), только `custom_components/houseplan/__init__.py`
безусловно импортирует `homeassistant.components.frontend` — то же самое
ограничение, что автор указал в хендофф-комментарии. Обойдено через
stub-пакеты в `sys.modules`, минуя реальный `__init__.py`, и модуль загружен
как **настоящий, неизменённый файл из рабочего дерева** — не переписанная
копия теста.
Так воспроизведены оба ключевых сценария теста `test_support_package.py`:
1. **Полный пакет с sentinel** (та же конфигурация, что и `_source()` в
`tests_backend/test_support_package.py:29-90`, включая
`value_badge.enabled = {"private": "value-badge-private-sentinel"}`,
`position = [...]`, `source.kind = "private"`, лишний ключ `future`):
на **текущем** коде (`c5d9775c`) `"value-badge-private-sentinel" in raw.decode()` →
`False`, `marker["value_badge"]` отсутствует целиком (все поля
провалились по типу/allowlist) — соответствует AC2 буквально: «если после
проекции не осталось допустимых полей, `value_badge` отсутствует
целиком».
2. **Тест умеет падать** (обязательная дисциплина ревью): та же
конфигурация прогнана через **старый** `support_package.py`
(`git show origin/dev:...` во временный файл, загружен тем же приёмом).
На старом коде `"value-badge-private-sentinel" in raw.decode()` → **True**,
`marker["value_badge"] == {'enabled': {'private': 'value-badge-private-sentinel'}, 'position': ['value-badge-private-sentinel']}` —
утечка воспроизведена документально. Правка меняет `True → False` на
идентичном входе — это и есть доказательство, что тест красный без фикса и
зелёный с ним.
3. Четыре параметризованных случая из
`tests_backend/test_support_package.py:344-357`
(`({"private": …}, "right") → {"position": "right"}`,
`(True, {"private": …}) → {"enabled": True}`,
`([], "sideways") → None`, `("true", ["bottom"]) → None`) и случай
`test_value_badge_projection_omits_empty_result` (все три поля невалидны →
`value_badge` отсутствует) — воспроизведены исполнением `_project_marker`
напрямую, результат в каждом случае совпал с ожиданием теста дословно.
4. Валидный полный набор (`enabled: True, position: "bottom",
source: {"kind": "derived_lqi"}`) сохраняется без изменений — AC1.
Это исполнение конкретного, неизменённого продуктового модуля на входных
данных из реального тест-файла, а не пересказ логики по чтению — записываю
как «доказано исполнением», а не «прочтением».
### Соответствие allowlist контракту фронтенда
`src/types.ts:101` — `ValueBadgePosition = 'right' | 'bottom' | 'left' |
'top'` — ровно та же четвёрка, что и `_VALUE_BADGE_POSITIONS` в
`support_package.py`. `src/types.ts:104-108` — `ValueBadgeSource.kind` ∈
`{entity_state, entity_attribute, derived_lqi, derived_marker_state}` — ровно
множество, которое различает уже существующая `_project_value_source()`
(переиспользована без изменений). Allowlist не изобретён ревьюером и не
автором задним числом — он списан с действующего контракта фронтенда,
который проекция обязана пережить.
## AC — построчно
- **AC1 (корректная проекция, backend).** Доказано исполнением (раздел выше,
случай 4 и тест `test_rich_plan_projection_preserves_safe_structure_and_drops_unknown_values`,
строка diff `tests_backend/test_support_package.py:311-313`): валидные
`enabled: bool`, `position` из четвёрки, допустимый `source` проходят без
изменений; посторонние ключи `value_badge` (пример — `future` в sentinel-
конфиге) никогда не попадали в вывод ни в старом, ни в новом коде — они не
входили в allowlist `_copy_keys(("enabled", "position"))` изначально, так
что это не регрессия, а неизменная часть контракта. **Выполнено.**
- **AC2 (fail-closed, backend).** Доказано исполнением: объект/массив/строка
вместо `enabled`, невалидная/нестроковая `position`, вложенные
sentinel-ключи не попадают в байты пакета (полный прогон `_source()` на
`c5d9775c` — 0 совпадений); при полностью невалидном `value_badge` ключ
отсутствует целиком (`test_value_badge_projection_omits_empty_result`,
воспроизведено исполнением). Тест **умеет падать** — показано прогоном той
же сцены на коде `origin/dev` (раздел выше, пункт 2): без фикса тот же
ассерт был бы `False`. **Выполнено.**
- **AC3 (совместимость, ревью кода).** `git diff origin/dev...HEAD` не
трогает `validation.py` — `MARKER_SCHEMA` и delta-safe-хранение будущих
литералов остаются `vol.Optional("enabled"): object` /
`vol.Optional("position"): object` (подтверждено чтением
`validation.py:1750-1763`, там же явный комментарий про lossless-доктрину).
Изменилась только диагностическая проекция support-пакета. Проверено
чтением (по определению AC — «ревью кода», не автотест), подтверждено
отсутствием изменений в файле схемы в диффе. **Выполнено.**
## Single-source-numbers
Изменение не добавляет и не меняет ни одной величины, видимой пользователю
дважды (превью/запись, подпись/площадь и т.п.) — это правка серверной
diagnostic-проекции одного экспортного файла, у `enabled`/`position` в нём и
так был единственный источник (конфиг маркера). Не применимо.
## Находки
Нет.
## Что проверено и корректно
- Логика `_project_value_badge` корректна на всех протестированных
комбинациях типов, включая случай полностью пустого результата.
- `source` внутри `value_badge` использует прежнюю `_project_value_source`
без изменений — риска рассинхронизации с остальными местами, где та же
функция используется (`value_source` на маркере), нет.
- Позиции и виды источника в allowlist совпадают с реальным TS-контрактом
(`src/types.ts`), а не придуманы.
- `validation.py`/`MARKER_SCHEMA` не тронуты — compatibility-контракт
«не отвергать неизменённые данные будущей версии» не пострадал.
- Трейлеры `Issue: #420` / `User-Visible: yes` на коммите корректны; оба
changelog (`docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`) правлены в том же
коммите, формулировки на EN/RU согласованы по смыслу.
- `npm test`, `npx tsc --noEmit`, `npm run build`, `ruff check` — зелёные,
прогнаны лично на `c5d9775c`.
- Диагноз задачи (аудит `AUDIT-2026-09-02-v1700.md` §3.2 B2) устраняется
именно там, где был назван: `value_badge.enabled`/`position` в
`_project_marker`, без побочного расширения на `glow_color.bri`, `display`,
`tap_action` — эти поля вне контракта исправления и в этой задаче
сознательно не тронуты (issue формулирует их как «то же самое, но
прикрыто строгой схемой», не как часть этой правки).
## Чего не проверял
- Полный `pytest tests_backend` через закреплённый набор из
`tests_backend/requirements.txt` (`pytest-homeassistant-custom-component==0.13.357`,
`homeassistant==2026.8.3`) — установка тяжёлого пинned-стека не выполнялась
в песочнице ревью. Компенсировано прямым исполнением немодифицированного
`support_package.py` на тех же входных данных, что и тест-файл, включая
явное воспроизведение «красного» состояния на коде `origin/dev` (см. раздел
«Прямое исполнение модуля»). Тестовые файлы (`.py`) прочитаны целиком и
их ассерты сверены построчно с результатом исполнения — расхождений нет.
- `python -m mypy` — не запускался, файл вне strict-allowlist, гейт
неприменим к этому диффу.
- Browser smoke (`demo/smoke_*.mjs`), `golden:verify`, `npm run invariants`
— не прогонялись; diff не трогает `src/**`, рендер, геометрию или ссылки на
неё, что подтверждено и инструментом `smoke-select.mjs` (нечего выбирать),
и составом diff (`git diff --stat` выше).
- Ручного тестирования UI/браузера не было — изменение целиком серверное, у
UI нет наблюдаемого пути к нему (support-пакет генерируется и скачивается
тем же способом, что и раньше; изменился только внутренний состав байтов
для непривилегированного тестового сценария с sentinel-данными, которых в
реальном фронтенде и так не бывает).
## Материал раунда
- Ветка: `issue/420-support-badge-projection`
- SHA: `c5d9775c7a17a0ba3434fc9bc179f965f5c6455e`
- Диапазон: `origin/dev..HEAD` (один коммит)
- Первый заход (r1), унаследованных разделов нет.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/420-support-badge-projection`, коммит `c5d9775c7a17` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `dbba2a334e77f806dc77aa912a06962fcb1eaa92`
```
git log --all --format='%H %T' | grep dbba2a334e77
```