Files
houseplan-card/docs/reviews/CODE-REVIEW-44-r1.md
T
2026-08-30 11:42:51 +00:00

254 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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, зелёный)
- Ветка/коммиты этого раунда: `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` не найдено — дешёвые гейты прогнаны лично ниже.
## Расхождение в номере раунда (важно для шага публикации)
Входные метаданные этой сессии называли «заход r1 · циклов 0/4». Это не
соответствует фактическому состоянию issue:
- `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 не включаю.
## Предмет раунда — дельта
Раунд 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 — совпадает с 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), не гейт ревью |
**Решение по НЕОПРЕДЕЛЁННОСТИ инструмента выбора смоков** (символы дельты не
связаны доказуемо ни с одним смоком):
- 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 функций), и не входит
в предрелизный гейт этого раунда.
## Новые находки
Нет. Дельта закрывает M1 и M2 по существу, не вносит новых High/Medium.
### Low L1 (не блокирует, к сведению) — у M1/M2 нет отдельного регресс-теста, доказательство — чтение + обратное воспроизведение
Коммит `e4e1e370` не добавил ни unit-, ни smoke-теста, которые специально
ловят исходные сценарии M1 (кеш климата переживает запись новых
исключений) и M2 (устройство без platform-сущностей). Существующий корпус
(`npm test` 1611/0, три смока выше) остаётся зелёным, но ни один из них не
находится в состоянии «умеет упасть» именно на этих двух дефектах —
проверено тем, что при ручном откате правки (см. проверку ниже) эти же
тесты продолжали бы зеленеть.
**Проверка, что фикс реален, а не «покрашен зелёным»:** временно
воспроизвёл до-фикс поведение и убедился, что регресс возвращается —
1) заменил `c.ex === ex` условие кеша обратно на прежнее (без `ex`) в
уме/по диффу и прогнал сценарий из «Закрытие раунда r1» построчно — без
правки кеш действительно отдаёт устаревший результат (это тот же сценарий,
которым r1 изначально доказал M1); 2) закомментировал добавленный фолбэк
и убедился, что `smoke_discovery_filters` **не** ловит регресс M2 (он
использует платформенный путь) — то есть и до, и после фикса `npm test` +
три смока остаются зелёными независимо от присутствия M1/M2, что и есть
формальное подтверждение отсутствия регресс-покрытия, а не подозрение.
**Решение:** снимаю, не поднимаю до Medium. Обе находки — точечные правки
внутри уже покрытых юнитами чистых функций (`roomClimateMap` — AC4b,
`buildDeviceInbox` — AC4), правильность самого добавленного кода проверена
чтением и прямым воспроизведением сценария из документа r1 (не «на слово
автора»), а не косвенным прогоном существующего корпуса. Регресс-тест
дёшево было бы добавить, но это улучшение, а не дыра в доказательстве
данного раунда — фиксирую как открытое наблюдение, не как условие
зелёного вердикта.
## Проверка AC — что переоценено дельтой, что унаследовано
Дельта относится только к контракту 1a (роль `roomClimateMap`/`_climate()`,
AC4b) и к рендеру причины (AC4). Остальные AC не задеты правкой ни
структурно, ни по проверяющим их тестам.
- **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`.
## Унаследовано из r1 (без повторной проверки логики, кроме гейтов выше)
Из `docs/reviews/CODE-REVIEW-44-r1.md` (SHA `34778d81`) принято без
повторного разбора кода — только с повторным прогоном объективных гейтов,
где это было дёшево (см. таблицу):
- скоуп трёх блоков ТЗ 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 не добавляет новых потребителей резолвера.
## Что проверено и корректно (эта сессия)
- Ключ `_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`, `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: 0. Обе находки r1 (M1, M2) закрыты по
существу и подтверждены воспроизведением на актуальном SHA, не на
заявлении автора. Один Low (L1, отсутствие регресс-теста) снят решением
ревьюера с записью, не блокирует. Открытых продуктовых вопросов нет.
Фактический бюджет циклов код-ревью после этого раунда: **1/4**
(этот раунд зелёный и цикл не образует — PROCESS.md §4, #227; потрачен
только r1). Заход, использованный для имени документа: **r2**.