19 KiB
CODE-REVIEW-318-r1
- Issue: #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, SHA1242a3b1) - Ребейз перед ревью: конвейер докатил ветку
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— не прогонял. Диф действительно меняет видимый результат маркера, но только для одной узкой конфигурации: activedevice: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():
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 подтверждены неизменными. Вердикт: зелёный.