Files
houseplan-card/docs/specs/044-filter-grouping-policy.md
T
2026-08-30 13:48:10 +03:00

16 KiB
Raw Blame History

ТЗ #44 — Явные фильтры и группировка устройств

  • Issue: https://github.com/Matysh/houseplan-card/issues/44
  • Приоритет: P2, tech-debt; полный трек (discovery/UI/settings/i18n/tests — решение аналитики 2026-08-15)
  • Ревизия: 4 (2026-08-30) — по SPEC-REVIEW-44-r2 (M1 + Low); policy v1 принята владельцем, конкретизация под инбокс v1.69
  • Связано: device inbox #29, field registry/манифест #33 (паспорта allow-extra обоих ключей уже выданы), персист-паттерн #377

Сценарий

Хозяин плана открывает «Устройства» и на вкладке «Доступны» видит новый раскрываемый блок «Фильтры обнаружения». Там два честных элемента: тумблер «Объединять светильники комнаты в группу» (включён по умолчанию — как продукт и вёл себя всегда) и список «Исключённые интеграции» с рекомендованным набором, поиском по реально присутствующим в HA интеграциям и кнопкой «Вернуть рекомендуемые». Изменение показывает счётчики «появится / скроется / сгруппируется» до сохранения; Сохранить пишет настройки один раз.

Что человек увидит до и после

