17 KiB
CODE-REVIEW-420-r1
Issue: #420 — Support-пакет: value_badge.enabled/position копируются дословно в обход типизированной проекции
Ветка: 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:
- Полный пакет с 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отсутствует целиком». - Тест умеет падать (обязательная дисциплина ревью): та же
конфигурация прогнана через старый
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на идентичном входе — это и есть доказательство, что тест красный без фикса и зелёный с ним. - Четыре параметризованных случая из
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напрямую, результат в каждом случае совпал с ожиданием теста дословно. - Валидный полный набор (
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, строка difftests_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), унаследованных разделов нет.
Материал раунда
- Ветка:
issue/420-support-badge-projection, коммитc5d9775c7a17— ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала:
dbba2a334e77f806dc77aa912a06962fcb1eaa92git log --all --format='%H %T' | grep dbba2a334e77