18 KiB
CODE-REVIEW-301-r1
Issue: #301 — «Add search/filter to entity selector for doors, windows and other openings»
Этап: code (S7-code-review) · заход r1 · блокирующих циклов 0/2
Проверено на: ветка issue/301-opening-entity-search, HEAD 56dbb488
(fix: keep opening picker semantics accurate 13219307, feat: search opening entity selectors 8b4730df
поверх dev@19332a91, тот же SHA, на котором ТЗ получило зелёный вердикт).
Это первый засчитанный код-ревью раунд (заход r1, блокирующих циклов 0/2) — разбор полный, разделы
«Закрытие раунда r» и «Унаследовано из r» не применяются: предыдущие циклы принадлежат этапу
spec, а не code, у стадий разный бюджет (AGENTS.md, «Cycles are counted per stage»).
Скоуп
Диалог свойств проёма (дверь/окно/ворота/открытый проём): нативные <select> для контакта и замка
заменены на существующий паттерн dropbtn/droppanel/candlist с текстовым поиском по
friendly_name/entity_id. Кандидаты и их порядок (дверные device_class первыми у контактов) не меняются,
формат opening.contact/opening.lock не меняется. Закрывает J4/J6 из docs/SCOPE.md (поддержание точного
плана в больших инсталляциях) — без конфликта с продуктовой рамкой и с лок-инвариантом (замок только
привязывается, путь актуации не создаётся).
Как проверялось
Прочитаны: docs/SCOPE.md, AGENTS.md, PROCESS.md (§2.7, §2.9/2.10, §4, §7, §8, §12), тело issue #301 и все
десять комментариев (пять ТЗ-итераций и обмен из-за сбоя автоматического ревью на споте — все находки
предыдущего этапа закрыты авторм, зелёный вердикт spec получен на dev@fd4fc801), docs/USER-GUIDE.ru.md
(раздел про проёмы, до и после правки).
Код: git log --oneline origin/dev..HEAD (3 коммита) и git diff origin/dev...HEAD построчно —
src/houseplan-card.ts (тип _openingDialog, _toggleOpeningEntityPicker/_filterOpeningEntities/
_selectOpeningEntity, замена opt() на picker(), ветки contact/lock/passage), src/logic.ts
(filterOpeningEntityCandidates), src/styles.ts, src/i18n/{en,ru}.json, scripts/mutation-gate.mjs,
test/opening-entity-search.test.mjs, demo/smoke_opening_entity_search.mjs, оба CHANGELOG,
docs/USER-GUIDE.ru.md, docs/TESTING.md. Отдельно прочитаны и сверены с диффом _contactCandidates
(houseplan-card.ts:12875, дверные классы первыми, без пересортировки после диффа), _lockCandidates
(:12892), _saveOpening (:12755) — подтверждено чтением, что запись o.contact/o.lock не изменилась
(o.contact = d.contact || null; o.lock = ... d.lock || null), _editOpening/место создания проёма (:12550,
:12572) — новые поля contactOpen/contactFilter/lockOpen/lockFilter всегда стартуют undefined, старое
состояние между разными открытиями диалога не протекает.
Гейты — что прогнано и почему
Всегда (дёшево, прогнаны на HEAD):
npx tsc --noEmit— чисто.npm test— 1290 pass / 0 fail / 1 skip (без изменений в счёте, ожидаемо).npm run build+ сверка трёх копий бандла (dist/houseplan-card.js,custom_components/houseplan/frontend/houseplan-card.js,demo/srv/assets/houseplan-card.jsчерезnpm run bundle:sync) — байт-в-байт идентичны,git statusпосле пересборки чист.node scripts/check-docs.mjs— обязателен, диф трогаетsrc/**: «Documentation checks passed (7 files, 10 external links)».node scripts/mutation-gate.mjs --check— все 5 новых мутантов задачи (opening-search-filter-dead,opening-search-name-only,opening-search-order-resorted,opening-search-hides-none,opening-search-select-not-wired) —ok, наряду с остальными существующими мутантами репозитория. Это же служит доказательством «тест умеет падать» дляfilterOpeningEntityCandidatesи для смока — guard-команды мутантов запускают именно unit-тесты/смок задачи и ловят внесённый дефект.
Инварианты модели (npm run invariants) — не прогонялись, сознательно: диф не трогает геометрию,
рёбра комнат, layout, marker.space, open_spans или записи толщины стен — только представление выбора
сущности в диалоге и чистую функцию фильтрации массива {value,label}. Правило #254 не применяется.
Браузерные смоки — выбор по node scripts/smoke-select.mjs --base origin/dev --head HEAD:
- Новый
demo/smoke_opening_entity_search.mjs(назван в плане тестов ТЗ) — прогнан, все 16 проверокtrue: фильтр по имени и поentity_id, сохранение порядка (дверные первыми), пункт «нет» первым и всегда доступен, пустой результат, выбор/сохранение контакта и замка, форматopening.contact/opening.lockне изменился,passageпо-прежнему без пикеров привязки. - «Прямое совпадение» (называют изменённые символы
_contactCandidates/_lockCandidates):demo/smoke_opening_binding.mjs,demo/smoke_registryless_opening.mjs— прогнаны, обаOKбез изменений в наборе проверок. - «Зарегистрированная связь» (общий символ
_openingDialog, 11 смоков) — инструмент явно предупреждает, что это не обязанность прогонять. Прочитаны все 11 названий; выбраны и прогнаны два с содержательным риском для этого диффа:demo/smoke_inert_openings.mjs(лок-инвариант/View-режим — диалог правится, стоило перепроверить, что интерактивность вне Plan-режима не задета) иdemo/smoke_partition_openings.mjs(использует тот же_saveOpening/_editOpeningпуть для проёмов на перегородках) — обаOK. Остальные девять (smoke_dialog_footer_width,smoke_grid_scale_invariance,smoke_grid_snap,smoke_isometric_live_touch,smoke_open_passage,smoke_opening_inner_distances,smoke_opening_measure,smoke_opening_preview,smoke_opening_entity_searchуже отдельно) не прогнаны: связь только через тип поля черновика диалога (_openingDialog), новые ключи в нём опциональны и не читаются нигде за пределами правленных веток рендера/_saveOpening/_editOpening— геометрия, измерения, изометрия и предпросмотр этого черновика не касаются.
npm run golden:verify — не прогонялся: ни один golden-сценарий (demo/golden/*.mjs) не открывает
_openingDialog (grep по _editOpening/_openingDialog в demo/golden/ пуст) — они снимают только
холст (символы проёмов на плане), который этот диф не меняет. Диалог свойств — не часть golden-снимков.
python -m pytest tests_backend — не прогонялся, диф не трогает custom_components/**/*.py (только
скомпилированный фронтенд-бандл внутри интеграции, класс D).
Perf-профили — не названы в AC, не запускались; влияние на производительность явно заявлено в ТЗ (O(n)
локальный фильтр по уже сформированному массиву, кап 200, без debounce) и правдоподобно при чтении кода —
filterOpeningEntityCandidates не делает ничего дороже Array.filter+slice.
AC — разбор
| AC | Доказательство по ТЗ | Проверено | Итог |
|---|---|---|---|
| AC1 контакт ищется по имени | unit+smoke | test/opening-entity-search.test.mjs тест 1, смок contactOrderPreserved/searchByEntityId |
Выполнен |
AC2 находит и по entity_id |
unit+smoke | unit тест 2 (запрос по id находит запись, чьё имя не совпадает), смок searchByEntityId |
Выполнен |
| AC3 регистр/пробелы не влияют | unit | unit тест 3 (' GARAGE door ') |
Выполнен |
| AC4 «нет» видно всегда, очищает | unit+smoke | смок emptyStateKeepsNoneFirst+noneClearsContact (пункт «нет» умышленно вне фильтруемого массива в filterOpeningEntityCandidates, поэтому отдельного unit нет — это описано в самом ТЗ) |
Выполнен |
| AC5 замок ведёт себя как контакт | smoke | смок lockSearchWorks/lockSelectedAndClosed, чтение: picker() общий для обоих |
Выполнен |
| AC6 порядок и приоритет сохраняются | unit+smoke | unit тест 1 (пустой запрос) + мутант opening-search-order-resorted, смок contactOrderPreserved (дверные впереди motion-сенсора после фильтра) |
Выполнен |
| AC7 формат сохранения не меняется | smoke | смок savedSameFields, чтение _saveOpening:12755 (o.contact = d.contact || null, без изменений) |
Выполнен |
AC8 пустой результат → marker.nothing_found, «нет» доступен |
smoke | смок emptyStateKeepsNoneFirst |
Выполнен |
Дополнительно закрыты пункты контракта, не входящие в нумерованные AC, но явно требуемые ТЗ:
- п.4.2 автофокус не форсируется — смок
openDoesNotForceFocus; подтверждено чтением:picker()не содержитautofocus/.focus()на открытие панели. - п.4.2 вторичная подпись
entity_idвидна в каждой строке — смокrowsExplainEntityId+closedButtonExplainsEntityId; чтениеhouseplan-card.ts:19519-19521(<span class="cs">${candidate.value}</span>). - п.4.4 лимит 200 после фильтрации, не 40 — unit тест 4 (240 кандидатов → 25 при явном лимите), чтение
filterOpeningEntityCandidates(filtered.slice(0, Math.max(0, limit)), дефолт200) и вызововpicker()без переопределения лимита. - «Не входит» (п.5 ТЗ): правил отбора кандидатов (
_contactCandidates/_lockCandidates) диф не касается — подтверждено чтением, изменений в этих методах нет за пределами их вызова изpicker().
Одно число — один источник
В диффе нет новой видимой пользователю величины (площадь, счётчик, процент, подпись рядом с превью) —
только строковые friendly_name/entity_id. Закрытая кнопка (selectedLabel) и строка кандидата берут имя
из одного и того же списка list (_contactCandidates()/_lockCandidates()), с одним и тем же фолбэком
(hass.states[...].attributes.friendly_name → _fullRegistryHass.entities[...].name → сам id) — два места
отображения одной сущности читают общий источник, второго независимого источника имени нет. Гейт
test/single-source-numbers.test.mjs прошёл в общем прогоне npm test, содержательных дублей чисел здесь
не создаётся — раздел применим формально, находок нет.
Что проверено и корректно
- Три копии бандла синхронны, TS компилируется, полный юнит-набор зелёный.
- Мутанты задачи (5 шт.) ловят ровно те регрессии, которые описаны в их
because, и используют реальные unit/smoke-команды проекта. _openingDialogновые поля (contactOpen/contactFilter/lockOpen/lockFilter) не протекают между открытиями диалога (оба места создания черновика —:12550/:12572— строят объект с нуля) и сбрасываются при смене типа проёма (contactOpen: false, lockOpen: falseна каждыйradio change).- Переключение контакт/замок-панелей взаимоисключающее (
_toggleOpeningEntityPickerзакрывает вторую панель), как и в трёх существующихdroppanel-инстансах — согласованное поведение, не новый паттерн. passageпо-прежнему не получает ни один из двух пикеров — подтверждено чтением условий рендера и смокомpassageStillHasNoBindingPickers.- Трейлеры:
feat-коммит несётUser-Visible: yesи правит оба changelog в этом же коммите;fix/docs- коммиты —User-Visible: no, обоснованно (внутренняя правка разметки без нового поведения; обновление снимков документации). docs/USER-GUIDE.ru.mdобновлён словами интерфейса («поле выбора», «— нет —», «entity_id»), без изобретённой терминологии; строка непротиворечива с таблицей настроек проёма выше по файлу.- Лок-инвариант (
docs/SCOPE.md) не затронут:_saveOpeningпишетlockкак и раньше, путей актуации не добавлено;smoke_inert_openings.mjsподтверждает интерактивность диалога ограничена Plan-режимом.
Чего не проверял и почему
npm run invariants— диф не геометрический (см. выше).npm run golden:verify— ни один golden-сценарий не открывает диалог проёма; холст не меняется.python -m pytest tests_backend— Python-код не тронут.- 9 из 11 «слабых» смоков по общему символу
_openingDialog— прочитаны по названию и решению не прогонять: геометрия/измерения/изометрия/предпросмотр не читают новые опциональные поля черновика. - Ручное тестирование в браузере (вне смока) не выполнялось — по правилам этапа его в цикле ревью нет; роль замены — смоки и код-чтение выше.
Находки
Нет High. Нет Medium. Нет Low. Реализация точно соответствует финальной редакции ТЗ, все восемь AC и дополнительные пункты контракта закрыты воспроизводимо (unit/smoke/чтение кода с указанием строк), гейты дёшевы и прогнаны либо осознанно пропущены с причиной.
Вердикт
Зелёный. Основание: полное соответствие ТЗ #301 (доказано по каждому AC), все обязательные гейты (tsc,
npm test, сборка+сверка трёх копий, check-docs, mutation-gate --check) чистые, прямые смоки и выбранные
по риску слабые смоки зелёные, трейлеры и changelog в порядке, лок-инвариант и продуктовая рамка не задеты.