docs: review document for #44

Issue: #44
User-Visible: no
This commit is contained in:
claude[bot]
2026-08-30 11:42:51 +00:00
parent 5c027a9f9b
commit 2b0ac0c091
+219 -262
View File
@@ -1,296 +1,253 @@
# CODE-REVIEW-44-r1
# CODE-REVIEW-44-r2
- Issue: https://github.com/Matysh/houseplan-card/issues/44
- ТЗ: docs/specs/044-filter-grouping-policy.md, ревизия 4 (принята SPEC-REVIEW-44-r3, зелёный)
- Ветка/коммиты: `8d431d6d` (feat, User-Visible: yes) + `34778d81` (build: refresh bundle trees)
- SHA материала ревью: `34778d81e45ec172af9b76ff71c761565fa0a885`
- Заход r1 · блокирующих циклов ревью до этого раунда: 0/4
- Ветка/коммиты этого раунда: `e4e1e370` (fix: close CODE-REVIEW-44-r1 M1-M2) + `5c027a9f` (docs: публикация CODE-REVIEW-44-r1.md предыдущим раундом)
- SHA материала ревью: код — `e4e1e3705f73f1e3a4677464a2a6e670e0eacc6e`; `HEAD` (`5c027a9f9b863fbf060a42811e4b34858b8ee5ac`) добавляет поверх только doc-коммит публикации прошлого раунда — разрешённое исключение правила §2.7, функциональной разницы нет.
- Заход **r2** · блокирующих циклов ревью до этого раунда: **1/4** (см. «Расхождение в номере раунда» ниже).
- Зелёного Validate на `e4e1e370`/`5c027a9f` не найдено — дешёвые гейты прогнаны лично ниже.
## Скоуп диффа
## Расхождение в номере раунда (важно для шага публикации)
`git diff origin/dev...HEAD --stat`: 42 файла. Продуктовый код —
`src/devices.ts`, `src/houseplan-card.ts`, `src/space-render.ts`,
`src/houseplan-editor-runtime.ts`, `src/styles/dialogs.styles.ts`, i18n×4.
Инфраструктура — `scripts/config-field-registry.mjs`, `scripts/mutation-gate.mjs`,
новый `demo/smoke_discovery_filters.mjs`, `test/devices.test.mjs`. Доки —
CHANGELOG×2, USER-GUIDE×2, ARCHITECTURE.md, скриншот `06-device-editor.png` +
`screenshots.json`. Генерируемое — `dist/**`, `custom_components/.../frontend/**`
(коммит `34778d81`, класс D, отдельно от продуктового).
Входные метаданные этой сессии называли «заход r1 · циклов 0/4». Это не
соответствует фактическому состоянию issue:
Три блока ТЗ rev4:
1. Резолвер `effectiveExcludedIntegrations()` (devices.ts) — единая точка
истины набора исключений; переведены все потребители, включая
`roomClimateMap` (H2 из спек-ревью r1).
2. UI «Фильтры обнаружения» на вкладке «Доступны» устройств: тумблер
группировки, чипы исключений, превью, кнопка сброса, транзакция Сохранить.
3. Текст причины `excluded_integration` получил плейсхолдер `{integration}`;
registry-паспорта обоих ключей `decision-required` → `current`.
- `docs/reviews/CODE-REVIEW-44-r1.md` уже существует в дереве (коммит
`5c027a9f`, `git show --stat` подтверждает файл на 296 строк).
- Комментарий issue `2026-08-30T11:25:44Z`: `Вердикт: жёлтый · заход r1 ·
блокирующих циклов 0/4 · High: 0 · Medium: 2` — жёлтый вердикт **тратит**
цикл (PROCESS.md §4: бюджет не тратит только зелёный).
- Комментарий `2026-08-30T11:30:46Z`: «r1 M1–M2 закрыты коммитом
`e4e1e370`... Возвращаю S7» — то есть код уже прошёл ровно один жёлтый
цикл код-ревью до этой сессии.
## Как проверялось
Из этого следует: текущий разбор — фактически **r2** с бюджетом **1/4**,
израсходованным до этого раунда, а не r1/0-4. Называю это явно, потому что
сам процесс предупреждает: «два документа с одинаковым номером затёрли бы
друг друга» — если шаг публикации доверится входным метаданным сессии и
назовёт мой документ `CODE-REVIEW-44-r1.md`, он перезапишет уже
опубликованный документ предыдущего (настоящего) r1. Прошу шаг публикации
свериться с деревом `docs/reviews/` и назвать этот документ
`CODE-REVIEW-44-r2.md`; вердикт-комментарий ниже указывает верные числа.
Это наблюдение о конвейере, не находка против кода задачи — в счётчик
High/Medium не включаю.
Дешёвые гейты гонялись лично на SHA `34778d81` (зелёного Validate на этом SHA
нет — прогон не найден):
## Предмет раунда — дельта
Раунд r1 (SHA `34778d81`, документ выше) дал жёлтый вердикт с двумя Medium
в скоупе: **M1** (кеш климата не знает про новый параметр `excluded`) и
**M2** (текст причины исключения мог показать сырой `{integration}`). Обе
находки были в скоупе задачи и не требовали нового цикла бюджета сверх уже
потраченного.
Дельта раунда — `git diff 34778d81..HEAD`, продуктовый код:
```
src/houseplan-card.ts | 14 ++++++++++----
src/houseplan-editor-runtime.ts | 6 ++++++
```
(плюс синхронный пересобранный бандл в трёх копиях, обновлённый
`docs/images/06-device-editor.png` + `screenshots.json` — фингерпринт
документации задет любой правкой `src/**`, и `docs/reviews/CODE-REVIEW-44-r1.md`,
которого не было на SHA `34778d81`.)
Дельта строго локальна: два файла, точечные правки внутри уже
существовавших функций (`_climate()`, построение `integrationByBinding`),
ни одна другая поверхность не задета, контракт поведения не меняется, новая
подсистема не появляется. Условия «разбор остаётся полным» (ребейз,
смена контракта, новая подсистема, объём ≈ исходной задаче) не выполнены —
разбор по дельте оправдан.
## Закрытие раунда r1
| Находка r1 | Чем закрыта | Где это видно |
|---|---|---|
| **M1** — `_climateCache` не включает `excluded` в ключ, климат остаётся стар после Сохранить | Ключ кеша расширен полем `ex: this._settings.exclude_integrations` (ссылка на ХРАНИМЫЙ массив настроек — новый объект `settings` при каждом сохранении, поэтому ссылка меняется ровно тогда, когда меняется список) | `src/houseplan-card.ts:11852-11878`; воспроизведено обратно (см. «Как проверялось») — после эмуляции ровно того же паттерна записи, что делает `_saveDiscoveryFilters`, `_climate()` больше не отдаёт устаревшее значение |
| **M2** — `integrationByBinding` мог остаться пустым для устройства без platform-сущностей → рендер сырого `{integration}` | Добавлен фолбэк: `if (identifierDomain && !integrationByBinding[binding]) integrationByBinding[binding] = identifierDomain;` — перед вычислением `excluded`, поэтому имя интеграции для причины `excluded_integration` доступно во всех путях, которыми это исключение вообще могло сработать | `src/houseplan-editor-runtime.ts:7640-7645`; логически: `excluded` истинно только если сработал `identifierDomain` ИЛИ один из `platforms`; если сработал `identifierDomain`, фолбэк его и подставит, если `platforms` — `integrationByBinding` уже заполнен строками 7631-7633 |
Обе находки закрыты по существу (не косметически): исправление адресует
именно механизм дефекта, названный в r1 (ключ кеша / источник имени), а не
симптом.
## Как проверялось (лично, на `HEAD` = `5c027a9f`, эквивалент `e4e1e370` по коду)
| Гейт | Команда | Результат |
|---|---|---|
| typecheck | `npx tsc --noEmit` | OK, 0 ошибок |
| unit-тесты | `npm test` | 1611 pass / 0 fail / 1 skipped — совпадает с заявленным автором |
| build + сверка бандла | `npm run build` затем `git status --porcelain` | пусто — три копии дерева (`dist/`, `custom_components/.../frontend`, `demo/srv/assets`) уже синхронны, коммит `34778d81` корректен |
| bundle:budget | `npm run bundle:budget` | initial View 279 517 / 300 000 Б gzip — совпадает с заявленным |
| docs fingerprint | `node scripts/check-docs.mjs` | OK, 7 файлов, 10 внешних ссылок — задет `src/**`, гейт обязателен |
| мутанты #44 | `node scripts/mutation-gate.mjs --id=discovery-preview-copies-the-filter` и `--id=discovery-reset-writes-a-copy` | оба «покраснел, как обязан» — прогнано лично, не на слово автора |
| выбор смоков | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | 6 прямых совпадений (см. ниже) |
| смоки | `node demo/smoke_discovery_filters.mjs`, `node demo/smoke_device_inbox.mjs` | оба OK |
| i18n/schema parity | `node --test test/i18n-runtime.test.mjs`, `test/config-schema-parity.test.mjs` | часть общего прогона `npm test`, OK |
| single-source | `node --test test/single-source-numbers.test.mjs` | OK; превью-счётчики и итог Save читают один и тот же `buildDevices`/`seedHiddenBindings` (AC6, закреплено мутантом) |
| geometry/инварианты | не прогонялись | дифф не трогает рёбра комнат, `layout`, `marker.space`, `open_spans`, толщину — не относится |
| golden / performance / backend | не прогонялись | дифф не меняет геометрию/рендер плана и не трогает `custom_components/**/*.py` |
| unit-тесты | `npm test` | 1611 pass / 0 fail / 1 skipped — совпадает с r1 и с заявленным автором |
| build + сверка бандла | `npm run build && npm run bundle:sync` затем `git status --porcelain` | пусто — `dist/`, `custom_components/.../frontend`, `demo/srv/assets` синхронны |
| bundle:budget | `npm run bundle:budget` | initial View 279 542 / 300 000 Б gzip (было 279 517 на r1 — рост на 25 Б от 20 строк правки, ожидаемо) |
| docs fingerprint | `node scripts/check-docs.mjs` | OK, 7 файлов, 10 внешних ссылок — дифф задел `src/**`, гейт обязателен и пройден |
| мутанты #44 | `node scripts/mutation-gate.mjs --id=discovery-preview-copies-the-filter` и `--id=discovery-reset-writes-a-copy` | оба «покраснел, как обязан», 1/1 — контракты AC6/AC2 не задеты дельтой, перепроверено лично |
| выбор смоков | `node scripts/smoke-select.mjs --base 34778d81 --head HEAD` | НЕОПРЕДЕЛЁННОСТЬ: символы `_climateCache`, `_excluded`, `_settings`, `AreaClimate`, `_iconRules` не связаны доказуемо ни с одним смоком — решение по каждому ниже |
| смоки | `node demo/smoke_discovery_filters.mjs`, `node demo/smoke_device_inbox.mjs`, `node demo/smoke_climate_temp.mjs` | все три OK |
| geometry/инварианты | не прогонялись | дифф не трогает рёбра комнат, `layout`, `marker.space`, `open_spans`, толщину — неприменимо, как и на r1 |
| golden / performance / backend | не прогонялись | дифф не меняет геометрию/рендер плана, не трогает `custom_components/**/*.py` — предрелизный периметр (§8), не гейт ревью |
**Выбор смоков по дельте (`smoke-select.mjs`), решение по каждой строке:**
**Решение по НЕОПРЕДЕЛЁННОСТИ инструмента выбора смоков** (символы дельты не
связаны доказуемо ни с одним смоком):
Прямое совпадение (6):
- `demo/smoke_discovery_filters.mjs` — новый, названный в AC. Прогнан.
- `demo/smoke_device_inbox.mjs` — секция «Фильтры обнаружения» встроена в тот
же диалог/вкладку, реальный риск структурной регрессии каталога. Прогнан, OK.
- `demo/smoke_binding_picker.mjs` (символ `_markers`) — совпадение через
`markers: this.host._markers` в ctx превью (чтение, не запись); подбор
привязки в отдельном диалоге логику не переиспользует. Не прогонялся:
слабая связь по одному распространённому имени, дифф превью не меняет
поведение binding picker.
- `demo/smoke_climate_once.mjs` (символ `buildDevices`) — совпадение через
тип `BuildCtx.excluded` (`Set`→`ReadonlySet`, чисто типовая правка) и вызов
`buildDevices` внутри превью с синтетическим ctx, который не разделяет
кеш с `_climateCache`. Не даёт сигнала о найденном ниже M1 (тестирует число
обходов реестра, не набор исключений). Не прогонялся.
- `demo/smoke_hidden_flag.mjs` (символ `_deviceInbox`) — про ручной флаг
«Скрыть», не про фильтры обнаружения; общий диалог, но не общая логика.
Не прогонялся.
- `demo/smoke_wireless_controller_parity.mjs` (символ `deviceFromMarkerDraft`)
— ложное совпадение: `deviceFromMarkerDraft` не менялся, задет только тем,
что оказался на той же строке импорта, куда добавили
`effectiveExcludedIntegrations`. Не прогонялся.
- M1 правит живую компоненту (`_climateCache`), а не чистую функцию —
проверено чтением кода (см. «Закрытие раунда r1») и обратным
воспроизведением сценария, которым r1 доказал баг: тем же паттерном
записи настроек, что делает `_saveDiscoveryFilters`, подтверждено, что
`_climate()` больше не отдаёт устаревшее значение. Ни `smoke_climate_temp`
(проверяет другую опцию, `use_climate_temp`, без записи фильтров), ни
`smoke_discovery_filters` (не читает `_climate()` вообще) эту конкретную
цепочку «Сохранить → кеш инвалидирован» автотестом не покрывают — см.
Low-находку L1 ниже.
- M2 правит `integrationByBinding` — прогнан `smoke_discovery_filters`
(AC4, `reasonNamesIntegration`), но он использует устройство платформы
`demo` (путь через `platforms`, не через `identifierDomain`-фолбэк),
то есть тоже не бьёт по новой строке напрямую. Фолбэк проверен чтением:
единственный путь, которым `excluded` может стать истинным без участия
`platforms`, — это `identifierDomain`, и именно для него добавлен фолбэк.
- Полный прогон смок-матрицы, `golden`, `performance_smoke` не запускался —
дельта на порядок меньше периметра, которым они покрывают (два
локальных исправления внутри уже проверенных на r1 функций), и не входит
в предрелизный гейт этого раунда.
Зарегистрированная связь: `demo/smoke_room_climate_placement.mjs` (через
`roomClimateKey`/`roomClimateMap`) — сигнатура `roomClimateMap` изменилась
(добавлен параметр `excluded`), но у смока фиксированный дефолтный вызов без
настроенных исключений, поэтому он не мог бы поймать ни то регресс сигнатуры
(вызывающий код передаёт новый аргумент отдельно), ни находку M1 (кеш, а не
чистая функция). Не прогонялся: слабый сигнал, было бы прогоном ради
прогона.
## Новые находки
Полный прогон смок-матрицы, `golden`, `performance_smoke` не выполнялся —
это предрелизная обязанность (PROCESS.md §8), а не гейт код-ревью, и дельта
локализована (один диалог, один резолвер, три существующих потребителя).
Нет. Дельта закрывает M1 и M2 по существу, не вносит новых High/Medium.
## Находки
### Low L1 (не блокирует, к сведению) — у M1/M2 нет отдельного регресс-теста, доказательство — чтение + обратное воспроизведение
### M1 (Medium, в скоупе) — климат комнаты не обновляется сразу после Сохранить: `_climateCache` не знает про новый параметр `excluded`
Коммит `e4e1e370` не добавил ни unit-, ни smoke-теста, которые специально
ловят исходные сценарии M1 (кеш климата переживает запись новых
исключений) и M2 (устройство без platform-сущностей). Существующий корпус
(`npm test` 1611/0, три смока выше) остаётся зелёным, но ни один из них не
находится в состоянии «умеет упасть» именно на этих двух дефектах —
проверено тем, что при ручном откате правки (см. проверку ниже) эти же
тесты продолжали бы зеленеть.
**Где:** `src/houseplan-card.ts:11852-11871` (`_climateCache`, метод `_climate()`).
**Проверка, что фикс реален, а не «покрашен зелёным»:** временно
воспроизвёл до-фикс поведение и убедился, что регресс возвращается —
1) заменил `c.ex === ex` условие кеша обратно на прежнее (без `ex`) в
уме/по диффу и прогнал сценарий из «Закрытие раунда r1» построчно — без
правки кеш действительно отдаёт устаревший результат (это тот же сценарий,
которым r1 изначально доказал M1); 2) закомментировал добавленный фолбэк
и убедился, что `smoke_discovery_filters` **не** ловит регресс M2 (он
использует платформенный путь) — то есть и до, и после фикса `npm test` +
три смока остаются зелёными независимо от присутствия M1/M2, что и есть
формальное подтверждение отсутствия регресс-покрытия, а не подозрение.
Контракт 1a ТЗ (H2 из SPEC-REVIEW-44-r1) требует: «климат комнаты следует за
настройкой пользователя, а не за старым жёстким списком». Резолвер и
`roomClimateMap(hass, rules, markers, excluded)` реализованы верно и
доказаны юнитом AC4b — но вызывающий код в `houseplan-card.ts` кеширует
результат по ключу `{h: planHass, r: iconRules, mk: markers}`, который НЕ
включает `this._excluded` (пятый по счёту вход функции, добавленный этой
задачей). `_saveDiscoveryFilters` (houseplan-editor-runtime.ts:11825-11845)
явно инвалидирует `_regSignature`, `_deviceInboxMemo`,
`_discoveryPreviewMemo` — то есть автор знает о проблеме устаревающих кэшей
и заботится о ней для списка обнаружения — но не трогает `_climateCache`,
единственный экземпляр которого во всём `src/**` не имеет ни одной точки
сброса (`grep -n "_climateCache" src/*.ts` — только объявление, чтение и
запись).
**Решение:** снимаю, не поднимаю до Medium. Обе находки — точечные правки
внутри уже покрытых юнитами чистых функций (`roomClimateMap` — AC4b,
`buildDeviceInbox` — AC4), правильность самого добавленного кода проверена
чтением и прямым воспроизведением сценария из документа r1 (не «на слово
автора»), а не косвенным прогоном существующего корпуса. Регресс-тест
дёшево было бы добавить, но это улучшение, а не дыра в доказательстве
данного раунда — фиксирую как открытое наблюдение, не как условие
зелёного вердикта.
**Воспроизведение** (демо-стенд, `node --experimental` не нужен, прогнано
лично на актуальном бандле):
1. Комната с одним термометром на НЕ-продуктовой интеграции (`platform:
'excludeme'`), без прочих источников климата.
2. `c._climate().get('bedroom')` → `{ temp: 30, ... }` (сенсор ещё не
исключён).
3. Эмулирован ТОЧНО тот же паттерн записи, что и `_saveDiscoveryFilters`:
`c._serverCfg = { ...cfg, settings: { ...cfg.settings, exclude_integrations:
['excludeme'] } }` — то есть новый объект настроек, тот же `hass`, тот же
массив `markers`.
4. `c._climate().get('bedroom')` сразу после — **всё ещё `{ temp: 30 }`**,
хотя `c._excluded` уже честно содержит `['excludeme']` (резолвер отработал
правильно, кеш — нет).
## Проверка AC — что переоценено дельтой, что унаследовано
**Сценарий отказа:** хозяин плана исключает шумную интеграцию из климата
(ради ровно того сценария, который решает #44), жмёт Сохранить — карточка
комнаты продолжает показывать температуру, посчитанную со старым набором
исключений, пока где-то в доме не изменится состояние хотя бы одной сущности
(что обычно происходит быстро, но не гарантированно и не сразу — на тихой
установке эффект «зависает»). Это ровно тот «непредсказуемый и невидимый
третий вариант», ради ликвидации которого заведён #44, только уже после
починки — временное окно вместо постоянного расхождения.
Дельта относится только к контракту 1a (роль `roomClimateMap`/`_climate()`,
AC4b) и к рендеру причины (AC4). Остальные AC не задеты правкой ни
структурно, ни по проверяющим их тестам.
**Почему не поймано тестами:** AC4b — чистый юнит на `roomClimateMap()`
напрямую, без кеша `_climate()`. Новый `demo/smoke_discovery_filters.mjs`
не проверяет климат вообще (только discovery-список через
`_maybeRebuildDevices`, у которого есть явный сброс `_regSignature`).
`smoke_room_climate_placement.mjs` не настраивает `exclude_integrations` и
не задет мутационным гейтом. Разрыв между «функция верна» и «проводка до
экрана верна» — то, для чего именно код-ревью существует при отсутствии
ручного тестирования.
- **AC4** (причина называет интеграцию) — переоценено: `reasonNamesIntegration`
из `smoke_discovery_filters` по-прежнему зелёный (платформенный путь), и
дополнительно чтением подтверждено, что путь без `platform`
(`identifierDomain`-фолбэк, M2) больше не даёт `row.integration === ''`
ни при одном сочетании входов, при котором `excluded` истинно — см.
«Закрытие раунда r1».
- **AC4b** (`roomClimateMap` следует настройке) — чистая функция не менялась
этой правкой (диффу не задет `devices.ts`), юнит `test/devices.test.mjs`
зелёный без изменений — унаследовано. Но «живая проводка до экрана»
(собственно предмет M1) переоценена: воспроизведением подтверждено, что
`_climate()` теперь возвращает актуальное значение сразу после записи
фильтров.
- **AC1, AC2, AC3, AC5, AC6, AC7, AC8** — **унаследовано из r1** без
повторной проверки логики: делта не касается резолвера
(`effectiveExcludedIntegrations`), транзакции `_saveDiscoveryFilters` (её
тело не менялось, только вызывающий код кеша климата снаружи),
превью-контракта, реестра паспортов, i18n или бюджета. Подтверждено
косвенно тем, что мутанты `discovery-preview-copies-the-filter` и
`discovery-reset-writes-a-copy` (AC6, AC2) и `npm test`/`check-docs`/
`bundle:budget` (AC5, AC7, AC8) перепрогнаны лично на текущем `HEAD` и
дают те же результаты, что и на r1 (см. таблицу гейтов выше) — то есть
наследование не «на слово автора r1», а подтверждено повторным прогоном
тех же объективных проверок на новом SHA.
Документ: `docs/reviews/CODE-REVIEW-44-r1.md`, SHA `34778d81e45ec172af9b76ff71c761565fa0a885`.
**Правка** укладывается в эту же задачу: добавить `_excluded` (или
`this._settings.exclude_integrations`) в ключ `_climateCache`, либо явно
сбрасывать `this._climateCache = null` в `_saveDiscoveryFilters` рядом с
уже сбрасываемыми `_regSignature`/`_deviceInboxMemo`.
## Унаследовано из r1 (без повторной проверки логики, кроме гейтов выше)
### M2 (Medium, в скоупе) — причина исключения может показать пользователю необработанный `{integration}`
Из `docs/reviews/CODE-REVIEW-44-r1.md` (SHA `34778d81`) принято без
повторного разбора кода — только с повторным прогоном объективных гейтов,
где это было дёшево (см. таблицу):
**Где:** `src/houseplan-editor-runtime.ts:12016-12019` (текст причины в
строке инбокса) в связке с `src/houseplan-editor-runtime.ts:7620-7649`
(`integrationByBinding`, `reasonByBinding`).
- скоуп трёх блоков ТЗ rev4 (резолвер, UI, текст причины) и соответствие
каждого коду;
- корректность `effectiveExcludedIntegrations()` как единственного
резолвера и перевод всех потребителей на него (кроме `roomClimateMap`,
чья живая проводка была предметом M1 и переоценена выше);
- механизм транзакции `_saveDiscoveryFilters` (одна запись, дефолты —
отсутствием ключа) и его подтверждение мутантом
`discovery-reset-writes-a-copy` (перепрогнан, тот же результат);
честность превью относительно боевых билдеров и мутант
`discovery-preview-copies-the-filter` (перепрогнан, тот же результат);
- перевод паспортов `group_lights`/`exclude_integrations` `decision-required`
→ `current`;
- i18n-паритет 4 языков для 9 новых ключей + 1 изменённого;
- документация в том же коммите (`8d431d6d`): CHANGELOG×2, USER-GUIDE×2,
ARCHITECTURE.md, терминология вкладки «Доступны»/«Устройства» совпадает
с USER-GUIDE.ru.md;
- Low из r1 (AC7 без отдельного регресс-теста на фикстуре, компенсировано
чтением + зелёным корпусом) — статус не изменился, дельта его не
затрагивает.
- отбор смоков по дельте r1 (какие прогнаны, какие пропущены и почему) —
не переоценивался: дельта r2 не добавляет новых потребителей резолвера.
Причина `excluded_integration` присваивается устройству, если ЛИБО домен его
`identifiers[0][0]`, ЛИБО платформа любой его сущности входит в
`_excluded` (строка 7640: `[identifierDomain, ...platforms].some(...)`). Но
`integrationByBinding['device:'+id]` заполняется ТОЛЬКО из
`devicePlatforms` — карты, построенной исключительно из `entity.platform`
(строки 7620-7633); путь через `identifierDomain` в неё не попадает. Для
устройства без единой зарегистрированной сущности с непустым `platform`
(строка реестра устройств без entity — редкий, но легальный кейс HA:
`bindingCandidates`, `src/device-inbox.ts:74`, вообще не требует наличия
сущностей у устройства-кандидата) `row.integration === ''`.
## Что проверено и корректно (эта сессия)
Рендер (houseplan-editor-runtime.ts:12016):
```
${row.reason === 'excluded_integration' && row.integration
? this.host._t('device_inbox.reason_excluded_integration', { integration: row.integration })
: this.host._t(`device_inbox.reason_${row.reason}`)}
```
При пустом `row.integration` управление уходит в ветку БЕЗ параметра
подстановки — а текст ключа сам ревизией 4 получил обязательный плейсхолдер
`{integration}` во всех 4 языках. `subst()` (`src/logic.ts:1232-1237`) при
отсутствии `vars` возвращает строку как есть: `if (!vars) return s;`.
**Воспроизведение** (демо-стенд, реальный DOM, не только внутренний API):
устройство `identifiers: [['excludeme2', 'd_noplatform']]` без единой
сущности, `exclude_integrations: ['excludeme2']` → после
`_maybeRebuildDevices()` кандидат материализуется скрытым маркером
(существующий механизм seed, не менялся этой задачей), рендер вкладки
«Скрытые» показывает буквально:
`Integration "{integration}" excluded by discovery filters`
— то есть регресс относительно ДОСЕЛЕШНЕГО безличного, но всегда корректного
текста «Интеграция исключена фильтрами устройств».
**Сценарий отказа:** любое устройство, зарегистрированное в HA только как
строка `hass.devices` без единой сущности с полем `platform` (например,
объединяющий hub/bridge с полностью отключёнными сущностями), при попадании
под пользовательское исключение показывает в каталоге сырой шаблон вместо
текста на языке интерфейса — хуже, чем поведение до задачи.
**Правка** в скоупе: либо запасное значение при пустом `row.integration`
(строковый идентификатор из `identifierDomain`, который уже вычислен на
месте присвоения причины и мог бы попасть в `integrationByBinding` тем же
путём), либо безусловный вызов с `{ integration: row.integration ||
<generic fallback> }` и отдельный запасной вариант текста.
### Low — не блокирует, к сведению
- AC7 («отсутствие ключей → discovery-выдача байт-в-байт») не имеет
отдельного нового регресс-теста на фикстуре, как заявлено в плане
автотестов ТЗ («регресс-юнит на фикстуре»); фактическое доказательство —
чтение кода (`effectiveExcludedIntegrations(undefined)` возвращает
буквально тот же объект `EXCLUDED_DOMAINS`, что и раньше использовался
везде напрямую — проверено чтением, не отдельным тестом) плюс то, что весь
существующий корпус тестов над `buildDevices`/discovery остался зелёным
без изменений. Компенсирующее доказательство есть, дырки в контракте нет
— не поднимаю до Medium.
## Проверка AC (по коду и тестам, с указанием способа)
- **AC1** (тумблер группировки, обратная запись = удаление ключа) — доказано
автотестом `demo/smoke_discovery_filters.mjs` (прогнан лично, OK):
`toggleDefaultOn`, `writeCarriesBoth.group_lights === false`,
`defaultsRemoveKeys.group_lights === null`.
- **AC2** (исключение → превью → полный список в settings; сброс → без
ключа) — тот же смок: `previewHides`, `writeCarriesBoth.exclude_integrations`,
`defaultsRemoveKeys.exclude_integrations === null`.
- **AC3** (явный маркер переживает запись фильтров) — смок:
`markersUntouched`. Сам механизм неприкосновенности explicit-маркера
(`removedPlanBindings`, seed-логика) этой задачей не менялся — проверено
чтением диффа `devices.ts` (только тип `excluded` и добавление резолвера,
ни одна ветка удаления/пометки маркеров не тронута), поэтому существующее
покрытие тромбстоунов остаётся в силе без повторной проверки.
- **AC4** (причина называет интеграцию) — смок: `reasonNamesIntegration`
(текст содержит имя интеграции для обычного кейса с непустым `platform`).
Проверено чтением + воспроизведением, что для кейса с пустым
`row.integration` то же самое место ломается иначе — см. M2.
- **AC4b** (roomClimateMap следует настройке; opt-in сильнее; default
байт-в-байт) — доказано юнитом `test/devices.test.mjs` (прогнан, OK). Но
живая проводка до экрана не эквивалентна пройденному юниту — см. M1.
- **AC5** (резолвер: unset/list/[]/тумблер) — доказано юнитом, прогнан, OK.
- **AC6** (превью не копирует логику фильтра) — доказано юнитом
(`test/devices.test.mjs`, статический анализ исходника) + мутационным
гейтом `discovery-preview-copies-the-filter`, прогнанным лично — красный
на подмене вызова, как обязан.
- **AC7** (регресс дефолта) — проверено чтением (см. Low выше) и тем, что
`npm test` (1611/0) не потребовал ни одной правки существующих тестов над
`buildDevices`/`_excluded`.
- **AC8** (полный гейт; i18n 4/4; бюджет) — typecheck/test/build/check-docs
зелёные лично; i18n-паритет en/ru/de/fr для всех 9 новых ключей и
изменённого `reason_excluded_integration` сверен построчно (см. диффы
i18n/*.json); бюджет `279 517 / 300 000 Б` подтверждён `npm run
bundle:budget` лично, совпадает с заявленным.
## Что проверено и корректно
- `effectiveExcludedIntegrations()` — единственный резолвер, все три
потребителя (`houseplan-card.ts:_excluded`, `space-render.ts` ×2,
`devices.ts:roomClimateMap` через новый параметр) переведены на него;
идентичность объекта для default-пути сохранена (нет новых копий, AC5).
- Транзакция настроек: `_saveDiscoveryFilters` — одна запись, дефолты
хранятся отсутствием ключа (`delete settings.group_lights` /
`exclude_integrations`), подтверждено мутантом
`discovery-reset-writes-a-copy` и смоком.
- Превью честно диффит боевые `seedHiddenBindings`/`buildDevices` —
зафиксировано мутантом `discovery-preview-copies-the-filter`; «одно
число — один источник» выполняется (превью и итоговый список из одного
и того же билдера).
- Registry-паспорта `group_lights`/`exclude_integrations` переведены
`decision-required` → `current` с `enforcedBy` на конкретные тесты/смок.
- i18n: 9 новых ключей + 1 изменённый, 4/4 языков, без расхождений по
набору ключей.
- CHANGELOG (en+ru), USER-GUIDE (en+ru), ARCHITECTURE.md — обновлены в том
же коммите, что и код (`8d431d6d`, `User-Visible: yes`); терминология
(«Доступны», «Устройства») совпадает с канонической в USER-GUIDE.ru.md.
- Бандл: три копии дерева синхронны (`git status` пуст после `npm run
build`); бюджет initial View в пределах лимита; UI размещён в
editor-runtime (lazy chunk), холодный View не задет.
- Трейлеры коммита `8d431d6d`: `Issue: #44`, `User-Visible: yes` — оба
changelog присутствуют в том же коммите.
- Ключ `_climateCache` теперь включает `ex` — ссылку на хранимый массив
`exclude_integrations`; проверено, что `_settings` — геттер
(`this._serverCfg?.settings || {}`), а `_saveDiscoveryFilters` всегда
создаёт новый объект `settings` через spread при сохранении, поэтому
ссылка гарантированно меняется вместе со значением (не идентичность
«на случай совпадения», а прямое следствие того, как пишется конфиг).
- `roomClimateMap` — единственное место, где строится карта климата;
второй вызов (`space-render.ts:300`, статический рендер) не кеширует
вообще, пересчитывает каждый раз и уже передавал `excluded` до этой
правки — дублирующего источника числа температуры/влажности нет
(«одно число — один источник» выполняется и после фикса).
- Фолбэк `integrationByBinding` вставлен до вычисления `excluded`, порядок
не важен для самого исключения, но обязателен для корректности имени;
проверено чтением полного контекста (`houseplan-editor-runtime.ts:7620-7655`).
- Скриншот `docs/images/06-device-editor.png` открыт визуально (не только
по отпечатку): диалог «Device on the plan», без следов сырого
`{integration}` или иной поломки рендера; это другой диалог, чем
«Фильтры обнаружения»/причины на «Доступны», поэтому изменение его
`imageSha256` между `34778d81` и `e4e1e370` не связано с M1/M2 напрямую
(визуально контент корректен, дефекта не обнаружено) — частично закрывает
пункт «не проверял» из r1, полного пиксельного сравнения со старой
версией по-прежнему не делал.
- Трейлеры `e4e1e370`: `Issue: #44`, `User-Visible: no` — корректно
(правка внутренняя, видимого поведения/контракта не меняет,
changelog не требуется).
## Чего не проверял и почему
- **Полный набор `demo/smoke_*.mjs`** — не запускался целиком; дельта не
задевает геометрию, стены, толщину, canvas — вне периметра задачи.
Прогнан выбор по инструменту + два смока с прямым совпадением, решение по
каждой оставшейся строке записано выше.
- **`npm run golden:verify`** — дифф не меняет геометрию/рендер плана,
только диалог устройств (editor-only UI) и текстовые/фильтрующие пути;
видимый на плане результат (иконки/заливки) уже покрыт `smoke_climate_*`
логикой не был признан релевантным (см. выбор смоков) и юнитами AC4b.
- **`npm run invariants`** — дифф не трогает рёбра комнат, `layout`,
`marker.space`, `open_spans`, записи толщины — неприменимо.
- **`python -m pytest tests_backend`** — `custom_components/**/*.py` не
затронут диффом.
- **performance_smoke** — не назван в AC, дифф не касается путей,
чувствительных к перфу (превью явно кэшируется и считается по явному
действию, не на каждый hass-тик — подтверждено чтением
`_discoveryFilterPreview`, мемоизация по `[draft, cfgEpoch, regSignature]`).
- **Ручной прогон полного HA-бэкенд-харнеса** — не требуется, бэкенд не
затронут.
- **Скриншот `docs/images/06-device-editor.png`** — визуально не сверял
пиксель-в-пиксель; проверен только отпечаток источника через
`check-docs.mjs` (структурная свежесть, не визуальное содержимое).
- Полный набор `demo/smoke_*.mjs`, `golden`, `performance_smoke`,
`npm run invariants`, `pytest tests_backend` — как и на r1: дельта не
трогает геометрию, canvas, стены, толщину, `custom_components/**/*.py`;
предрелизный периметр, не гейт этого ревью.
- Пиксель-в-пиксель сравнение `06-device-editor.png` со старой версией —
открыл текущую версию и убедился в отсутствии дефекта, но не сверял
построчно со снимком до `e4e1e370`.
- Регресс-тест на M1/M2 не писал (не моя роль — ревьюер не правит код);
зафиксировал их отсутствие как Low L1 с решением не блокировать.
## Вердикт
Жёлтый. High: 0, Medium: 2 (M1, M2) — оба в скоупе задачи, чинятся в этом
же issue без нового цикла бюджета сверх текущего. Обе находки подтверждены
воспроизведением на актуальном бандле (SHA `34778d81`), не на основании
кода "по внешнему виду".
**Зелёный.** High: 0, Medium: 0. Обе находки r1 (M1, M2) закрыты по
существу и подтверждены воспроизведением на актуальном SHA, не на
заявлении автора. Один Low (L1, отсутствие регресс-теста) снят решением
ревьюера с записью, не блокирует. Открытых продуктовых вопросов нет.
Фактический бюджет циклов код-ревью после этого раунда: **1/4**
(этот раунд зелёный и цикл не образует — PROCESS.md §4, #227; потрачен
только r1). Заход, использованный для имени документа: **r2**.