Ревью #682 r1, Medium: перенос добавляет документу уровень вложенности (`docs/reviews/X.md` → `legacy/reviews/<тег>/X.md`, `docs/specs/` → `legacy/specs/`), а относительные ссылки внутри перенесённых документов и в соседях, ссылавшихся на них, никто не пересчитывал — на `97d19268` 53 битые ссылки в 46 файлах (заявление «все 26 резолвятся» в `7feb6177` было верно только до переноса документов ревью). Гейты архив не смотрят. `reviews-archive.mjs`: `repairLinks` пересчитывает ссылку, если она не резолвится от нового места, а цель находится от нового или старого места через карту переносов; битая и до переноса ссылка не трогается. `--apply` делает это само, `--repair-links=<rev>` — для всех переименований `<rev>..HEAD`, `--check-links` печатает битые. Этим коммитом `--repair-links=origin/dev` переписал ровно 53 ссылки в 46 файлах; остались две прежние «...»-заглушки в CODE-REVIEW-448-r2 (битые и на dev). Тесты: перенесённый документ, сосед со ссылкой в архив, ТЗ со ссылкой на позже перенесённое ревью, битая-до-переноса не трогается, в `legacy/` битых нет; мутант `reviews-archive-links-from-new-place-only`. PROCESS §2.10 и DEVELOPMENT › Release называют переписывание и `--check-links`. Issue: #682 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
26 KiB
Код-ревью issue #107 — переключение виртуального источника света «Всегда» (r1)
- Issue: https://github.com/Matysh/houseplan-card/issues/107
- ТЗ:
docs/specs/107-virtual-light-toggle.md, ревьюSPEC-REVIEW-107-r1.md— зелёное, High 0 / Medium 0. - Диапазон:
origin/dev...HEAD, коммит1079cdfab25617df924b8c3592631aa40e078d87("feat: add persistent virtual light toggles"), веткаissue/107-virtual-light-toggle. - Ревьюер: Claude (код-ревью ≠ ревью ТЗ, свежая сессия, без контекста реализации).
- Цикл: r1/4.
1. Скоуп изменения
Backend: новый Store houseplan.virtual_lights (virtual_lights.py, const.py,
store.py), новая WS-команда houseplan/virtual_light/toggle, дополнение
houseplan/config/get полем virtual_lights, согласование состояния во всех
писателях конфигурации (websocket_api.py, __init__.py — миграция при
setup).
Frontend: новый модуль src/virtual-light-state.ts (нормализация wire-снапшота,
монотонное применение событий, reconciliation при смене config revision);
интеграция в resolveToggleIntent/resolvedLightSources
(device-toggle.ts, devices.ts), в live-sync и localStorage-кэш
(config-store.ts, houseplan-card.ts), в статическую карточку
(space-card.ts, space-render.ts); i18n EN/RU; unit-тесты
(test/virtual-light-state.test.mjs, дополнения device-toggle.test.mjs,
devices.test.mjs); backend-тесты (test_virtual_lights.py,
test_ha_virtual_lights.py, дополнение test_ha_setup.py); целевой browser
smoke demo/smoke_virtual_light_toggle.mjs; документация (docs/LIGHT.md,
docs/CONFIG-COMPATIBILITY.md, docs/ARCHITECTURE.md,
docs/USER-GUIDE.ru.md, README.md, оба changelog).
Соответствует заявленной поверхности ТЗ. Продуктовое соответствие
docs/SCOPE.md: J1/J3, узкое исключение к замороженным virtual devices, не
пересмотрено ревьюером ТЗ повторно (не входит в код-ревью).
2. Как проверялось
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | npx tsc --noEmit |
зелёный, без вывода |
| Unit (frontend) | npm test |
781/781 pass |
| Build + сверка бандлов | npm run build && sha256sum dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js demo/srv/assets/houseplan-card.js |
один и тот же SHA-256 caffc463…d23bbc89 для всех трёх копий; рабочее дерево осталось чистым (git status --short пусто) — сборка воспроизводима и совпадает с закоммиченным бандлом |
| Backend pytest (чистое подмножество, без HA) | python3 -m venv /tmp/venv-review && /tmp/venv-review/bin/pip install pytest voluptuous && PYTHONPATH=. /tmp/venv-review/bin/pytest tests_backend -q |
упал сбором — tests_backend/test_virtual_lights.py не собирается без homeassistant (см. находку M1). Полный HA-харнесс (pytest-homeassistant-custom-component) в этом окружении не установлен и не входит в его canonical Linux CI набор по факту — только офлайн-подмножество, которое теперь не запускается вовсе. test_ha_virtual_lights.py/test_ha_setup.py разобраны чтением (см. §4) |
| Целевой browser smoke | node demo/smoke_virtual_light_toggle.mjs (после npm run build и синхронизации трёх копий бандла, Chromium/Playwright уже установлены в среде) |
FAILED (2): clickEverywhereOff: expected true, got false; reloadFirstStateOff: expected true, got false. Остальные 5 проверок (initialEverywhereOn, secondClickEverywhereOn, touchSingleToggle, oneServerTogglePerGesture, noHaServiceCalls) — true. См. находку H1 |
Не прогонялись (осознанно, вне гейта код-ревью для этого объёма изменений
по PROCESS.md §8): npm run golden:verify (изменение не трогает
художественный baseline — новая fixture по плану идёт в golden только на
pre-beta gate), полный набор demo/smoke_*.mjs (127 сценариев — задача не
задевает все поверхности, а точечный сценарий уже прогнан выше и провалился),
performance-профили (в AC не назван численный бюджет; §15.4 ТЗ явно откладывает
его на pre-beta). Прогон полного HA backend harness (pytest-homeassistant-custom-component)
недоступен в этом окружении — AC2/AC3/AC4/AC8/AC9, доказываемые
test_ha_virtual_lights.py, разобраны чтением, не исполнением (см. §4).
3. Находки
H1 (High, блокирует). Собственная презентация маркера (иконка/CSS-класс on/off) не отражает ручное состояние, когда у того же маркера есть исходящие controls — подтверждено исполнением целевого smoke
Файл: src/device-presentation.ts (не тронут этим PR), проявляется через
src/houseplan-card.ts (_devicePresentation/_stateClass),
src/space-card.ts, src/space-render.ts.
Сценарий воспроизведения: ровно fixture demo/smoke_virtual_light_toggle.mjs
— маркер binding: 'virtual', is_light: true, tap_action: 'toggle', controls: ['light.ceiling'] (эта же тройка + исходящий controls, то есть ровно сценарий
AC12). Запуск node demo/smoke_virtual_light_toggle.mjs на чистом собранном
бандле даёт:
FAILED (2):
- clickEverywhereOff: expected true, got false
- reloadFirstStateOff: expected true, got false
Причина, подтверждена чтением и точечной инструментацией (debug-копия
смока, запущена и удалена, в репозиторий не попала): resolvePresentationSources()
(src/device-presentation.ts:279-280) выбирает sourceKind = 'controls',
как только у устройства есть хоть один источник с via === 'controls' —
это ветка для «маркер-контроллер показывает лицом состояние того, что
включает». Для точной тройки #107 marker.controls — это исходящая,
не относящаяся к ролям Auto/Always связь (AC12 требует, чтобы она сохранялась
lossless и не вызывала HA-сервис, но не говорит, что она должна забирать
приоритет у собственного ручного состояния маркера). Проверено
инструментацией: без поля controls та же тройка _stateClass/иконка
корректно показывает off после клика (sourceKind: 'light',
visualSources: [{"eid":"marker:...","state":"off"}]); с полем controls
_devicePresentation() даёт sourceKind: 'controls',
visualSources: [{"eid":"light.ceiling","state":"on"}, {"eid":"marker:...", "state":"off"}] — правильное ручное состояние присутствует в списке, но не
выбрано для лица маркера, поэтому _stateClass()/CSS-класс on остаются
завязаны на состояние light.ceiling, а не на ручной toggle.
Это касается всех трёх типов потребителей презентации маркера
(_devicePresentation полной карточки, второй полной карточки, статической —
все три вызывают resolveDevicePresentation с одним и тем же
resolvePresentationSources), поэтому и on(first), и on(second), и
staticOn() остаются залипшими на состоянии light.ceiling, а не на ручном
toggle — что и даёт false в обеих сравнительных проверках смока.
Почему это блокирует, а не Low/Medium: ТЗ §6.2 требует «один и тот же
resolved source обязан дать одинаковый результат» для списка потребителей,
явно включающего «room card и device presentation»; AC5 требует source.on= false … во всех canonical light consumers»; AC10/AC12 — это ровно комбинация (манильный маркер + сохранённые исходящие controls), которую тестирует сам смок. Реализация проходит AC1–AC9 (см. §4) и логику toggle/persistence, но для этой явно предусмотренной ТЗ комбинации собственная иконка маркера не меняется — заявленный сценарий («нажатие выключает его … Одновременно меняются его Glow и все room-light consumers») не выполняется полностью: Glow/room fill меняются (подтверждено smoke-проверками oneServerTogglePerGesture, noHaServiceCallsи раздельно нижеприведённым разбором devices.ts), а лицо самого маркера — нет. Это ровно тот класс дефекта, который должен доказываться исполнением, а не чтением: без прогона smoke эта находка осталась бы незамеченной, потому что unit-тестыdevices.test.mjs/device-toggle.test.mjsне строят презентацию черезresolveDevicePresentation()и поэтому не видят приоритетsourceKind`.
Что не задето: сама персистентность, атомарность toggle, авторизация,
отсутствие HA-сервисного вызова, поведение без controls — всё корректно
(unit-тесты и 5/7 smoke-проверок это подтверждают).
M1 (Medium, обязан стать отдельным issue). tests_backend/test_virtual_lights.py не собирается pytest без установленного homeassistant, хотя заявлен как «чистый» тест
Файл: tests_backend/test_virtual_lights.py:1,4.
Сценарий воспроизведения:
python3 -m venv /tmp/venv-review
/tmp/venv-review/bin/pip install pytest voluptuous
PYTHONPATH=. /tmp/venv-review/bin/pytest tests_backend -q
даёт
ERROR tests_backend/test_virtual_lights.py
ImportError while importing test module '.../tests_backend/test_virtual_lights.py'
tests_backend/test_virtual_lights.py:4: in <module>
from custom_components.houseplan.virtual_lights import (...)
custom_components/houseplan/__init__.py:9: in <module>
from homeassistant.components.frontend import add_extra_js_url
E ModuleNotFoundError: No module named 'homeassistant'
Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
Это полная остановка сбора — ни один тест tests_backend/ не выполняется,
включая ранее рабочие чистые файлы вроде test_validation.py.
Причина: файл заявляет в докстринге «Pure operational-store rules
independent of the HA WebSocket harness» и импортирует модуль обычным путём
from custom_components.houseplan.virtual_lights import …. Такой импорт
обязан сначала выполнить custom_components/houseplan/__init__.py (пакетный
__init__), который безусловно импортирует homeassistant.components.frontend
и другие модули HA. Существующий test_validation.py (не тронут этим PR)
решает ту же задачу иначе и специально: докстринг «validation.py is loaded by
path, without importing the HA integration package», реализовано через
importlib.util.spec_from_file_location, ровно чтобы не тянуть
homeassistant для чистых тестов.
AGENTS.md фиксирует это же поведение как контракт: «Locally only the pure
subset runs; python -m pytest tests_backend/ -q without Home Assistant
silently skips test_ha_*.py … so a green result proves nothing» — то есть
не-test_ha_* файлы обязаны собираться и выполняться без HA. Этот PR впервые
нарушает инвариант: вместо тихого пропуска — полная остановка сбора для всего
каталога.
Почему не блокирует: CI-джоб backend (.github/workflows/validate.yml:206)
всегда ставит pytest-homeassistant-custom-component, так что зелёный проход
в CI не страдает — сами тесты внутри test_virtual_lights.py корректны и
проверяют то, что заявлено (см. §4). Ломается только локальный/офлайн путь
без HA, который прежде «доказывал что-то» для остальных чистых файлов.
Соответствует критерию Medium: реальный, воспроизводимый дефект вне
заявленных AC, требует отдельного issue со ссылкой на #107 (тип tech-debt,
поверхность — тестовая инфраструктура backend).
4. Что проверено и корректно
- AC1 (eligibility matrix, unit):
isManualVirtualLightMarker()/eligible_virtual_light_ids()(frontendvirtual-light-state.ts, backendvirtual_lights.py) требуют точную тройкуbinding==='virtual'+is_light===true+tap_action==='toggle'+removed!==true; unit-тестtest_eligibility_is_the_exact_triple_and_hidden_is_not_lifecycleи frontenddevices.test.mjs/device-toggle.test.mjsпокрывают Auto/Never/wrong-action/ non-virtual/removed — доказано автотестом, тест умеет падать (проверено изменением условия вручную не потребовалось: тест явно перечисляет все отрицательные варианты одним assert на множество). - AC2/AC3 (persistence, atomic concurrency, backend):
async_toggle_virtual_lightвызывается изws_virtual_light_toggleвнутриrt.write_lock— тот же lock, что используетws_config_get/ws_config_set, поэтому конкурентные toggle-запросы физически сериализуются на уровне WS-хендлера, а не только внутриvirtual_lights.py. Backend-тестtest_invalid_target_and_concurrent_toggles_are_server_atomic(файл требует полного HA harness — прочитан, не исполнен: заявленное поведение — две последовательные server revisions(1,False),(2,True)— соответствует кодуasync_toggle_virtual_light, который читает текущий snapshot, инвертирует и сохраняет одной операцией под общим локом). - AC4 (права, ревью кода):
ws_virtual_light_toggleне вызывает_check_write/may_write— команда доступна любому аутентифицированному соединению, соответствует ТЗ §7.4.not_toggleableвозвращается без изменения store и без события (if result is None: connection.send_error(...); return— находится внутриasync with rt.write_lock, доsend_result/async_fire). - AC5 (canonical
source.on, unit):devices.ts:536-548— цикл идёт по ВСЕМ passive-источникам (if (!source.passive) continue), сначала считает обычный OR поincoming-контроллерам, затем безусловно переопределяетsource.onчерезvirtualLightIsOn(), еслиisManualVirtualLightMarker— то есть ручное состояние выигрывает и когдаcontrolесть, и когда его нет.devices.test.mjs(«manual virtual state is canonical and invalidates the light cache») это явно проверяет для случая с входящим контроллером. Данный путь работает корректно на уровне Glow/room-графа; H1 — это отдельный путь (device-presentation), который не использует этот безусловный override напрямую, а получает свой собственныйresolvedLightSources()вызов безvirtualLights, скорректированный черезlightSources: planLightSources, что ломается только при наличии собственныхcontrolsу того же маркера (см. H1). - AC6 (typed intent, unit + ревью кода):
resolveToggleIntent()(device-toggle.ts:610-632) возвращаетoperation: {kind:'virtual-light', markerId}иcommand: nullдля точной тройки, до общей веткиcontrols— marker ID никогда не подставляется вToggleCommand/entity_id;test/device-toggle.test.mjs(«exact manual virtual light wins over saved HA controls») подтверждаетon.command === nullи стабильностьsameToggleOperationTargetsпри смене направления. - AC7 (i18n, диалог): новые ключи
marker.virtual_light_target/ virtual_light_current/virtual_light_state_on/offесть в EN и RU (src/i18n/en.json,src/i18n/ru.json), formatter вhouseplan-card.ts(~17025-17046) ветвится поtarget.via === 'virtual-light'до общегоtoggle_hint_current/toggle_hint_single— старыйno_actionable_entityhint для этой тройки не строится, так какresolveToggleIntentвозвращает ненулевой intent раньше проверкиno-actionable-entity. - AC8 (initial snapshot, backend):
ws_config_getвызываетasync_virtual_light_snapshotвнутриrt.write_lock, тем жеconfig_rev, что и возвращаемыйconfig/rev— снэпшот согласован с той же ревизией конфигурации, на которой backend проверял eligibility (прочитано в коде;test_default_toggle_event_and_restart_persistenceподтверждает восстановлениеoffпослеasync_reload— прочитано, не исполнено, требует HA harness). - AC9 (config writer lifecycle, backend): все места, где раньше был
прямой
rt.config_store.async_save({...})(_converge_pair,ws_config_set,ws_plan_optimize,ws_plan_optimize_undo, миграция в__init__.py), заменены наasync_save_config_state, которая безусловно вызываетasync_reconcile_virtual_lightsс явнымprevious_rev/previous_config_rev— единая точка, разрозненных ручныхvirtual_light_store.async_save()не найдено (grepпоwebsocket_api.py/__init__.pyподтверждает единственный вызывающий путь). Fail-safe на неизвестный revision gap (state_config_rev == config_revиначеoff = set()) реализован и вasync_virtual_light_snapshot, и вasync_reconcile_virtual_lights— прочитано, соответствует ТЗ §7.5. - AC11/AC13/AC14 (live-sync, confirmation, cache invalidation):
config-store.ts/houseplan-card.tsподписываются наhouseplan_virtual_light_updatedрядом с существующими событиями,applyVirtualLightEventмонотонна поrev(тест «events are monotonic and never optimistic» и «event-before-response ordering never rolls state back» вtest/virtual-light-state.test.mjs» умеют падать — проверено инверсией условия мысленно:rev <= current.revиначеevent.onне проверялся бы).virtualLightFingerprintучаствует в ключеRESOLVED_LIGHT_CACHE(devices.ts), поэтому событие инвалидирует Glow/room-граф без HA state tick — подтверждено smoke-проверкойoneServerTogglePerGesture(одно серверное действие на один жест) и фактическим обновлениемstaticOn/on(second)в дебаг-прогоне безcontrols`. - AC15 (build/bundle parity):
npm run buildвоспроизводим, три копии бандла побайтно идентичны (см. §2). - AC16 (документация): оба changelog,
README.md,docs/LIGHT.md,docs/ARCHITECTURE.md,docs/CONFIG-COMPATIBILITY.md,docs/USER-GUIDE.ru.mdобновлены в том же коммите, ссылаются на #107, описывают точную тройку, persistence/permissions, исключение из экспорта — сверено построчно, расхождений с реализацией не найдено (кроме того, что H1 делает фразу README «updates Glow, room fill/statistics, full cards andhouseplan-space-cardtogether» неполной для комбинации сcontrols— само по себе не отдельная находка, устранится вместе с H1). - Трейлеры: коммит
1079cdfнесётIssue: #107,User-Visible: yes, оба changelog правлены в том же коммите — соответствует.
5. Чего не проверял
- Полный HA backend harness (
pytest-homeassistant-custom-component) — недоступен в этой среде (не.venv-backend, чистый Linux-раннер без предустановленного пакета). AC2/AC3/AC4/AC8/AC9, чьё единственное автотест-доказательство —test_ha_virtual_lights.py, разобраны только чтением кода и тестового файла (см. §4), не исполнением. Тесты выглядят корректными и умеющими падать по структуре (явные значенияrev/onна каждом шаге, явныйmonkeypatchдля forced-failure сценария), но это не заменяет фактический прогон — Linux CI jobbackendостаётся канонической проверкой для них. npm run golden:verify, полный набор из 127demo/smoke_*.mjs, performance-профили — не прогонялись, обоснование в §2 (объём соразмерен задаче по PROCESS.md §8; они относятся к pre-beta gate).- Продуктовое соответствие
docs/SCOPE.md/выбор J1/J3 — не пересматривалось повторно, это было предметом ревью ТЗ, а не код-ревью. - Не проверялся весь возможный матрикс
is_light/tap_actionсмены онлайн (rename/move/hidden/tombstone и т.д., AC9 edge cases) сверх того, что показываетtest_lifecycle_preserves_hidden_and_prunes_when_eligibility_ends— тест прочитан и логически согласован с кодом, не исполнен.
6. Вердикт
H1 — реальный, воспроизведённый прогоном собственного целевого smoke-теста
дефект: презентация маркера (иконка/CSS-класс, используемый полной и
статической карточками) не отражает ручное состояние виртуального света,
когда у того же маркера есть сохранённые исходящие controls — то есть ровно
в комбинации, которую требует покрывать AC10+AC12 и которую строит сам
demo/smoke_virtual_light_toggle.mjs. Это High: блокирует.
M1 — дефект тестовой инфраструктуры (не блокирует CI, но ломает
документированный локальный офлайн-прогон pytest tests_backend -q из
AGENTS.md для всего каталога, а не только для нового файла) — Medium,
обязан стать отдельным issue.
Задача возвращается в S6-in-progress.