Files
houseplan-card/docs/reviews/CODE-REVIEW-318-r1.md
T
2026-08-28 13:18:35 +00:00

197 lines
19 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-318-r1
- Issue: [#318](https://github.com/Matysh/houseplan-card/issues/318) — «Маркер switch не меняет отрисовку при смене состояния сущности»
- Заход: r1 (код-ревью), блокирующих циклов израсходовано 0/4
- SHA материала ревью: `72913fee84db87df0871940bea8232aa183e8f61` (сверено `git rev-parse HEAD` непосредственно перед вердиктом)
- Ветка: `issue/318-switch-render-state`
- Трек: полный (ТЗ `docs/specs/318-empty-controller-roster.md`, ревью ТЗ зелёное — `docs/reviews/SPEC-REVIEW-318-r1.md`, SHA `1242a3b1`)
- Ребейз перед ревью: конвейер докатил ветку `7d11cac7 → 72913fee` (+1 коммит `dev`). Это другой код (§7.2), поэтому разбор ниже полный, не по дельте.
## 1. Скоуп
Разобрано (см. владельческое решение в комментариях issue): активный физический
`device:` binding с пустым собственным entity roster (0 строк entity registry)
должен считаться `available`, а его working/neutral — следовать `controls`.
Ранее пустой roster безусловно давал `unavailable`, поэтому маркер выглядел
постоянно недоступным независимо от состояния управляемой цели. Контракт #251
(доступность контроллера ≠ доступность цели) для непустого roster без живых
состояний не меняется.
Диапазон материала: `git log --oneline origin/dev..HEAD` (3 коммита) и
`git diff origin/dev...HEAD` (43 файла, из них продуктовый код — только
`src/device-presentation.ts`, остальное — тесты/смоки/mutation-gate/документация/
generated bundle-копии).
## 2. Как проверялось
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | PASS, без вывода |
| Unit | `npm test` | PASS: 1473 passed, 0 failed, 1 skipped, 1474 всего |
| Build | `npm run build` | PASS |
| Копии бандла | `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` + `npm run bundle:sync` | PASS, `git status` после sync чист — все три дерева (`dist`, `custom_components/.../frontend`, `demo/srv/assets`) уже синхронизированы в коммите |
| Бюджет бандла | `npm run bundle:budget` | PASS: initial View 256091 B / 282000 B (запас 25909 B) — совпадает с числом хендоффа |
| Docs fingerprint | `node scripts/check-docs.mjs` | PASS: 7 files, 10 external links (диф трогает `src/**` → гейт обязателен) |
| Новый `any` | `node scripts/no-new-any.mjs --base origin/dev --head HEAD` | PASS: 10 добавленных строк в 1 файле, новых `any` нет |
| Выбор смоков | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | НЕОПРЕДЕЛЁННОСТЬ: 1 файл `src/**`, 0 связанных символов на изменённых строках — инструмент не называет ни одного смока, решение по AC остаётся за ревьюером |
| Смок из AC5 | `node demo/smoke_wireless_controller_parity.mjs` | PASS — все 16 полей `true`, включая 5 новых полей `entityless*` (empty roster on/off/unavailable, parity plan/preview) |
| Mutation `entityless-active-controller-stays-available` (AC8, новый мутант) | `node scripts/mutation-gate.mjs --id=entityless-active-controller-stays-available` | PASS: чистый прогон green, мутант покраснел |
| Mutation `controller-availability-follows-target` (#251, регрессия) | `node scripts/mutation-gate.mjs --id=controller-availability-follows-target` | PASS, мутант покраснел |
| Mutation `controller-diagnostics-do-not-prove-online` (#251, регрессия) | `node scripts/mutation-gate.mjs --id=controller-diagnostics-do-not-prove-online` | PASS, мутант покраснел |
| Mutation `wireless-controller-loses-filtered-target-role` (#274, регрессия) | `node scripts/mutation-gate.mjs --id=wireless-controller-loses-filtered-target-role` | PASS, мутант покраснел |
| Mutation `wireless-controller-preview-drops-sibling-markers` (#274, регрессия) | `node scripts/mutation-gate.mjs --id=wireless-controller-preview-drops-sibling-markers` | PASS, мутант покраснел |
| Process gate | `node scripts/process-gate.mjs` | PASS: диапазон `origin/dev..HEAD`, 3 коммита, 0 предупреждений |
### Чего не прогонял и почему
- **`golden:verify`** — не прогонял. Диф действительно меняет видимый результат
маркера, но только для одной узкой конфигурации: active `device:` binding с
`d.entities.length === 0`. Проверено чтением `demo/golden/matrix.mjs` —
ни один существующий golden-маркер с `binding: 'device:*'` не имеет пустого
списка сущностей (все golden-устройства — реальные лампы/датчики с
зарегистрированными entity rows), то есть изменённая ветвь
`controllerAvailability()` не задействуется ни одним текущим golden-сценарием.
Пересъёмка эталонов ради строки кода, которую они не исполняют, была бы
«ради зелёного CI», что прямо запрещено. Решение согласуется с §15 ТЗ:
новый golden baseline не требуется, если семантический smoke зелёный и
существующая visual-матрица не задета.
- **`smoke_device_preview_parity.mjs`** — рассмотрен и не прогонялся. Слабая
связь по имени («preview parity»), но по чтению кода он проверяет только
собственный (`owned_light`/S09) источник лица для живого света, не
`controllerAvailability()`/сценарий S03/S15; этот путь не тронут диффом.
- **`smoke_static_icon.mjs`, `smoke_disabled_device.mjs`, `smoke_device_inbox.mjs`,
`smoke_new_device.mjs`, `smoke_device_icon_*`** — рассмотрены по списку
`ls demo/smoke_*.mjs | grep -i device` (196 смоков всего), отклонены: static
face (F08), lifecycle ha_disabled/orphaned (L02/L03/L06) и инвентарь новых
устройств не проходят через изменённую ветку (правится только `active`+пустой
roster).
- **`python -m pytest tests_backend`** — не тронут `custom_components/**/*.py`
(см. diff stat), не запускал.
- **Performance-профили** — не названы в AC, диф — одна булева проверка `O(1)`
на уже собранном marker, не запускал.
- **Полный набор `demo/smoke_*.mjs` (196 файлов)** — не запускал: `smoke-select`
не нашёл ни одной доказанной связи, задача не задевает «всё», полный прогон —
предрелизный гейт, а не гейт ревью (§8).
## 3. Разбор кода
`src/device-presentation.ts:208-225`, `controllerAvailability()`:
```ts
const ownEntities = d.entities || [];
const activeEntitylessDevice = ownEntities.length === 0
&& (d.bindingKind === 'device' || d.marker?.binding?.startsWith('device:'))
&& d.bindingStatus?.kind === 'active';
if (activeEntitylessDevice) return 'available';
const live = ownEntities.some((eid) => { ... });
```
Проверено чтением и трассировкой по продуктовому коду, не только по тесту:
- `virtual`-ветка (строка 211) идёт раньше нового предиката и не меняется —
виртуальный контроллер с пустым roster по-прежнему `available` тем же путём,
что и раньше (проверено тестом `virtual` в новом unit-кейсе).
- Предикат требует одновременно (а) пустой `d.entities`, (б) `device:` binding,
(в) `bindingStatus.kind === 'active'`. `src/ha-binding-status.ts:14-17`
показывает, что `kind` может быть только `active | ha_disabled | orphaned |
unverified` — ни одно другое значение не проходит строгое сравнение `===
'active'`, поэтому `ha_disabled`/`orphaned`/`unverified` не получают fallback
(соответствует AC4, матрице §6.2 ТЗ и строкам L02/L03/L06 таблицы решений).
- `entity:`-binding никогда не удовлетворяет `d.bindingKind === 'device' ||
d.marker?.binding?.startsWith('device:')`, поэтому маркер с точной `entity:`
привязкой и пустым `d.entities` (аномальный случай) не получает fallback и
остаётся на старом пути `live = ownEntities.some(...)` → `unavailable`.
Негативный тест на это есть (`entityBinding` в новом unit-кейсе).
- Реальность предиката в продакшн-пути подтверждена в `src/devices.ts:1198`:
`entIds = bindingStatus.kind === 'active' ? bindingStatus.enabledEntityIds :
[]` — то есть `d.entities === []` при `active` — это не выдумка теста, а
реальный результат сборки ростера для `device:` маркера без entity rows
(`src/ha-binding-status.ts:434-444`, ветка `enabledEntityIds.length === 0`).
- `controllerAvailability()` вызывается независимо от `sourceKind`
(`src/device-presentation.ts:616-638`, `controllerFace = sourceKind ===
'controls' || (configuredController && ...)`), поэтому fallback работает и в
сценарии «все controls отфильтрованы рантаймом» (`sourceKind: 'none'`,
AC2) — не только в прямом «target on/off».
- `working/neutral` не синтезируется отдельно: policy получает
`controllerAvailability: 'available'` и `sourceVisual` из уже существующего
`combineVisualSamples(sources.samples)`, который считается из
`resolvePresentationSources()` — та же функция, что строила поведение до
фикса. Значит фикс не создаёт новый источник статуса, а только снимает
ложный приоритет `unavail` — ровно риск «Target unavailable станет ложным
working», закрытый по таблице рисков ТЗ §13, подтверждён unit-тестом
(`h.states[...] = 'unavailable'` / `delete h.states[...]` → `neutral`, не
`working`).
**Один источник числа.** Диф не вводит и не дублирует ни одной пользовательской
величины (значение, площадь, подпись): решается только булева проекция
availability/status одного и того же уже вычисленного `combined`-агрегата.
Правило `test/single-source-numbers.test.mjs` диф не касается и не должно.
## 4. Разбор по AC
| AC | Статус | Доказательство |
|---|---|---|
| AC1 | Выполнен | `test/device-presentation.test.mjs` — «issue 318 keeps an active entityless physical controller available», ветки on/off; прогнан, зелёный; mutation `entityless-active-controller-stays-available` подтверждает, что тест умеет падать |
| AC2 | Выполнен | тот же unit-кейс: target `unavailable`, target `missing` (entity удалена из `hass.states`), runtime-filtered `controls: []` (`sourceKind: 'none'`) — все три дают `available`+`neutral`, не `working`/`unavail` |
| AC3 | Выполнен | тот же unit-кейс, ветка `provenOffline`: непустой roster (`event.*` без live state) при `target on` даёт `unavailable`; #251-тест (`issue 251 separates controller availability...`) не изменён и зелёный — mutation `controller-availability-follows-target`/`controller-diagnostics-do-not-prove-online` подтверждают, что регрессия была бы поймана |
| AC4 | Выполнен | unit-кейс проверяет `entity:`-binding и `orphaned` явно (обе — `unavailable`); `ha_disabled`/`user_hidden`/alarm/static/live_states-off не тронуты кодом (не в изменённой ветке) и покрыты существующими policy-тестами, прошедшими в `npm test` |
| AC5 | Выполнен | `demo/smoke_wireless_controller_parity.mjs`, новые проверки `entitylessOnIsAvailableWorking`, `entitylessPreviewMatchesOnPlan`, `entitylessOffIsAvailableNeutralEverywhere`, `entitylessUnavailableTargetIsAvailableNeutralEverywhere` — все `true` при запуске |
| AC6 | Выполнен частично автотестом, частично чтением | `resolveDevicePresentation()` — общий resolver для plan/preview/hosted Static (архитектурный инвариант, не специфичный для этой задачи); прямого hosted-Static-смока нет, но shared-resolver unit + `entitylessPreviewMatchesOnPlan` в browser-смоке покрывают ту же функцию, которую использует Static-путь. Light/dark и layout не проверялись экраном — **проверено чтением**: диф не трогает CSS/классы face, только availability boolean |
| AC7 | Выполнен | diff-аудит: `git diff origin/dev...HEAD -- src/` показывает изменения только в `device-presentation.ts`; конфиг/schema/model/backend не тронуты вовсе (нет файлов `custom_components/**/*.py`, `*.json` схем в diff) |
| AC8 | Выполнен | 5 команд `mutation-gate.mjs` выше, все «мутант покраснел» |
| AC9 | Выполнен | таблица гейтов раздела 2; числа `bundle:budget` совпадают с хендоффом |
## 5. Документация и трейлеры
- `docs/DEVICE-PRESENTATION.md` — S05 переформулирован под «непустой roster без
live», добавлена строка S15 под новый fallback; таблица/фикстура/тест
синхронизированы автоматическим тестом «device presentation decision
document, fixture and mutation registry stay exact» (прогнан в составе
`npm test`, зелёный).
- `docs/ARCHITECTURE.md`, `docs/USER-GUIDE.md`, `docs/USER-GUIDE.ru.md`,
`docs/TESTING.md` — обновлены консистентно с новым контрактом; терминология
RU/EN совпадает («выключатель», «непрозрачным», «жёлтая/нейтральная
подложка» — уже принятые термины USER-GUIDE, не изобретены заново).
- Оба changelog обновлены в том же коммите `72913fee` (`fix: show entityless
active controllers`, `User-Visible: yes`), трейлер `Issue: #318` на всех трёх
коммитах диапазона.
- `docs/specs/README.md` получил строку для #318, но она вставлена перед #294,
а не после — таблица в этом разделе иначе идёт по возрастанию номера issue.
**Low, не вне скоупа, не блокирует**: чисто косметическая перестановка строки
индекса, не влияет ни на один AC и не читается инструментом. Снимаю с записью,
а не отправляю на правку — цена цикла ревью выше цены одной переставленной
строки таблицы.
- Скриншоты `docs/images/*.png` + `docs/images/screenshots.json` пересобраны
командой `npm run build && node demo/docs/capture.mjs`, что совпадает с
явной инструкцией `check-docs.mjs` («run npm run build && node
demo/docs/capture.mjs») и полем `command` в самом манифесте; фингерпринт
обновлён и `check-docs.mjs` зелёный. Отдельная строгая приёмка
`docs:accept -- --reviewed --from=<CI-артефакт>` — гейт релизного/`golden`-типа
для официального байтового эталона, а не условие этого код-ревью.
## 6. Продуктовое рассуждение
Сценарий и контракт полностью соответствуют J1/J2 из `docs/SCOPE.md`
(«что происходит сейчас», спатиальная тревога/статус без ложных сигналов).
Решение владельца в комментариях issue («активный физический device-binding с
пустым roster считается доступным») реализовано буквально, включая явно
исключённые владельцем случаи (непустой roster без живых состояний остаётся
`unavailable`). Изменение не расширяет и не сужает скоуп: Toggle, service
payload, Glow, room fill, badge — не тронуты, что подтверждено diff-аудитом.
Жёлтых оснований (AC выполнены, но сценарий не решён или соседний ухудшен) не
нашёл.
## 7. Находки
Нет находок High или Medium. Один Low (порядок строки в `docs/specs/README.md`)
снят с записью в разделе 5.
## 8. Итог
Все 9 AC доказаны автотестом с подтверждённой способностью падать (mutation
gate) либо browser-смоком, один пункт (AC6, hosted Static визуально) закрыт
чтением кода при отсутствии прямого смока — задокументировано явно. Гейты
typecheck/test/build/bundle-sync/bundle-budget/check-docs/no-new-any/
process-gate зелёные на SHA `72913fee`. Регрессионные mutation-guards #251 и
#274 подтверждены неизменными. Вердикт: зелёный.