Files
houseplan-card/docs/reviews/CODE-REVIEW-457-r1.md
T
2026-09-05 10:50:19 +03:00

346 lines
29 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.
# Код-ревью #457 — направление Zigbee-связей к координатору
- Issue: https://github.com/Matysh/houseplan-card/issues/457
- ТЗ: `docs/specs/457-zigbee-route-arrows.md` (принято зелёным в
`docs/reviews/SPEC-REVIEW-457-r1.md`, SHA `f3a1dd84`)
- Материал: `git diff origin/dev...HEAD`, `git log --oneline origin/dev..HEAD`
- SHA материала: `d44aa30f` (ветка `issue/457-zigbee-route-arrows`)
- Заход: r1 · блокирующих циклов израсходовано 0 из 4
## Скоуп диффа
6 коммитов на `origin/dev`: `f3a1dd84` (ТЗ), `c844473c` (feat, User-Visible:
yes, оба changelog в этом же коммите), `ee3af3b8` (perf, User-Visible: no),
`e574bc6b`/`d44aa30f` (обновление отпечатков скриншотов/сборки).
Продуктовые поверхности: `src/zigbee-topology.ts` (дерево аплинков BFS,
нормализация `relationship`, проекция parent target), новый
`src/zigbee-topology-geometry.ts` (screen-pixel геометрия наконечника),
`src/hp-zigbee-topology-overlay.ts` (стрелки, bubble, forced-colors, переход
viewBox с процентов на пиксели), `src/zigbee-topology-overlay-bridge.ts` +
`src/houseplan-card.ts` (проброс `spaces`), 4 словаря i18n. Доказательные
поверхности: `test/zigbee-topology.test.mjs`, `demo/smoke_zigbee_topology_hover.mjs`,
новые записи `scripts/mutation-gate.mjs`. Документация: оба `CHANGELOG`, оба
`USER-GUIDE`, `docs/STATUS.md`, отпечаток скриншотов. `dist/**` и
`custom_components/houseplan/frontend/**` синхронизированы через bundle:sync
(проверено, диффа при повторной сборке нет). Golden не задет (подтверждено
прогоном). Backend Python не тронут.
Соответствует заявленному в ТЗ §16 объёму; неожиданных поверхностей нет.
## Что и как проверял (гейты)
Зелёного Validate на SHA `d44aa30f` не было, прогнал сам:
| Гейт | Результат |
|---|---|
| `npx tsc --noEmit` | зелёный, без вывода |
| `npm test` | 1979 passed, 1 skipped, 0 failed (`test/zigbee-topology.test.mjs`: 10 подтестов зелёные, включая 5 новых для этой задачи) |
| `npm run build` + `git status --porcelain` | сборка чистая, `dist/**`/`custom_components/houseplan/frontend/**` уже синхронизированы коммитом — повторная сборка не меняет рабочее дерево |
| `npm run bundle:budget` | initial View 297 497 B gzip (потолок 298 000±2000, budget 300 000), headroom 2503 B — зелёный с существующим предупреждением о низком запасе (#367, долг не этой задачи) |
| `node scripts/check-docs.mjs` | пройден (7 файлов, 12 внешних ссылок) — обязателен, т.к. диф трогает `src/**` |
| `node scripts/no-new-any.mjs --base origin/dev --head HEAD` | новых `any` нет (251 добавленная строка, 5 файлов) |
| `node scripts/mutation-gate.mjs --check` | все патчи, включая 3 новых мутанта для #457 (`zigbee-route-parent-keeps-bfs-level`, `zigbee-route-local-arrow-not-inverted`, `zigbee-route-parent-not-counted-twice`), применяются к текущему коду |
| `npm run benchmark:zigbee-topology` | normalize 8.0 ms, map 19.0 ms, firstHover 0.66 ms, repeatedHover 6.2 ms — все далеко внутри потолков 80/160/180/120 ms |
| `npm run golden:verify` | 153/153 passed, без новых baseline — как и требует ТЗ §15.8 |
| `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | одна зарегистрированная связь — `demo/smoke_zigbee_topology_hover.mjs` (уже назван в AC4/6/7/9/10); 28 слабых связей по общему символу `_mode` — решил не гонять, они не про эту фичу |
| `node demo/smoke_zigbee_topology_hover.mjs` | зелёный, все 16 полей `checkAll` true, включая новые `localRouteArrow`, `remoteParentBubble`, `unplacedDeviceBubble`, `unplacedCoordinatorBubble` |
| Инварианты модели (`npm run invariants`) | не гонял — диф не трогает геометрию комнат/стен/`layout`/`marker.space`/`open_spans`, это чисто runtime-проекция поверх уже размещённых устройств |
| `python -m pytest tests_backend` | не гонял — `custom_components/**/*.py` не тронут |
Дисциплина «тест умеет падать»: для трёх новых unit-тестов дерева/hover
проверил вручную, что они ловят подмену BFS-guard / инверсию стрелки / повторный
учёт parent в remote count — через сам `mutation-gate --check`, который
подтверждает применимость патчей к текущему тексту файла (не запускал полный
дорогой прогон `mutation-gate.mjs` без `--check`, это предрелизный гейт, а не
гейт код-ревью).
## Разбор по AC
**AC1 (дерево всегда ведёт к координатору).** `buildZigbeeRouteTree` строит
BFS от единственного `coordinator`, `parents` заполняется только для узлов
`distance > 0`, кандидат в родители обязан иметь `distance - 1`
(`src/zigbee-topology.ts:352`) — цикл невозможен по построению. Адъяцентность
строится по отсортированным ключам узлов и рёбер
(`src/zigbee-topology.ts:296-311`), поэтому результат не зависит от порядка
входных массивов — подтверждено тестом с `reverse()` обоих массивов
(`test/zigbee-topology.test.mjs:172-175`) и не отличается от неперевёрнутого
дерева. Доказано автотестом, тест падает при регрессии BFS-guard (mutation
`zigbee-route-parent-keeps-bfs-level`). **AC1 закрыт.**
**AC2 (детерминированный выбор родителя).** Порядок tie-break в коде
(`src/zigbee-topology.ts:355-361`) — прямое `relationship==='parent'`, затем
прямой LQI (unknown трактуется как `-1`, т.е. хуже любого известного), затем
лексикографический ключ — совпадает с ТЗ §6.2 дословно. LQI берётся из
`left.observation`/`right.observation`, то есть наблюдения САМОГО дочернего
узла о соседе (`aToB` в контексте узла-адресата a), не встречного —
соответствует запрету ТЗ «обратное LQI не подменяет отсутствующее прямое».
Покрыто table-driven тестом на ties и противоречивый `relationship`
(`test/zigbee-topology.test.mjs:178-201`). **AC2 закрыт для канонических
строк** — см. находку №1 про нормализацию нестандартных строковых форм.
**AC3 (Z2M relationship).** Числовая таблица `Z2M_RELATIONSHIPS` — точное
соответствие ТЗ §6.3 (0 parent … 4 previous_child), покрыто реальной
анонимизированной fixture (`relationship: 2` → `sibling`,
`test/zigbee-topology.test.mjs:83-88`). Неизвестное число (`Z2M_RELATIONSHIPS[value]`
для value вне 0-4) даёт `undefined` — не роняет link/LQI, не становится
предпочтением. **AC3 закрыт для чисел** — но см. находку №1: строковая часть
той же функции (`relationshipOf`) не соответствует ТЗ.
**AC4 (направление локальных стрелок).** Логика в
`resolveMappedTopologyHover` (`src/zigbee-topology.ts:398-431`): `isParent`
→ `toward-neighbor` (стрелка от наведённого к соседу), обратное условие →
`toward-origin` (от соседа к наведённому), иначе — без стрелки. У координатора
`routes.parents.get(coordinatorKey)` всегда `undefined`, поэтому исходящей
стрелки нет ни при каком соседе — прочитано в коде и совпадает с ожиданием.
Проверено unit (`test/zigbee-topology.test.mjs:203-217`) и browser smoke
(`localRouteArrow`, geometry-assertions на `getBoundingClientRect`). Мутация
инверсии направления ловится тестом (`zigbee-route-local-arrow-not-inverted`).
**AC4 закрыт.**
**AC5 (соседские линии не регрессируют).** Существующий путь построения `lines`
не тронут кроме добавления `routeDirection`; цвет/пунктир/halo/цепочка
`existing.line.lqi` дозаполнения — та же логика, что и до задачи. Регрессионный
unit `exact device/entity mapping...` и `hidden and ambiguous placements...`
зелёные с добавленным полем `parentTargets: []`. **AC5 закрыт.**
**AC6 (remote parent → bubble с названием пространства).** `parentTargets`
формируется до основного цикла по линиям и явно исключает parent из `remote`
(`if (!isParent) remote.add(...)`, `src/zigbee-topology.ts:412`) — не
дублируется. Название берётся из `this.spaces` (`_targetText`,
`src/hp-zigbee-topology-overlay.ts`), пустой/нестроковый `title` не заменяется
raw id, fallback — `route_other_space`. Мутация повторного учёта parent в
remote count ловится тестом (`zigbee-route-parent-not-counted-twice`). Browser
smoke (`remoteParentBubble`) подтверждает и текст, и то, что старый агрегат
`zigbee-topology-remote` не рисуется одновременно с bubble. **AC6 закрыт.**
**AC7 (unplaced parent без утечки деталей).** Классификация
`unplaced-coordinator` vs `unplaced-device` по роли узла в полном графе
(`src/zigbee-topology.ts:407-409`), не по причине ненаходимости — все
технические случаи (`hidden`/`ambiguous`/`unmatched`/`provider_scan_failure`)
дают одинаковый `unplaced-device`, что проверено unit
(`test/zigbee-topology.test.mjs:220-238`) и smoke (`unplacedDeviceBubble`,
`unplacedCoordinatorBubble`, оба через скрытие устройства/несопоставленный
маркер). Bubble только для parent наведённого узла — неразмещённые дети не
получают ни bubble, ни подписи (не участвуют в `parentTargets`, только в
`omittedCount`, который нигде не выводится пользователю). DOM-privacy: raw
IEEE/provider id не попадают в `_targetText` ни в одной ветке. **AC7 закрыт.**
**AC8 (fail-closed при 0/N координаторах).** `buildZigbeeRouteTree` возвращает
пустые `parents`/`distances` при `coordinators.length !== 1`
(`src/zigbee-topology.ts:298-300`), покрыто unit на удалении координатора и на
добавлении второго (`test/zigbee-topology.test.mjs:210-216`). Обычные линии
продолжают работать, потому что `resolveMappedTopologyHover` не завязан на
наличие дерева для построения `lines`. **AC8 закрыт.**
**AC9 (screen-space геометрия).** Переход viewBox с `"0 0 100 100"
preserveAspectRatio="none"` на `"0 0 ${width} ${height}"` с тем же
`preserveAspectRatio="none"` устраняет растяжение по построению: и viewBox, и
перевод процентных координат маркеров в пиксели (`_position`) используют одно
и то же `width/height`, поэтому 1 единица SVG = 1 CSS-пиксель этого узла вне
зависимости от соотношения сторон контейнера — проверено чтением, это ровно
тот приём из технической ловушки #1 в issue. `_markerClearance` = радиус halo
(`0.61` = половина коэффициента `1.22` из существующего `.halo`) + отступ 3px —
согласуется с той же полупроцентной геометрией halo. Чистая геометрия
(`zigbeeArrowGeometry`) покрыта unit на горизонтали/вертикали/диагонали и
случае недостатка места (`usable < 4` → `null`). **Но** заявленное в ТЗ §14
доказательство AC9 — «pure pixel-geometry unit **+ browser smoke на двух
aspect ratios и трёх zoom**» — реализовано только наполовину: в
`demo/smoke_zigbee_topology_hover.mjs` нет ни одного изменения viewport,
resize или zoom (`grep -n "zoom\|resize\|viewport" demo/smoke_zigbee_topology_hover.mjs`
— пусто). Смок проверяет геометрию только при зафиксированном дефолтном
размере окна и zoom=1. Это разрыв между тем, что ТЗ объявило доказательством, и
тем, что реально доказано — см. находку №2. **AC9 закрыт для дефолтного
viewport, не закрыт для заявленного диапазона aspect ratio/zoom.**
**AC10 (темы, forced-colors, lifecycle).** `.route-arrow` и `.parent-bubble`
добавлены в существующий блок `forced-colors: active`
(`src/hp-zigbee-topology-overlay.ts`), используют системные `Highlight`/
`CanvasText`/`Canvas`, как требует ТЗ §8. Lifecycle (`pointerleave`, mouse→touch,
смена пространства/режима) не тронут отдельно от существующего кода — новые
элементы рендерятся из того же `render()`, что и раньше, и очищаются той же
логикой `_hovered=''`. Smoke `leaveClears`/`touchClears`/`editorHasNoOverlay`/
`nonAdminHasNoOverlay` зелёные. **AC10 закрыт.**
**AC11 (перф и lazy boundary).** `benchmark:zigbee-topology` прогнан, все 4
метрики далеко внутри потолков; `mapMs` уже включает построение дерева, так как
`buildZigbeeRouteTree` вызывается внутри `mapTopologies` — задача из плана
тестов ТЗ §15.5 («расширить в существующем `mapMs`») выполнена без отдельного
изменения benchmark-скрипта, потому что интеграция сделала это автоматически;
не дефект. `bundle:budget` зелёный. Topology overlay по-прежнему грузится
только через `void import('./hp-zigbee-topology-overlay')` при включённой
настройке — новый код (`zigbee-topology-geometry.ts`) импортируется из
ленивого модуля, а не из initial. **AC11 закрыт.**
**AC12 (документация и i18n).** Все 4 словаря получили одинаковые 3 ключа
(`route_device_not_on_plan`, `route_coordinator_not_on_plan`,
`route_other_space`); `check-docs.mjs` зелёный; оба `USER-GUIDE` описывают
семантику стрелки, approximation и honest absence именно теми словами, что
требует ТЗ §19; оба `CHANGELOG` содержат одну пользовательскую запись со
ссылкой на #457 в том же коммите (`c844473c`, `User-Visible: yes`), без утечки
внутренних терминов. **AC12 закрыт.**
## Находки
### Находка 1 (Medium, в скоупе) — нормализация `relationship` отступает от принятого ТЗ §6.3
`src/zigbee-topology.ts:114-118` (текущий HEAD):
```ts
function relationshipOf(value: unknown): string | undefined {
if (typeof value === 'number') return Z2M_RELATIONSHIPS[value];
return typeof value === 'string'
? value.trim().toLowerCase().slice(0, 40) || undefined : undefined;
}
```
ТЗ §6.3 (принято зелёным на SHA `f3a1dd84`): «Строковые значения нормализуются
без учёта регистра, пробелов, дефисов и подчёркиваний». Ровно эта нормализация
была в коде после коммита `c844473c` («feat: show Zigbee routes to
coordinator»):
```ts
function relationshipOf(value: unknown): string | undefined {
if (typeof value === 'number' && Number.isInteger(value)) return Z2M_RELATIONSHIPS[value];
if (typeof value !== 'string') return undefined;
const compact = value.trim().toLowerCase().replace(/[\s_-]+/g, '');
if (!compact) return undefined;
if (compact === 'previouschild') return 'previous_child';
if (compact === 'parent' || compact === 'child' || compact === 'sibling' || compact === 'none') {
return compact;
}
return value.trim().toLowerCase().slice(0, 40) || undefined;
}
```
Следующий коммит `ee3af3b8` («perf: keep Zigbee route overlay within bundle
guard») убрал именно эту нормализацию — без единого слова об этом в сообщении
коммита, без правки текста ТЗ и без нового теста, который бы объяснил или хотя
бы зафиксировал сужение поведения. Итог: строка вида `"Previous-Child"`,
`"previous child"` или `"PARENT "` с любым нестандартным разделителем больше
не совпадёт с ожидаемым каноническим значением; для `parent` это напрямую
влияет на выбор родителя (единственное значение `relationship`, которое реально
участвует в tie-break AC2), а `slice(0,40)` без стрип-нормализации выдаст,
например, `"previous-child"` вместо `"previous_child"`.
**Воспроизведение (по коду, не исполнением):** `relationshipOf('Previous-Child')`
→ `'previous-child'` (дефис сохранён) вместо ожидаемого по ТЗ канонического
`'previous_child'`; `relationshipOf('PARENT ')` (с завершающим пробелом,
`.trim()` уберёт только края, но не внутренние разделители, если бы они были)
работает случайно правильно только потому что `'parent'` не содержит
внутренних разделителей — но `relationshipOf('parent ')` (лишний пробел внутри
после какого-то провайдерского форматирования, напр. `"Parent "`) тоже
случайно проходит через `.trim()`. Ни один из тестов диффа не подаёт
разделённые строки (`test/zigbee-topology.test.mjs` использует только
`'Parent'`/`'Child'`/`'parent'`/`'sibling'` без пробелов/дефисов/подчёркиваний),
поэтому регрессия не видна ни одному прогону CI.
Это не гипотетическая придирка к букве ТЗ: сама формулировка §6.3 была
написана в ответ на реальную неопределённость формата — в issue прямо
сказано, что raw-строка ZHA и Z2M-число два разных источника, и что фикстур с
реальным ZHA-payload в проекте нет вовсе (я проверил — `test/fixtures/` содержит
только Z2M-фикстуру). Раз формат ZHA не зафиксирован фикстурой, единственная
гарантия того, что реальные варианты форматирования (case, `_`, `-`, пробелы)
не сломают выбор родителя — явная нормализация, которую ТЗ и предписывает, а
код только что перестал делать.
**Почему не High:** ни один принятый AC не формулирует явный тест на
`previous_child`/дефисы напрямую (AC2/AC3 говорят про tie-break и Z2M-числа), и
`'parent'`/`'child'`/`'sibling'` без разделителей (наиболее вероятный формат
из капитализированного enum-имени) продолжают работать. Функция не роняет
link/LQI ни в одном случае — деградация тихая, а не крэш.
**Что сделать:** вернуть версию нормализации из `c844473c` (либо эквивалентную
по результату) и добавить unit-кейс с дефисом/подчёркиванием/пробелом внутри
строки, который различал бы обе версии функции.
### Находка 2 (Medium, в скоупе) — доказательство AC9 неполно: нет browser-smoke на аспектах/zoom
ТЗ §14 (AC9) явно называет способ доказательства: «pure pixel-geometry unit +
browser smoke на двух aspect ratios и трёх zoom». В `demo/smoke_zigbee_topology_hover.mjs`
нет ни resize окна, ни emulateMedia/emulate zoom — весь smoke выполняется на
одном дефолтном размере страницы и zoom=1 (проверено: `grep -n
"zoom\|resize\|viewport" demo/smoke_zigbee_topology_hover.mjs` — пусто).
Пиксельная геометрия наконечника сама по себе верна для любой формы контейнера
«в вакууме» (viewBox и перевод процентов в пиксели используют одно и то же
`width/height`, см. разбор AC9 выше) — это можно подтвердить чтением. Но именно
переход на такую геометрию был предложен в issue как решение конкретной
технической ловушки («SVG оверлея неравномерный», trap #1), и ТЗ поставило
кросс-aspect/zoom smoke как приёмочное доказательство не просто для полноты, а
потому что часть системы, которую эта фича трогает
(`[data-hp-live-layer="camera"]`, `src/live-viewport.ts`), в живых
zoom/pan-жестах применяет CSS `transform: translate()/scale()` **прямо к
самому** `<hp-zigbee-topology-overlay>` (он помечен `data-hp-live-layer="camera"`
в `zigbee-topology-overlay-bridge.ts`). `_markerClearance` берёт размер маркера
из `getBoundingClientRect()` (пост-трансформенный, то есть уже отмасштабированный
экранный размер), а `origin`/`neighbor` координаты — из процентных стилей
маркера, переведённых в пиксели через **собственный** `clientWidth/clientHeight`
оверлея, которые CSS-трансформация, применённая к самому оверлею, не меняет.
При активном live-жесте эти две величины перестают быть в одной системе
координат: клиренс скейлится с текущим live-zoom, а дистанция между точками —
нет. Я не смог подтвердить это как воспроизводимый баг за отведённое время
ревью (нужно проверять, остаётся ли mouse-hover активным во время live-жеста
панорамирования мышью, а не только touch/pinch — существующий контракт уже
гарантирует, что touch чистит hover) — поэтому не поднимаю это до High и не
утверждаю дефект как подтверждённый. Но это ровно тот класс проверки, которую
заявленный в ТЗ browser smoke на разных zoom должен был закрыть, и он не
написан.
**Что сделать:** добавить в `demo/smoke_zigbee_topology_hover.mjs` хотя бы одну
проверку с изменённым `page.setViewportSize` (широкий/высокий контейнер) и,
если mouse-hover способен пережить live zoom/pan жест, зафиксировать это
сценарием; если нет (hover гарантированно сбрасывается раньше) — явно
аргументировать в ТЗ/тесте, почему zoom-часть AC9 достаточно доказана чистым
unit, и сузить формулировку доказательства.
## Низкие / снятые без правки
- `src/hp-zigbee-topology-overlay.ts`: класс `.route-arrow` объявляет
`vector-effect: non-scaling-stroke`, но полигон наконечника рисуется только
заливкой (`fill`), без `stroke` — правило не имеет эффекта. Не влияет на
видимое поведение ни на одном профиле. Снимаю без правки.
## Что не проверял
- `python -m pytest tests_backend` — не тронут `custom_components/**/*.py`.
- `npm run invariants` — дифф не меняет геометрию комнат/стен/`layout`/
`marker.space`/`open_spans`, только runtime-проекцию поверх уже
размещённых устройств.
- 28 «слабых» смок-связей по общему символу `_mode`, напечатанных
`smoke-select.mjs` — не по теме этой фичи, не гонял.
- Полный `node scripts/mutation-gate.mjs` (без `--check`, с реальной
пересборкой бандла на каждого мутанта) — дорогой предрелизный гейт, не гейт
код-ревью; ограничился `--check` (патчи применимы).
- Живой zoom/pan-жест мышью с активным hover в реальном браузере — см.
находку 2; не smoke-нул руками, только прочитал код `live-viewport.ts` и
`hp-zigbee-topology-overlay.ts`.
## Вердикт
Обе находки — Medium, в скоупе задачи (правятся в этой же ветке, не отдельным
issue). High нет. Все 12 AC либо доказаны зелёными автотестами, либо разобраны
чтением с явной пометкой, кроме двух мест, где доказательство ТЗ выполнено не
полностью (находки 1 и 2). Это соответствует критерию жёлтого вердикта даже
при формально выполненных AC.
```
Вердикт: жёлтый · заход r1 · блокирующих циклов 0/4 · High: 0 · Medium: 2 → в задаче
```
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/457-zigbee-route-arrows`, коммит `d44aa30f90e4` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `d56824dbfc4ad80f5c560ba21fa19d17ca8eca5f`
```
git log --all --format='%H %T' | grep d56824dbfc4a
```
- ТЗ `docs/specs/457-zigbee-route-arrows.md`, блоб `2c8350e397b0192a8f0db3d1c3ee0bf9f5f6e1f6`
```
git log --all --find-object=2c8350e397b0192a8f0db3d1c3ee0bf9f5f6e1f6 -- docs/specs/457-zigbee-route-arrows.md
```