Files
houseplan-card/docs/reviews/CODE-REVIEW-341-r1.md
T
2026-08-28 11:52:12 +00:00

147 lines
12 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 — issue #341 · заход r1
## Скоуп
Issue #341 (трек `trivial`, метки `bug`/`P3`/`polish`): аудит C5 нашёл, что
`src/labs.ts` защищает подписку на `hashchange`/`popstate` только
модульным флагом `listening`. Home Assistant может перезагрузить ресурс
карточки без перезагрузки страницы (обновление через HACS, dev-режим,
повторная регистрация ресурса) — новая инстанция модуля не видит старый
флаг и добавляет вторую пару обработчиков на тот же `window`, что даёт
повторную обработку одного события.
AC1–AC3 из тела issue:
- AC1/AC2 — после двух инициализаций Labs в одном `window` одно событие
`hashchange`/`popstate` вызывает обработчик только актуальной инициализации.
- AC3 — обычная одиночная инициализация и разрешение флагов не меняются.
Диапазон: `git log --oneline origin/dev..HEAD` / `git diff origin/dev...HEAD`.
Коммиты:
- `5f6bddea` fix: deduplicate Labs location listeners — `src/labs.ts`,
`test/labs.test.mjs`.
- `2b6db4c8` docs: refresh screenshot fingerprint — `dist/**`,
`custom_components/houseplan/frontend/**`, `docs/images/**`,
`docs/images/screenshots.json` (следствие правки `src/**`, не отдельная
задача).
Продуктовая рамка (SCOPE.md): фикс внутренний, к Core user jobs не
привязан напрямую — это защита контракта Labs («One module-level browser
listener; card instances only subscribe to its snapshot»), которым
управляется presentation-only флаг `iso` (#89, J1-презентация). Правка не
меняет UX, URL/storage-грамматику Labs, жизненный цикл карточки — всё
согласно разделу «Не входит» issue. Изменение точечное: только
`src/labs.ts` (class A) + `test/labs.test.mjs` (class B) + сгенерированные
class D/C артефакты. Постороннего кода в диффе нет.
## Как проверялось
Дешёвые гейты на SHA `2b6db4c8` подтверждены Validate
(https://github.com/Matysh/houseplan-card/actions/runs/33168458678, success) —
`npx tsc --noEmit`, `npm test`, `npm run build` со сверкой бандла не
перегонял.
Сам прогнал точечно (диапазон правки узкий, стоимость минимальна):
- `npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs &&
node --test test/labs.test.mjs` → **green**, 6/6, включая новый тест
«a reloaded Labs module replaces both browser location listeners».
- **Тест умеет падать**: временно убрал вызов
`window.__hpLabsListenerCleanup?.()` из собранного `test-build/labs.js`
(эмуляция кода до фикса) и перезапустил — новый тест краснеет
(`2 !== 1`, ожидание `firstPublishes === 1`, фактически `2`, т.к.
обработчик снятой инстанции продолжает публиковать на `hashchange`).
Откатил файл обратно, `git status` подтвердил чистое дерево.
- `node scripts/check-docs.mjs` → **green** (7 файлов, 10 внешних ссылок) —
обязателен, т.к. диф трогает `src/**`.
- Байт-в-байт сверка `dist/**` ↔ `custom_components/houseplan/frontend/**`
(`houseplan-card.js`, `houseplan-assets.json`, все файлы
`houseplan-assets/*`) → идентичны, `bundle:sync` не разошёлся.
- `node scripts/smoke-select.mjs --base origin/dev --head HEAD` →
**НЕОПРЕДЕЛЁННОСТЬ**: 1 файл `src/**`, ни один из 196 смоков не связан
доказуемо; символ без покрытия — `onLocationChange`. Решение: не
прогонять браузерные смоки — `onLocationChange` не рендерит и не меняет
DOM/маршрутизацию, это чистая функция диспетчеризации на
`publishBrowserLabs`, а контракт «ровно одна активная пара обработчиков»
полностью воспроизведён и провёрен модульным тестом с двумя реальными
ES-module инстанциями (через `import(...'?listener-instance=...')`),
что ближе к реальному сценарию (повторная загрузка ресурса), чем любой
браузерный смок мог бы дать.
- Чтение кода (`src/labs.ts:147–212`): прослежен путь `ensureBrowserLabs` →
`window.__hpLabsListenerCleanup` для 1, 2 и 3 последовательных
инстанций — каждая новая инстанция снимает ровно предыдущую пару
обработчиков (через замыкание `cleanup`, ссылающееся на её же
`onLocationChange`) и кладёт на `window` свой `cleanup`; guard
`if (window.__hpLabsListenerCleanup === cleanup)` предотвращает
случайное затирание чужого cleanup при непоследовательном вызове.
Повторный вызов `ensureBrowserLabs` в РАМКАХ ОДНОЙ инстанции (обычный
случай — несколько карточек на одной странице) не трогает `if (!listening)`
вовсе — поведение AC3 не изменено кодом, а не только заявлением.
Не прогонял: `npm run golden:verify` (диф не меняет рендер/геометрию/стили —
`onLocationChange` не рисует ничего), `npm run invariants` (геометрия,
`layout`, толщина стен, `open_spans` не затронуты), `python -m pytest
tests_backend` (`custom_components/**/*.py` не менялся),
performance-профили (не названы в AC, путь не в горячей точке рендера).
`npm run bundle:budget` не перегонял — автор привёл число
(256046 B при бюджете 282000 B) и разница от `iso`-строки правки
незначительна; независимо сверил байт-идентичность копий бандла, что
покрывает риск рассинхронизации сильнее, чем повторный бюджет-прогон.
## Находки
Нет находок High или Medium. Мелких Low-находок, требующих правки или
явного отказа, не нашёл — код и тест соответствуют предложенному в issue
решению («хранить состояние подписки на `window` под приватным глобальным
ключом») буквально.
## Что проверено и корректно
- **AC1/AC2 доказаны автотестом**, тест умеет падать (см. выше) — это не
«verified» на честном слове, а воспроизведённый красный/зелёный цикл.
- **AC3** — код, отвечающий за одиночную инициализацию, не тронут
(`if (!listening)` guard и его тело для случая true остаются пустой
веткой); резолвер флагов (`resolveLabs`, `liveLabsFlags` и т.д.)
вообще не в диффе — старые тесты резолвера все зелёные без изменений.
- Три и более последовательных инстанций разобраны построчно — не только
«два», как в тесте, — инвариант «ровно одна активная пара» держится по
индукции для N инстанций.
- `window.__hpLabsListenerCleanup` — новое глобальное свойство описано в
`declare global`, именование согласовано с существующим `__hpLabs`,
двойное подчёркивание держит приватность соглашения.
- Labs-правила AGENTS.md соблюдены: изменение не вводит новый флаг, не
трогает грамматику URL/storage, не гейтит данные/HA-действия —
чисто presentation-инфраструктура регистрации слушателей.
- Трейлеры обоих коммитов корректны: `Issue: #341`, `User-Visible: no` —
согласился с оценкой автора: фикс невидим при обычном использовании
(устраняет задвоение реакций на скрытый Labs-флаг `iso`, а не меняет
какое-либо наблюдаемое поведение), changelog обоснованно не трогался.
- Байты бандла (`dist` ↔ integration copy) и docs-фингерпринт
(`check-docs.mjs`) сошлись — вторая правка `2b6db4c8` не оставила
`dev` с красным `docs`-джобом (прецедент #230/#234 не повторился).
- Диф нигде не выходит за нужные классы файлов: A (`src/labs.ts`), B
(`test/labs.test.mjs`), C/D (сгенерированные скриншоты/бандл) — без
постороннего кода.
- Единственное новое число, видимое в тесте (счётчики публикаций), не
дублирует какую-либо пользовательскую величину — сравнение «одно число,
один источник» здесь неприменимо: change не вводит новых значений на
экране.
## Чего не проверял
- Полный `npm test`/`npm run build`/`npx tsc --noEmit` по всему проекту —
положился на зелёный Validate на этом же SHA (см. «Как проверялось»).
- Браузерные смоки — инструмент дал НЕОПРЕДЕЛЁННОСТЬ, обосновал отказ
выше; ручного захода в demo-стенд не делал.
- `golden`, backend pytest, performance — вне зоны дифа, см. обоснование
выше.
- Реальный сценарий HACS-перезагрузки ресурса в живом Home Assistant —
вне возможностей цикла ревью (нет ручного тестирования); заменено
точной симуляцией через две независимые ES-module инстанции, что и
является механизмом бага (новая инстанция модуля = новый `listening`).
## Вердикт
Зелёный. Все три AC выполнены и доказаны (автотест + чтение кода), фикс
не выходит за скоуп issue, гейты, относящиеся к дифу, зелёные, трейлеры и
byte-sync корректны. Блокирующих находок нет.