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

19 KiB
Raw Blame History

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, 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():

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 подтверждены неизменными. Вердикт: зелёный.