Files
houseplan-card/docs/reviews/CODE-REVIEW-178-r1.md
T
2026-08-18 18:47:17 +00:00

174 lines
18 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.
# Код-ревью #178 — выбор точной сущности для действия «Переключить состояние»
- **Issue:** https://github.com/Matysh/houseplan-card/issues/178
- **Цикл:** r1/4 (обычный трек — `small`/`trivial` не назначались)
- **Диапазон:** `origin/dev..HEAD`, коммиты `e46ef6f…f46be0e`, рабочий коммит
реализации `8086399` (`feat: select exact toggle entity`)
- **ТЗ:** `docs/specs/178-toggle-entity.md`, зелёное ревью r2
(`docs/reviews/SPEC-REVIEW-178-r2.md`)
- **Ревьюер:** Claude, свежая сессия, без контекста реализации
## Скоуп
Диапазон затрагивает: `src/device-toggle.ts`, `src/devices.ts`,
`src/houseplan-card.ts`, `src/marker-toggle-entity.ts` (новый), `src/types.ts`,
i18n en/ru; backend `validation.py`/`import_export.py`; unit
(`test/device-toggle.test.mjs`, `test/marker-toggle-entity.test.mjs`,
`test/native-select-contract.test.mjs`, `test/golden-matrix.test.mjs`) и backend
(`tests_backend/test_validation.py`, `tests_backend/test_ha_import_export.py`)
тесты; новый smoke `demo/smoke_toggle_entity.mjs`; golden-матрица (2 новых
сценария, без capture); документация (ARCHITECTURE/CONFIG-COMPATIBILITY/
USER-GUIDE ru+en/CHANGELOG ru+en); `docs/specs/README.md`. Коммит `f46be0e`
— чисто C-класс (обновление fingerprint скриншотов после ребилда), не несёт
продуктового кода.
## Как проверялось
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | зелёный, без вывода |
| Unit | `npm test` | `856/856`, 0 fail |
| Build + bundle parity | `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` | build зелёный; обе копии побитово совпадают с `dist/`, `git status` после сборки чист |
| Backend, чистый Python | `pip install pytest pytest-asyncio voluptuous` (среда ревью не содержала pytest); `python -m pytest tests_backend -q` | `138 passed`. **Важно:** `homeassistant` в среде ревью не установлен, `conftest.py` игнорирует `test_ha_*.py` целиком — значит, `tests_backend/test_ha_import_export.py` (обе новые/изменённые проверки toggle_entity: `test_export_copies_toggle_entity_literal`, обновлённый `test_space_import_remaps_owned_ids_and_duplicate_policy`) **не выполнялись** этим прогоном. Подтверждено: `pytest tests_backend -k toggle_entity` → `1 passed, 137 deselected` — это единственная проверка из `test_validation.py` |
| Named production-bundle smoke (AC1/2/3/5, §18.2) | `node demo/smoke_toggle_entity.mjs` | `9/9 OK`: `selectorForMultiple`, `savedSelectionProjected`, `selectionUpdatesDraft`, `selectionUpdatesHintBeforeSave`, `writerPersistsSelection`, `reopenRestoresSelection`, `staleWarnsAndShowsAuto`, `staleLiteralSurvives`, `singleEntityHidesSelector` |
| Смежные browser smoke (тот же резолвер: group-toggle и default-light) | `node demo/smoke_ha_controls.mjs`; `node demo/smoke_light_default_tap.mjs` | оба зелёные, все под-проверки `true` |
| Golden | не запускался | Пререлизный гейт по правилу самого ТЗ (§18.4) и PROCESS §8; см. «Чего не проверял» |
| Performance | не запускался | Спецификация заявляет отсутствие нового hot path (§15.1); подтверждено чтением, не измерением |
Дисциплина «тест умеет падать» проверена вручную для показательных случаев, а
не только продекларирована:
- `test/device-toggle.test.mjs` → `explicit own selection joins controls without
changing legacy groups`: без `entries.push(selectedOwnEntity...)` в
`resolveControls` (src/device-toggle.ts:617-623) группа осталась бы только
`['light.external']`, а тест ожидает `['light.external', 'switch.child_lock']`
и `service: 'turn_off'` — упал бы.
- `demo/smoke_toggle_entity.mjs` → `staleWarnsAndShowsAuto`: без узла
`role="status"` с текстом сущности выражение `warning?.textContent?.includes(...)`
даёт `undefined`/`false` — смок падает.
- `tests_backend/test_validation.py::test_toggle_entity_is_domain_bounded_only_when_new_or_changed`:
прогнан лично, `invalid_toggle_entity` действительно поднимается на изменённом
значении, не поднимается на неизменённом future-литерале — код умеет и падать,
и не падать там, где нужно.
## Проверка AC (docs/specs/178-toggle-entity.md §19)
| AC | Доказательство по ТЗ | Как проверено сейчас |
|---|---|---|
| 1 | smoke §18.2.1, golden desktop | smoke: `selectorForMultiple` (3 опции при 2 кандидатах) — **выполнено**. Golden — не запускался, см. ниже |
| 2 | unit §18.1.1, smoke §18.2.2-3 | unit `issue 178: an explicit toggle entity selects one exact composite-device channel` — выполнен; smoke `selectionUpdatesDraft`/`selectionUpdatesHintBeforeSave`/`writerPersistsSelection` — выполнены |
| 3 | unit §18.1.6, smoke §18.2.5 | smoke `singleEntityHidesSelector` — выполнен; соответствующий unit — код прочитан (`toggleEntities.length > 1 \|\| staleToggleEntity` в `src/houseplan-card.ts:18339-18340`) |
| 4 | unit §18.1.3/8, mutation floor | `npm test` зелёный целиком, включая нетронутые legacy fixtures; прочитано, что `selectedToggleEntity()` возвращает `null` при отсутствии поля → путь идентичен старому |
| 5 | unit §18.1.4/11, smoke, mobile golden | smoke `staleWarnsAndShowsAuto`/`staleLiteralSurvives` — выполнены; unit `stale selection falls back...` — выполнен. Golden — не запускался |
| 6 | unit §18.1.5/9 | оба unit-теста (`stale selection falls back while an active missing target never retargets`, `unavailable selected own group member is skipped without replacing it`) — выполнены и содержательны (проверяют `noneReason`/`skippedTargets`, а не просто отсутствие исключения) |
| 7 | unit §18.1.7-9 | `explicit own selection joins controls without changing legacy groups` — выполнен, покрывает оба направления (с полем и без) |
| 8 | unit §18.1.3/10, diff | `origin === 'legacy-cover'` возвращается до чтения `selectedToggleEntity` (src/device-toggle.ts:717-727); `isManualVirtualLightMarker`/incoming-controller путь возвращается до него же (строки 687-708) — **проверено чтением, не исполнением**, дополнительно подтверждено тем, что unit-фикстуры cover/virtual light в существующем наборе не изменились и остались зелёными |
| 9 | backend/import unit §18.1.13 | **не выполнено исполнением** — тесты живут в `test_ha_import_export.py`, недоступном без HA-harness в среде ревью. Проверено чтением: `build_space_merge` добавил `"toggle_entity"` в allowlist удаления (import_export.py:897), схема экспорта не имеет отдельного списка полей для full/space (единственное упоминание `light_entity` в файле — та же строка), значит буквальный перенос происходит тем же путём, что и `light_entity` |
| 10 | backend unit §18.1.12, full-import fixture | **выполнено исполнением**: `test_toggle_entity_is_domain_bounded_only_when_new_or_changed` прошёл в чистом Python-прогоне |
| 11 | i18n/native-select unit, smoke, golden | RU/EN-паритет — существующий `test/i18n.test.mjs` проверяет полное соответствие ключей и обязательное `.aria` для всех `marker.*.help`-ключей, включая новые (прогнан как часть `npm test`); native-select — `test/native-select-contract.test.mjs` теперь включает `marker-toggle-entity` в список проверяемых id, тест выполнен и зелёный. Golden — не запускался |
| 12 | trailers/process gate, code-review inventory | commit `8086399` несёт `Issue: #178`, `User-Visible: yes`, и в нём же — оба changelog, `USER-GUIDE.md`/`.ru.md`, `CONFIG-COMPATIBILITY.md`, `ARCHITECTURE.md` (проверено `git show --stat`) |
## Разбор реализации (проверено чтением)
- `toggleEntityCandidates()` (src/device-toggle.ts:398-404) сознательно не
расширяет entity-binding до registry-siblings и возвращает `[]` для
`virtual` — совпадает с §8.1 ТЗ и подтверждено unit-тестом «entity binding
stays exact even with a sibling toggle selection».
- Порядок в `resolveToggleIntent`: `manual-virtual-light`/`incoming-controller`
→ `explicit-toggle` с `controls` → `legacy-cover` → общий single-путь с
`selectedToggleEntity()`. Новый код не может сработать раньше guard-ов,
которые ТЗ явно выводит из scope (§10 «cover… не использует поле»).
- `resolveGroupEntities` (существующая, не тронутая функция) дедуплицирует по
`entityId` через `Map`, поэтому даже гипотетическое совпадение выбранной
собственной сущности с внешним `controls`-ref не даёт дублирующегося
вызова сервиса — крайний случай вне явного покрытия тестами, но не является
дефектом при существующей структуре кода.
- Backend: `_LIGHT_ENTITY_RE` идентичен по паттерну для обоих полей;
делегирование в тот же цикл (`validate_marker_light_entities`) означает, что
`toggle_entity` автоматически проверяется на всех тех же границах, что и
`light_entity` (websocket save/config update, import merge, `validate_all`
import) — подтверждено `grep` по вызовам, без отдельного риска рассинхрона.
- `toggleEntityWriteFields()` (новый файл `src/marker-toggle-entity.ts`)
повторяет транзакционную модель `light_entity`; unit-тесты покрывают все 4
комбинации `touched × originalHas`.
- Golden-инфраструктура (`demo/golden/harness.mjs`, `demo/golden/matrix.mjs`)
статически согласована с реальной вёрсткой: класс `.markertoggleentity` и id
`#marker-toggle-entity` совпадают буквально с `src/houseplan-card.ts`; фикстура
`golden-washer` в `demo/fixtures/visual-matrix.mjs` содержит ровно два
`switch.*` (power/child_lock) для сценария «selected» и не содержит
`switch.golden_washer_removed` для сценария «stale» — соответствует
замыслу сценариев.
## Находки
Нет находок уровня **High** или **Medium**.
**Low-1 (не блокирует, снимается с запиской).** `docs/USER-GUIDE.ru.md` и
`docs/USER-GUIDE.md` описывают появление селектора только для «маркера с
привязкой к устройству» с 2+ сущностями, не упоминая явно, что тот же
селектор может появиться и при сохранённом stale-значении на маркере с любым
типом binding (условие «либо» из §9.1 ТЗ). Пользовательского риска нет:
поведение верно реализовано и растворено в проверке via smoke
(`staleWarnsAndShowsAuto`), формулировка документации просто не покрывает
редкий путь явно. Снимается без правки — редкий путь (stale-значение после
внешнего изменения конфигурации) не искажает основной сценарий, а
дополнять формулировку ради полноты документации при зелёном коде
избыточно для этого цикла.
## Что проверено и корректно
- Совместимость без миграции: отсутствие поля даёт бит-в-бит прежний результат
(полный `npm test` зелёный, включая нетронутые legacy-фикстуры).
- Точность explicit-выбора (single и group) и отсутствие silent retarget при
transient unavailable — оба пути покрыты содержательными unit-тестами,
которые я прочитал и для которых проверил, что они способны упасть.
- Транзакционность диалога (touched/stale/original) и live-preview до Save —
подтверждено production-bundle smoke на реальном собранном бандле.
- Backend lossless delta-validation — подтверждено исполнением теста.
- i18n RU/EN паритет и accessibility-контракт нового `<select>` — подтверждено
исполнением существующих контрактных тестов, которые уже включают новый
элемент.
- Все три копии production-бандла (`dist/`, `custom_components/.../frontend/`,
`demo/srv/assets/`) побитово идентичны после чистой пересборки.
- Оба changelog и вся релизная документация лежат в том же коммите, что и
продуктовый код; трейлеры `Issue`/`User-Visible` корректны на всех 6
коммитах диапазона.
- Не найдено нарушений scope/non-scope из ТЗ (§5/§6): `tap_target`, cover,
virtual-light, functional role/primary и confirmation flow не тронуты по
диффу.
## Чего не проверял
- **`pytest tests_backend` с установленным Home Assistant** — среда ревью не
содержит `homeassistant`/`pytest-homeassistant-custom-component`, поэтому
`test_ha_import_export.py` целиком пропущен `conftest.py` (документированное
ограничение AGENTS.md: «зелёный результат без HA ничего не доказывает»).
Два новых/изменённых теста в этом файле (`test_export_copies_toggle_entity_literal`,
обновлённый `test_space_import_remaps_owned_ids_and_duplicate_policy`)
разобраны только чтением кода — AC9 подтверждён косвенно, не исполнением.
Каноничный прогон — Linux CI (`backend` job).
- **`npm run golden:verify` / `golden:capture`** — не запускался. ТЗ (§18.4) и
PROCESS.md §8 сами относят golden к предрелизному гейту для этой задачи;
два новых сценария не имеют baseline и по правилу принимаются только из
полного Linux CI артефакта. Статическая проверка (совпадение id/класса
между харнессом и вёрсткой, состав фикстуры) не заменяет визуальную
проверку, но снижает риск того, что сценарий структурно не работает.
- **Полный browser smoke-набор (127 файлов)** — не запускался целиком. Помимо
именованного `smoke_toggle_entity.mjs` (обязателен по AC), запущены
`smoke_ha_controls.mjs` и `smoke_light_default_tap.mjs` — они делят
изменённый резолвер (`resolveControls`/single-путь) и являются самым
вероятным местом регрессии за пределами нового смока. Остальные смоки не
касаются `device-toggle.ts`/диалога устройства по диффу и не запускались —
сознательное сужение по PROCESS.md §8.
- **Performance-профили** — не запускались. ТЗ прямо заявляет отсутствие
нового render-пути (§15.1); диффу не хватает признаков, которые обычно
требуют профилирования (новый per-frame/pointermove/global-scan код
отсутствует).
## Вердикт
Зелёный. Реализация точно соответствует зелёному ТЗ r2, все проверяемые здесь
AC подтверждены исполнением или обоснованным чтением кода, быстрые гейты
зелены, именованный и два смежных production-bundle smoke зелены, находок
уровня High/Medium нет.