Files
houseplan-card/docs/reviews/CODE-REVIEW-256-r1.md
T
2026-08-23 07:31:55 +00:00

172 lines
16 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 — issue #256 — заход r1
**Скоуп задачи:** `houseplan/config/get` и `houseplan/layout/get` получают
необязательные параметры проекции (`space_id`, `fields`, `marker_fields` /
`space_id`), не меняя ответ по умолчанию. Лёгкий трек (`small`), ТЗ — в теле
issue. Заход первый: `git log --oneline origin/dev..HEAD` даёт один коммит
(`a952f5f`), это полный разбор, дельты по предыдущим раундам нет.
Диапазон материала: `git diff origin/dev...HEAD` — 3 файла, +269/−5:
`custom_components/houseplan/projection.py` (новый, 95 строк), правки
`custom_components/houseplan/websocket_api.py` (+41/−5), `tests_backend/
test_projection.py` (новый, 138 строк, 11 тестов).
## Как проверялось
Прочитаны в порядке из инструкции: `docs/SCOPE.md`, `AGENTS.md`, тело issue
#256 и оба комментария (спека и отчёт автора), `PROCESS.md` §2.7/§2.10/§4.
Изменение не трогает видимое поведение (`User-Visible: no`, подтверждено
диффом — нет правок в `docs/CHANGELOG*`), поэтому `docs/USER-GUIDE.ru.md` и
канонические документы подсистем не задействованы: меняется только
серверный WS-протокол для диагностических клиентов, UI не тронут.
### Гейты — что прогнал сам и с каким результатом
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | чисто, exit 0 |
| JS-тесты | `npm test` | 1136/1136, 0 fail |
| Сборка + сверка бандла | `npm run build`, затем `diff dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` | сборка ок; diff пустой (файлы идентичны) — ожидаемо, диффа в `src/**` нет |
| Backend pure-тесты (новые) | `python3 -m pytest tests_backend/test_projection.py -v` (после `pip3 install --user pytest pytest-asyncio`, песочница не имела pytest) | **11/11 passed** |
| Backend pure-подмножество целиком | `python3 -m pytest tests_backend -q --ignore=tests_backend/test_coordinate_canonicalization.py --ignore=tests_backend/test_validation.py` | **51 passed** — совпадает с числом, заявленным автором |
| Дисциплина «тест умеет падать» | вручную сломал `keep = {"id", *names}` → `{*names}` в копии `projection.py`, перезапустил `test_projection.py`: **4 теста упали** (потеря `id`), включая `test_marker_fields_keep_id_even_when_not_asked` и `test_markers_survive_unexpected_entries`. Отдельно занулил условие раннего возврата (`if False: return config`) — упали **2 теста инварианта идентичности** (`test_no_parameters_change_nothing`, `test_empty_or_malformed_lists_mean_no_projection`). Оба раза файл восстановлен из бэкапа, `git status` чист | тесты не ватные |
| `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | — | «Исполняемого frontend-диффа нет (`src/**/*.ts` не тронут). Browser-smoke этим диффом не выбираются — выбирать нечего.» |
Полный вывод `git status --short` после всех экспериментов — пусто, рабочее
дерево осталось чистым.
### Что не прогонял и почему
- **`node scripts/check-docs.mjs`** — не прогонял. Условие запуска — diff
трогает `src/**`; этот diff `src/**` не трогает (подтверждено и
`git diff --stat`, и выводом `smoke-select.mjs`). Отпечаток документации
считается по `src/**`, изменение в него не входит.
- **`npm run invariants -- --config <...>`** — не прогонял. Diff действительно
упоминает `layout` и `marker.space`, но только как *ключи фильтрации при
чтении* — ни один путь записи (`config/set`, `layout/set`, `plan/optimize`)
не тронут, хранимый документ проекция не мутирует (проверено чтением и
тестами `test_no_parameters_change_nothing`, `is CONFIG`/`is LAYOUT`).
Инварианты модели (#254) — про целостность *хранимых* данных; здесь модель
не меняется, поэтому гейт неприменим, а не пропущен.
- **`golden:verify`, browser-смоки** — не прогонял; рендер, геометрия и стили
не затронуты, `smoke-select.mjs` подтверждает: выбирать нечего.
- **HA-харнесс (`test_ha_websocket.py` и соседние)** — не прогонял. В
песочнице нет `homeassistant` (тот же пробел независимо воспроизведён на
`origin/dev` — `test_coordinate_canonicalization.py`/`test_validation.py`
падают с `ModuleNotFoundError` и там, это не регрессия задачи). Проводка
двух обработчиков (`ws_config_get`, `ws_layout_get`) разобрана **чтением, не
исполнением** — см. AC2/AC4 ниже; фактический прогон харнесса — за CI.
- **`hassfest`/HACS** — манифест не менялся, гейт неприменим.
- **Perf-профили** — не названы в AC, путь не относится к чувствительным к
перфу (проекция — плоская фильтрация словаря/списка на чтении, O(n) от
размера уже загруженного документа).
- **Одно число — один источник** — неприменимо: изменение не добавляет и не
меняет пользовательскую величину, это диагностический протокол, не UI.
## AC — доказательства
1. **`config/get` без параметров — ответ прежний.** Доказано тестом
`test_no_parameters_change_nothing` (проверяет тождество объекта `is
CONFIG`, не только равенство) и чтением кода: `ws_config_get` строит
`config` ровно как раньше, вызывает `project_config(config, space_id=None,
fields=None, marker_fields=None)`, а та при всех трёх `None` возвращает
входной объект без копии (`projection.py:56-58`). Тест умеет падать —
проверено (см. таблицу гейтов).
2. **`space_id` возвращает одно пространство, `markers`/`settings` не
урезаны.** Доказано `test_space_id_narrows_spaces_only` и
`test_unknown_space_returns_empty_list_not_an_error`. Плюс чтением:
`project_config` трогает только ключ `spaces`, остальные ключи копируются
как есть (`dict(config)`).
3. **`marker_fields: ["binding","space"]` оставляет `id`+два поля.** Доказано
`test_marker_fields_keep_id_even_when_not_asked` (сверка точного словаря) и
разрушающим экспериментом выше (тест ловит потерю `id`).
4. **`layout/get` с `space_id` — только позиции этого пространства.**
Доказано `test_layout_space_filter`. Проводка в `ws_layout_get` —
**проверено чтением, не исполнением**: `project_layout(data.get("layout",
{}), space_id=msg.get("space_id"))` подставлена на место прежнего
`data.get("layout", {})`, `rev`/`can_optimize_undo`/`undo_kind` считаются
из `config_data`/`data`, которые проекции не касаются — инвариант «флаги
считаются до проекции» не может быть задет этой правкой хотя бы потому, что
строки, их вычисляющие, физически не изменены (diff это подтверждает).
Инвариант 2 из ТЗ («`rev`, `can_write`, `virtual_lights`, `can_optimize_undo`,
`undo_kind` не зависят от проекции») — проверено чтением: в `ws_config_get`
`virtual_lights` считается **до** вызова `project_config`, из непроецированного
`config`; `can_write`/`can_optimize_undo`/`undo_kind` используют `data`/
`config_data`, не `config`. Порядок операций в диффе это гарантирует
структурно, не только по намерению.
Инвариант 3 («проекция только для чтения») — проверено чтением: ни один из
трёх обработчиков записи (`config/set`, `layout/set`, `plan/optimize`) не
импортирует и не вызывает `projection.py` — `grep` по diff и по текущему
`websocket_api.py` подтверждает единственную точку импорта.
Инвариант 4 (неизвестное имя поля — не ошибка; неизвестный `space_id` — пустой
список) — доказано `test_unknown_space_returns_empty_list_not_an_error`,
`test_unknown_marker_field_adds_nothing`.
## Разобрано и корректно
- Проекция не мутирует исходный документ: `project_config` делает `dict(config)`
(мелкое копирование) и никогда не пишет в `config[...]` до этого; `spaces`/
`markers` заменяются новыми списками/словарями, а не редактируются на месте.
Подтверждено и тестами (`CONFIG == original` после вызова), и разрушающей
проверкой не потребовалось — код тривиально проверяем чтением.
- Порядок применения фильтров (`space_id` → `marker_fields` → `fields`)
корректен даже в комбинации «`fields` не включает `markers`, но
`marker_fields` задан»: `marker_fields` спроецирует `markers` до того, как
`fields` вырежет сам ключ `markers` — лишняя работа, не баг.
- Решение «пустой список = отсутствие проекции, а не проекция в ноль полей»
автор явно пометил как оспоримое. Он не наблюдаем ни одной персоной
(доступен только диагностическим клиентам, не описан в AC) — это техническое
решение в духе PROCESS.md §7 («решай и записывай»), а не догадка, выданная
за продуктовый факт. Принимаю: альтернатива (пустой список = «убрать все
поля») не более очевидна и нигде не запрошена.
- `id` всегда добавляется в `marker_fields` — соответствует AC3 буквально и
сопровождается тестом и комментарием, объясняющим «почему».
- Схемы `vol.Optional`/`vol.All(...vol.Length(...))` на `space_id`
(1–200 символов), `fields`/`marker_fields` (список строк 1–100 символов,
максимум 50 элементов) — разумные защитные пределы, симметричные между
`config/get` и `layout/get`.
- Загрузка модуля в тестах по пути (`importlib.util.spec_from_file_location`),
а не через пакет — повторяет приём из `test_virtual_lights.py` и обосновано
тем же #135; сам `projection.py` не импортирует ничего, кроме `typing`, что
и позволяет тесту работать без `homeassistant`. Подтверждено эмпирически:
`test_projection.py` собрался и прошёл в песочнице без HA/voluptuous, тогда
как соседние файлы, тянущие пакет, — нет.
- Трейлеры коммита `a952f5f`: `Issue: #256`, `User-Visible: no` — корректно,
изменение не даёт пользователю ничего нового, changelog обоснованно не
тронут.
- Продуктовая рамка не нарушена: `docs/SCOPE.md` не содержит прямого пункта
под диагностические WS-параметры, но задача явно заведена самим ревью
(аудит инфраструктуры 2026-08-23) и принята владельцем на уровне issue —
скоуп решён на входе, пересматривать на этапе кода нечего.
## Находки
Нет находок High или Medium. Ниже — то, что рассмотрено и сознательно не
поднято до находки:
- **Low, не блокирует, не правлю:** порядок `space_id`→`marker_fields`→
`fields` в `project_config` иногда строит `markers`-проекцию, которую тут же
выбрасывает `fields`-фильтр (см. выше). Чисто эффективность на маленьких
документах (максимум пара сотен маркеров), не корректность. Не стоит цикла
ревью.
- Рассмотрел как потенциальную Medium-находку: решение «пустой список =
нет проекции» могло быть продуктовым вопросом («хочу узнать только `rev`,
без конфигурации вообще»), а не техническим. Отклоняю как находку: ни один
реальный клиент (карточка, диагностика) не отправляет пустой список сегодня,
случай гипотетический, вариант автора не хуже альтернативы, и не наблюдаем
ни одной из трёх персон продукта — чисто внутренний контракт диагностического
API. Не эскалирую владельцу: это не вопрос «что видит пользователь».
## Вердикт
Все 4 AC доказаны автотестами, тесты проверены на способность падать. Оба
инварианта совместимости (байт-в-байт без параметров; `rev`/флаги считаются до
проекции) проверены и чтением, и тестами. Запись не затронута. Гейты,
соразмерные объёму задачи (typecheck, JS-тесты, build+bundle-сверка, pure
backend-тесты), зелёные; неприменимые гейты названы с обоснованием.
**Зелёный.**