До: группировка света и исключение интеграций происходят молча; причина excluded_integration в инбоксе уже существует (#29), но безлична («Интеграция исключена фильтрами»). После: обе настройки видны и управляемы; текст причины называет интеграцию по имени («Исключена интеграция {integration}»); ничего не меняется само — только по Сохранить.

Проблема

Оба ключа — «третий вариант», запрещённый issue: влияют на результат, не видны, без migration policy. Факты кода: group_lights читается как !== false (default TRUE, devices.ts:1033/1098); exclude_integrations при наличии ЦЕЛИКОМ подменяет продуктовый EXCLUDED_DOMAINS (13 доменов, rules.ts:12; потребители houseplan-card.ts:3924, space-render.ts:245, discovery devices.ts:1491).

Решение (policy v1, принята)

Оба ключа — поддерживаемые advanced-настройки. Storage-имена и семантика НЕ меняются (обратная совместимость): group_lights отсутствие = true; exclude_integrations отсутствие = продуктовый список, наличие = полная замена. UI прячет эту механику за честными представлениями.

Скоуп

1. Блок «Фильтры обнаружения» в инбоксе

  • Раскрываемая секция внизу вкладки «Доступны» диалога «Устройства» (editor-runtime; холодный View не задет — весь UI в runtime, класс #357).
  • Тумблер группировки: отражает settings.group_lights (unset→вкл). Выключение НЕ удаляет существующие маркеры (в т.ч. group-маркеры) — меняет только будущих кандидатов discovery.
  • Исключённые интеграции: чипы текущего действующего набора (ключ или продуктовый список); поиск-добавление по интеграциям, реально присутствующим в registry HA (platform-ы entity registry); удаление чипа; кнопка «Вернуть рекомендуемые» (= удалить ключ из settings). Если действующий набор равен продуктовому — ключ в settings отсутствует (паттерн «дефолт = отсутствие ключа», #377).
  • Превью до сохранения: счётчики «появится N / скроется M / группировка изменит K» — пересчёт кандидатов discovery на черновых значениях (чистая функция buildDevices уже принимает settings/excluded через ctx — используется она же, без дублирования логики). Список затронутых биндингов раскрывается (имена, без лишних id).
  • Транзакция: Изменения живут в черновике диалога; Сохранить пишет settings ОДИН раз штатным сериализованным путём (expected_rev #340); Отмена — no-op. Undo — штатный именованный снапшот конфига (паттерн существующих правок настроек).

2. Причина с именем интеграции (H1 r1: факт исправлен)

Причина excluded_integration УЖЕ существует и рендерится на вкладке «Доступны» (#29, houseplan-editor-runtime.ts:7637, device_inbox.reason_excluded_integration) — но текст обобщённый. Скоуп этой задачи: тексту добавляется плейсхолдер {integration} с фактическим platform-именем кандидата; вкладка НЕ меняется (перенос категории — отдельный продуктовый вопрос, здесь не открывается). Значение reason и его механика не трогаются.

3. Registry/доки

  • config-field-registry.mjs: статусы обоих ключей decision-required → current, ui: 'device inbox → Discovery filters', enforcedBy — новый смок; migration-строки — «none, supported setting».
  • USER-GUIDE(.ru): подраздел «Фильтры обнаружения» в теме инбокса.

Не-скоуп

  • Изменение семантики replacement→additive у exclude_integrations (совместимость; additive-режим — отдельное решение, если понадобится).
  • Пер-пространственные фильтры; фильтры по доменам сущностей.
  • Изменение самого продуктового списка EXCLUDED_DOMAINS.
  • Материализация/show_all (закрыто ранее, #33 registry).

Контракт поведения

  1. Конфиг без ключей → поведение байт-в-байт сегодняшнее (default-путь не меняется ни в одном потребителе). 1a. (H2 r1) roomClimateMap (devices.ts:1491) — единственное место в src/**, хардкодящее EXCLUDED_DOMAINS мимо настройки, — переводится на тот же резолвер действующего набора исключений, что и discovery: климат комнаты следует за настройкой пользователя, а не за старым жёстким списком (иначе UI создаёт новый «третий вариант»). Явный climate-opt-in (галочка «этот датчик меряет воздух комнаты») по-прежнему сильнее исключения — существующая ветка optClimate не меняется.
  2. Explicit-маркер (живой или tombstone) НИКОГДА не исчезает из-за смены фильтров — фильтры влияют только на автоматических кандидатов и seed.
  3. Оба значения читаются из одного источника (_settings) всеми потребителями — новых копий состояния нет.
  4. Вернуть рекомендуемые удаляет ключ (не пишет копию списка).
  5. Никакой записи до явного Сохранить; двойная вкладка — штатный conflict.

UX / i18n

Новые ключи (en/ru/de/fr, все четыре — паритет-гейт): заголовок секции, label+hint тумблера, label списка, placeholder поиска, кнопка reset, счётчики превью (3), причина device_inbox.reason_excluded_integration (с плейсхолдером {integration}). Порядка 9 ключей.

Модель данных и миграция

Нет изменений хранения: ключи те же, семантика та же. Бэкенд-схему не трогаем: ключи остаются allow-extra (паспорта #33). Причина: перенос в строгую схему — отдельное решение с рисками отклонения старых конфигов; issue требует видимости и policy, не пере-схемизации.

Критерии приёмки

  • AC1 (смок): тумблер группировки выключить+Сохранить → в конфиге group_lights: false, повторное открытие показывает выкл, discovery- кандидаты содержат отдельные лампы вместо группы; обратное включение → ключ УДАЛЯЕТСЯ из settings (default = отсутствие).
  • AC2 (смок): добавление интеграции в исключения → превью-счётчик «скроется» ненулевой; Сохранить → ключ в settings = полный действующий список; «Вернуть рекомендуемые» + Сохранить → ключа нет.
  • AC3 (смок): у явного маркера с binding исключённой интеграции ничего не меняется (живой остаётся на плане, tombstone не трогается).
  • AC4 (смок): кандидат исключённой интеграции виден на «Доступны» с причиной, называющей интеграцию по имени («Исключена интеграция demo_x»); вкладка и значение reason прежние (регресс-ветка).
  • AC4b (юнит, H2): roomClimateMap с настроенным exclude_integrations фильтрует по НЕМУ (интеграция вне списка даёт климат; продуктовая, вручную включённая в список — не даёт); без ключа — байт-в-байт сегодняшний результат; явный climate-opt-in побеждает исключение (регресс-ветка).
  • AC5 (юнит): резолвер действующего набора: unset→продуктовый список, []→пусто (валидная «ничего не исключать»), список→список; тумблер: unset→true.
  • AC6 (юнит): превью-диффер чист: те же ctx-входы, что у boевого buildDevices (контракт-проверка, что превью не дублирует логику фильтра).
  • AC7: отсутствие ключей → discovery-выдача байт-в-байт с сегодняшней (регресс-юнит на фикстуре).
  • AC8: полный гейт; i18n 4/4; бюджет: UI в editor-runtime (lazy), initial ≈ без изменений.

План автотестов

  • Юниты: резолверы (AC5), превью-контракт (AC6), регресс дефолта (AC7), климат-фильтр (AC4b) — test/devices.test.mjs + test/device-inbox.test.mjs.
  • Смок demo/smoke_discovery_filters.mjs (новый): AC1–AC4 на демо-стенде с моковым config/set (паттерн #377-смока).
  • Мутанты: м1 — превью-счётчики от копии логики (подмена вызова общего билдера константой) → красный AC6/смок; м2 — «Вернуть рекомендуемые» пишет копию списка вместо удаления ключа → красный AC2.

Риски

  • Пересчёт превью на большом registry — переиспользуется кэшируемый buildDevices; расчёт по явному действию (открытие секции/изменение), не на каждый тик.
  • Текст причины обогащается плейсхолдером на уже существующей вкладке «Доступны» — новых классов записей не появляется; смок проверяет отсутствие дублей.
  • Гонка вкладок — штатный conflict #340.
  • Перевод roomClimateMap на резолвер (H2) меняет климат ТОЛЬКО при заданном пользователем ключе — default-путь передаёт тот же продуктовый список (AC4b регресс-ветка + существующие климат-юниты #317 не слабеют).

Откат

git revert: UI и причина исчезают, ключи продолжают работать как до задачи (скрыто); записанные пользователями значения остаются валидными — семантика не менялась. Потери данных нет.

DoR-примечания: миграция/compatibility — нет (семантика и имена ключей неизменны); touch — стандартные элементы диалога (чипы/тумблер), новых жестов нет; производительность — превью по явному действию.

Release-артефакты

  • CHANGELOG + CHANGELOG.ru: user-visible запись (настройки фильтров стали видимыми и объяснимыми), ссылка #44.
  • USER-GUIDE(.ru): подраздел; ARCHITECTURE.md: абзац про policy «нет скрытых discovery-ключей» со ссылкой на registry.

Принятые предположения

  • Размещение — вкладка «Доступны» (фильтры определяют её содержимое); если ревью ТЗ предпочтёт отдельную вкладку/шестерёнку — правится без смены механики.
  • [] (пустой список) — валидное значение «ничего не исключать», отличное от отсутствия ключа; так уже работает код (list ? new Set(list) : EXCLUDED_DOMAINS).
  • Причина остаётся на «Доступны», где уже живёт (#29); ни переноса категории, ни новой вкладки.