24 KiB
CODE-REVIEW-29-r1
- Issue: #29 «[HP-UX-02] inbox и жизненный цикл устройств»
- Этап: code (PROCESS.md §2.7)
- Заход: r1 · блокирующих циклов израсходовано 0 из 4 (первый реальный код-ревью: предыдущая попытка была остановлена до чтения кода из-за конфликта ребейза — см. комментарий issue от 2026-08-27T22:42:46Z — поэтому дельта-режим §2.9 не применяется, разбор ниже полный)
- Проверено на SHA
905d4847a3797a8830eb6c5c509351f142193b73(веткаissue/29-device-inbox-lifecycle, перебазирована наorigin/dev@2c20f2d) - ТЗ:
docs/specs/029-device-inbox-lifecycle.md, зелёный spec-review r2 (docs/reviews/SPEC-REVIEW-29-r2.md)
Скоуп изменения
Один коммит 905d4847 (Issue #29, User-Visible: yes, оба CHANGELOG в этом же
коммите):
- новый чистый модуль
src/device-inbox.ts(281 строка):bindingCandidates(общий eligibility-helper, вынесенный из_bindingCandidates),buildDeviceInbox(резолвер строк каталога),filterDeviceInbox; src/houseplan-card.ts: диалог каталога устройств (_renderDeviceInboxи ~15 приватных методов), замена кнопок «Добавить»/«Скрытые и деактивированные» на «Устройства», перенос ghost-toggle внутрь каталога;src/styles/dialogs.styles.ts(+135),src/hp-dialog.ts(параметризована ширина wide-диалога);src/i18n/en.json/ru.json(+45 ключейdevice_inbox.*, обновлёнmarker.hide_tip);test/device-inbox.test.mjs(154 строки, чистый модуль);demo/smoke_device_inbox.mjs(новый),demo/smoke_hidden_flag.mjs(адаптирован под перенос ghost-toggle);demo/golden/{harness,matrix}.mjs— 3 новых golden-сценария (device-inbox-desktop-en-light/ru-dark/narrow-ru-dark);scripts/mutation-gate.mjs,tsconfig.test.json— точечные правки под перенос кода вdevice-inbox.ts;- документация:
docs/ARCHITECTURE.md,docs/FILTERING.md,docs/TESTING.md,docs/USER-GUIDE.md/.ru.md,docs/STATUS.md,docs/CHANGELOG.md/.ru.md, golden-скриншоты иscreenshots.json(docs-check зелёный, см. ниже).
Как проверялось
Гейты, реально прогнанные мной на дереве SHA 905d4847:
| Команда | Результат |
|---|---|
npx tsc --noEmit |
зелёный, без вывода |
npm test |
# tests 1400 / pass 1399 / fail 0 / skipped 1 |
npm run build |
собран dist/houseplan-card.js за 10.8s |
md5sum dist/… custom_components/… |
идентичны после чистой пересборки (bundle:sync подтверждён) |
node scripts/check-docs.mjs |
Documentation checks passed (7 files, 10 external links) |
npm run bundle:sync |
пересобрал и синхронизировал все три копии, включая недостающую demo/srv/assets/… |
node scripts/smoke-select.mjs --base origin/dev --head HEAD |
75 прямых совпадений, много «зарегистрированных связей», 41 слабая связь (полный вывод ниже) |
node demo/smoke_device_inbox.mjs |
OK, все 12 проверок true |
node demo/smoke_binding_picker.mjs |
OK, все 24 проверки true |
node demo/smoke_hidden_flag.mjs |
OK, все 27 проверок true |
node scripts/mutation-gate.mjs --id=device-tombstone-blocks-child-picker |
поймано 1 из 1 — тест умеет падать |
npm run inventory |
Node unit 1400, backend pure 198, HA-harness 143, browser smokes 194 (совпадает с числами автора после ребейза) |
node demo/smoke_editor_tabs.mjs |
FAILED — devBarBtns: expected true, got false (находка H1 ниже) |
gh run view на точный SHA 905d4847 |
CI уже прогнан и red: job «Смоки в браузере (шард 2 из 3)» упал именно на smoke_editor_tabs с той же ошибкой; job «Golden» упал на 3 missing-baseline для новых сценариев (ожидаемо, см. «Что не проверялось») |
smoke-select — «прямое совпадение» (75) в основном ложноположительное: почти
все совпадения идут по родовым символам _markerDialog/_openMarkerDialog/
_maybeRebuildDevices/_infoCard/_config/_spaceDialog/_zoom, которые
задеты только потому, что this._markerDialog = null заменено на
this._closeMarkerDialog() в нескольких местах кода, не относящихся к
каталогу. Прочитал _closeMarkerDialog() (houseplan-card.ts:14173-14186): вне
пути возврата из каталога (_deviceInboxReturn не установлен) он делает ровно
то же самое, this._markerDialog = null, — поведение для всех смоков вне темы
каталога не меняется. Прогонять все 75 не стал; прогнал три прямо тематических
(smoke_device_inbox, smoke_binding_picker, smoke_hidden_flag — все зелёные
выше) и, поскольку инструмент относит smoke_editor_tabs.mjs только к «слабой
связи» (общее имя _saveConfig), посмотрел его отдельно из-за прецедента
#234 («по названию не про тему — на деле регрессия») — и он оказался красным
(находка H1). Остальные слабые связи не прогонял: все они делят по одному
общему символу (_saveConfig/_applyView/_zoom/_model и т.п.) с
изменёнными строками, не относящимися по смыслу к каталогу устройств.
Прочитан полный diff src/houseplan-card.ts (687 строк) построчно и полностью
модуль src/device-inbox.ts; сверено с ТЗ по каждому AC1–AC11 (детали в
следующем разделе), с docs/SCOPE.md (J4/J6, admin persona, desktop-first) и
с docs/USER-GUIDE.ru.md (терминология «На плане/Доступны/Скрытые/Доступны
снова», «Показывать скрытые на плане» и т.д. совпадает 1:1 с UI-строками).
Находки
H1 — demo/smoke_editor_tabs.mjs сломан этим диффом (High, в скоупе)
demo/smoke_editor_tabs.mjs:159 — существующая, не тронутая этим PR строка:
out.devBarBtns = sr().querySelectorAll('.editbar.devbar .btn:not(.barclose)').length === 3; // add/show-all/rules (v1.33.2: Reset removed)
ТЗ §10.1 сознательно заменяет две кнопки («Добавить», «Скрытые и деактивированные») на одну («Устройства»), оставляя «Правила иконок» — итоговый devbar имеет 2 кнопки, не 3. Ассерт не обновлён.
Воспроизведение — не гипотеза, дважды подтверждено:
- Локально:
node demo/smoke_editor_tabs.mjs→FAILED (1): - devBarBtns: expected true, got false. - В CI на точном SHA задачи: run
33123777345(Проверка (CI), SHA905d4847…), job «Смоки в браузере (шард 2 из 3)» (https://github.com/Matysh/houseplan-card/actions/runs/33123777345/job/98697171028) уже красный с той же строкой:FAIL smoke_editor_tabs/devBarBtns: expected true, got false.
Это ровно тот класс дефекта, о котором предупреждает прецедент #234: смок
называется «editor_tabs» и не намекает на устройства, smoke-select относит
его только к слабой связи по общему имени _saveConfig — но по факту это
прямая регрессия темы задачи. smoke — один из обязательных gate-джобов
(AGENTS.md: docs, provenance, process-gate, hacs, hassfest, frontend, smoke, golden, performance_smoke, backend), и он объективно красный на SHA
задачи прямо сейчас, а не гипотетически. Фикс тривиален и целиком в скоупе
задачи: обновить ожидаемое число (и комментарий add/show-all/rules) до 2
кнопок/devices/rules; отдельный issue не заводится (решение владельца
2026-08-19, #202).
M2 — «комната» в каталоге — это имя HA-зоны, а не название комнаты плана (Medium, в скоупе)
src/houseplan-card.ts, _deviceInboxRows():
const areaNames: Record<string, string> = {};
for (const [id, area] of Object.entries<any>(this.hass?.areas || {})) areaNames[id] = area?.name || id;
Это единственный источник поля areaName/поисковой строки для «комнаты» в
каталоге (src/device-inbox.ts строит searchText и мета-строку строго из
переданных areaNames). Значение берётся из имени HA-зоны (hass.areas),
а не из имени комнаты плана (RoomCfg.name), которое пользователь
свободно переименовывает независимо от HA (единственное место записи —
room.name = this._nameSel.trim() || room.name в диалоге комнаты,
houseplan-card.ts:19386, без всякой синхронизации с зоной HA) и которое
показывается везде на самом плане (подпись комнаты на канвасе —
${r.name} — houseplan-card.ts:19054; sub-label бинд-пикера —
(sp.title || sp.id) + ' · ' + r.name, houseplan-card.ts:14571/14576).
Сам каталог явно различает эти два понятия: причина no_bound_room
переведена как «Зона HA не связана с комнатой плана» — то есть «зона» и
«комната» осознанно не одно и то же. При этом ТЗ §10.2 требует показывать в
строке «пространство и комнату», а docs/USER-GUIDE.ru.md (этот же коммит)
прямо обещает: «Поиск работает по имени, модели, интеграции, комнате,
пространству и exact binding» — то есть комнатой пользователю обещан
объект, названный так, как он называется на плане, а не как называется зона
Home Assistant.
Воспроизводимый сценарий: пользователь переименовал в редакторе плана
комнату «Kitchen» (имя зоны HA) в «Кухня-столовая» (room.name). На плане и
в бинд-пикере отображается «Кухня-столовая». В каталоге устройств та же
строка покажет и будет искаться по «Kitchen» — расхождение с тем, что видно
на самом плане, прямо в задаче, продукт которой — «объяснить, почему
устройство там, где оно есть» (§2 «После» ТЗ). Это не гипотетический边ge
case: room.name независимо редактируется в стандартном рабочем процессе, не
только в экзотике.
Исправление в скоупе: строить areaNames (или отдельный roomNames) из
this._areaToSpace[areaId]?.room?.name, а не из this.hass?.areas; на
hass.areas можно откатываться только для available-кандидатов без
привязанной комнаты (no_bound_room), где комнаты действительно ещё нет.
L3 — DeviceInboxRow.canOpenHa — мёртвое поле (Low)
src/device-inbox.ts:245: const canOpenHa = kind === 'device' || kind === 'entity'; — тривиально всегда true (у kind только два значения), и само
поле нигде не читается: рендер каталога (houseplan-card.ts) использует
отдельный, независимый метод this._bindingHasHaPage(row.binding) для того
же решения. Не влияет на поведение — предлагаю удалить поле как неиспользуемое,
либо, если оно задумано для другого потребителя, использовать per отчёту.
Не блокирует; можно оставить с пометкой в этом документе (снято ревьюером).
Проверено чтением и признано корректным
- AC1 (единая точка входа): подтверждено кодом и
smoke_device_inbox(oneCatalogEntryPoint), плюсsmoke_editor_tabsфактически доказывает замену кнопок (см. H1 — сам факт правильный, сломан только счётчик). - AC2 (детерминированная классификация): приоритет
removed → hidden → on_plan → availableвbuildDeviceInbox(device-inbox.ts:219-224) читается ровно как в §7.2 ТЗ; unit-тест «full lifecycle matrix» покрывает auto no-marker, manual/automatic hidden, ha_disabled, orphaned, unverified, removed active/missing, parent-tombstone+live-child, synthetic light group, candidate без комнаты — все проходят зелёным. - AC3 (auto/new):
isNew: !!runtime && newDeviceIds.has(runtime.id), подтверждён unit-тестами (device:auto/device:d3) и не снимается Find (_findInboxDeviceне трогает_newIds), снимается только через_openMarkerDialog→_ackNewDevice(существующий контракт, не менялся). - AC4 (lifecycle/HA-status независимы): проверено unit-тестом
«HA status overlays lifecycle…» —
hidden:true+ha_disabledостаётся вhidden,canShow=false,canFindвключается только вместе сshowHiddenOnPlan;limited registry(unverified) не создаёт ложный disabled/orphaned — статус остаётсяunverifiedдословно. - AC5 (exact binding/re-add): device-parent tombstone и live entity child
дают разные строки (
readd/on_plan) в unit-тесте №1; логика материализации Re-add переиспользует немодифицированный_saveMarker/deletePlanMarkerRecords(контракт #262), только точка входа_closeMarkerDialogзаменяетthis._markerDialog = null. Подтвержденоsmoke_binding_picker(24/24) и mutation-gate (device-tombstone-blocks-child-picker— поймано 1/1). - AC6 (действия): Find/Edit/Hide/Show/Add/Hide-from-list/Re-add сверены
построчно с таблицей §10.3;
_setInboxHiddenиспользует существующийmarker.hidden, не создаёт tombstone, требуетstatus.kind==='active', что совпадает сcanHide/canShowв резолвере. Ghost-toggle перенесён и проверенsmoke_hidden_flag(ghostToggleMovedIntoCatalog,ghostInEditorи весь остальной набор — 27/27). - AC7 (read-only): открытие/поиск/смена вкладки/Show more/Find не
вызывают
_saveConfig/websocket-запись — проверено чтением всех обработчиков (только присвоенияthis._deviceInbox = {...}) и подтвержденоsmoke_device_inbox.browsingIsReadOnly(перехватcallWS, сравнениеconfig/layout/cfgRev/layoutRevдо/после — 0 записей). - AC8 (возврат/refresh):
_deviceInboxReturnвосстанавливает tab/search/filter иanchorпосле Cancel/Save,_deviceInboxMemoинвалидируется поcfgRev/cfgEpoch/regSignature/newSyncKey— подтвержденоsmoke_device_inbox.nestedCancelReturnsContext. - AC9 (поиск/большие реестры):
bindingCandidatesне режет список до фильтрации (только глобальная сортировка), пагинация (limit) применяется строго послеfilterDeviceInbox— подтверждено unit-тестом на 260 сущностях (находитentity_259, за пределами старого cap=200) иsmoke_device_inbox.searchUsesFullSnapshot. - AC10 (accessibility/responsive):
role=tablist/aria-selected,aria-live=polite, стрелочная навигация (_deviceInboxTabKey, подтвержденаsmoke_device_inbox.arrowChangesTab), нет горизонтального скролла на десктопе (noHorizontalOverflow) и в golden narrow-сценарии (device-inbox-narrow-ru-dark, ширина 390px, harness проверяетscrollWidth <= clientWidth+1). Полный ручной keyboard-проход (Tab по всем действиям, focus-restore при закрытии) не выполнялся — см. «Что не проверялось». - AC11 (compatibility):
npm testзелёный целиком (1399/1400, 1 пропущен), включая незатронутые regression-наборыdevices.test.mjs,ha-binding-status.test.mjs; miграция конфигурации не добавлена (filter_seeded/show_allлогика не изменена по существу — см. отдельно ниже). - Три golden-сценария (
device-inbox-desktop-en-light/ru-dark,device-inbox-narrow-ru-dark) добавлены в матрицу и harness-препарация проверяет 4 вкладки + отсутствие горизонтального переполнения. - Трейлеры коммита корректны: один коммит,
Issue: #29,User-Visible: yes, оба CHANGELOG в этом же коммите. - Отдельно проверил переход
_showAll/_showHidden/legacysettings.show_all(комментарий автора в коде: «Legacy configs still honour it through buildDevices until they are seeded»): для конфигов доfilter_seededокно, где чекбокс «Показывать скрытые на плане» не мог бы выключить унаследованныйshow_all:true, закрывается автоматически при первом_maybeRebuildDevices()редактирующего клиента (сидер удаляетsettings.show_all, houseplan-card.ts:3693) — это существующий, немодифицированный этой задачей механизм; не завожу как находку.
Чего не проверял и почему
- Полный набор
demo/smoke_*.mjs(194 файла) — не прогонял целиком; прогнал 4 тематических (device_inbox,binding_picker,hidden_flag,editor_tabs— последний отдельно из-за прецедента #234) плюс сверил выводsmoke-select. Полный набор — обязанность pre-release гейта (PROCESS.md §8) и одновременно уже прогнан в CI (см. H1) — красный сейчас именно наeditor_tabs, других красных смоков в этом прогоне CI не было. npm run golden:verify/ приёмка baseline — не прогонял. Три новых golden-ID (device-inbox-*) не имеют базовых кадров; CI на этом SHA уже показалmissing-baselineдля всех трёх (job «Golden», run 33123777345) — это ожидаемое, задокументированное поведение (PROCESS.md: baseline принимается толькоnpm run golden:accept -- --reviewedна полном Linux CI-артефакте, не в рамках код-ревью), а не находка.python -m pytest tests_backend— diff не трогаетcustom_components/**/*.py; backend-код не менялся.- Performance-профили — АС/риски (§17 ТЗ) не called out perf budget,
smoke-selectне выделил performance-чувствительные пути; unit-тест на 260 записях покрывает синтетический large-registry сценарий, отдельный benchmark не запускал. - Ручной keyboard/screen-reader проход — полагался на чтение разметки (roles/aria-атрибуты) и golden/smoke автоматику; полного ручного прохода Tab-порядка по всем действиям строки не делал.
- 71 из 75 «прямых совпадений»
smoke-select— не прогонял; обоснование см. в разделе «Как проверялось» (общие символы_markerDialogи т.п., поведение вне пути возврата из каталога не меняется — проверено чтением_closeMarkerDialog).
Вывод
Один подтверждённый High (сломанный существующий смок, воспроизведён и локально, и в реальном CI-прогоне на SHA задачи) и один Medium в скоупе (источник имени «комната» в каталоге — HA-зона вместо имени комнаты плана, расходится с остальным UI и с обещанием user-guide). Оба чинятся в этой же задаче без нового issue. Функциональное ядро (резолвер, read-only контракт, поиск, re-add/exact-binding семантика) проверено тестами, которые умеют падать (mutation-gate), и не вызывает возражений.