mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
@@ -0,0 +1,257 @@
|
||||
# CODE-REVIEW-330-r2
|
||||
|
||||
Issue: #330 — «Ограничения стыков (#329) блокируют event loop HA и дёргают ресайз на больших планах»
|
||||
Этап: code (PROCESS.md §2.7)
|
||||
Заход: r2 · блокирующих циклов израсходовано 1 из 4 (до этого раунда)
|
||||
Ветка: `issue/330-junction-limits-performance`, HEAD ревью — `85636a65`
|
||||
|
||||
## 0. Почему разбор полный, а не по дельте
|
||||
|
||||
Предыдущий вердикт (code-review r1, 2026-08-28T00:00:48Z) не назвал SHA, на
|
||||
котором он получен — это само по себе процессное упущение (см. ниже). Автор
|
||||
в ответном комментарии называет два SHA до финального ребейза: `a7500973`
|
||||
(HEAD на момент публикации r1) и `7513f93d` (докс-фикс, слитый до публикации
|
||||
вердикта). Оба объекта в текущем дереве отсутствуют:
|
||||
|
||||
```
|
||||
$ git cat-file -t a7500973 → fatal: Not a valid object name
|
||||
$ git cat-file -t 7513f93d → fatal: Not a valid object name
|
||||
```
|
||||
|
||||
Ветка была перебазирована на ушедший вперёд `dev` через мердж #62 («ребейз
|
||||
над мерджем i18n-registry», коммит `85636a65`) уже ПОСЛЕ публикации r1. Это
|
||||
ровно исключение §7.2 из брифинга: «ребейз на ушедший вперёд dev — после
|
||||
ребейза это другой код». Пословный `git diff <r1 SHA>..HEAD` невозможен и не
|
||||
имел бы смысла — базовые деревья разошлись. Разбор ниже полный, по
|
||||
`git diff origin/dev...HEAD`, с явным закрытием находок r1 отдельным
|
||||
разделом.
|
||||
|
||||
**Процессное наблюдение (не находка в код, не считается в M/H):** ни в
|
||||
вердикте r1, ни в вердикте предыдущего code-review-раунда SHA не был назван
|
||||
текстом. Формат §7.2 этого не требует явно, но по факту это стоило времени
|
||||
на этот раунд — рекомендую в следующих раундах явно указывать SHA в теле
|
||||
вердикта, а не только в документе.
|
||||
|
||||
## 1. Скоуп
|
||||
|
||||
Диапазон `origin/dev...HEAD`, 31 файл, +1731/-258:
|
||||
|
||||
- `custom_components/houseplan/junction_limits.py` — линейный byNode-индекс
|
||||
П3, bucket-решётка П4, `_migrated_spaces` без ремиграции v9 (§4.6),
|
||||
`space_violation_counts`/`baseline_counts` (§4.2).
|
||||
- `custom_components/houseplan/websocket_api.py` — цепочка валидаторов
|
||||
`ws_config_set`/`ws_plan_optimize` в `async_add_executor_job` (§4.1),
|
||||
запись/чтение `rt.junction_baseline` по rev (§4.2).
|
||||
- `custom_components/houseplan/store.py` — новое поле рантайма
|
||||
`junction_baseline`.
|
||||
- `src/junction-limits.ts` — те же три среза на фронте (byNode-индекс,
|
||||
bucket-решётка, зеркальная логика).
|
||||
- `src/houseplan-card.ts` — `_junctionLimitViolations` принимает
|
||||
`sharedGeometry` (§4.7, общий проход топологии/кладки на все комнаты),
|
||||
`_junctionLimitsIntroduced` — кэш baseline по (identity, `_cfgEpoch`)
|
||||
(§4.4) и путь «v9 как есть» (§4.6 на фронте).
|
||||
- `demo/benchmark_junction_limits.mjs`, `package.json`,
|
||||
`.github/workflows/validate.yml` — новый перф-контракт §5/AC7, вшитый в
|
||||
job `performance_smoke` (не только в еженедельный `mutation-gate.yml`).
|
||||
- `demo/smoke_junction_limits.mjs` — счётчик вызовов `_junctionLimitViolations`
|
||||
за один жест ресайза (заявленная поведенческая часть AC4).
|
||||
- `scripts/mutation-gate.mjs` — три новых мутанта (П3, П4, «протухший
|
||||
baseline-кэш»).
|
||||
- `test/junction-limits.test.mjs`, `tests_backend/test_junction_limits.py`,
|
||||
`tests_backend/test_ha_websocket.py` — юниты эквивалентности и AC1.
|
||||
- `docs/CHANGELOG.md`/`.ru.md` — одна строка, User-Visible: yes, в том же
|
||||
коммите (`c90f5bf0`), что и код.
|
||||
- Скриншоты/`screenshots.json` — механический пересчёт отпечатка
|
||||
(src/** изменился), содержимое интерфейса не менялось.
|
||||
|
||||
Вердикты П1–П5 не меняются нигде в дифф — заявление автора подтверждено
|
||||
чтением: единственная содержательная логика (`_length`, `_distance_to_segment`,
|
||||
условия нарушений, epsilons) не тронута; менялись только структуры данных
|
||||
вокруг неё (индексация, кэш, порядок обхода).
|
||||
|
||||
## 2. Как проверялось
|
||||
|
||||
Все команды выполнены на этом SHA в этой рабочей копии; результат — вывод
|
||||
ниже, не «verified» без числа.
|
||||
|
||||
| Гейт | Команда | Результат |
|
||||
|---|---|---|
|
||||
| Типы | `npx tsc --noEmit` | чисто, без вывода |
|
||||
| Юниты фронта | `npm test` | **1419 passed**, 1 skipped, 0 failed |
|
||||
| Юниты бэкенда (чистый python, без HA) | `python3 -m pytest tests_backend/test_junction_limits.py -q` | **13 passed** |
|
||||
| Юниты бэкенда (HA-харнесс) | `python3 -m pytest tests_backend -q` (pytest-homeassistant-custom-component установлен в этой сессии) | **423 passed, 1 skipped, 1 error** — ошибка теardown (`_run_safe_shutdown_loop` thread-leak assert) воспроизводится и на НЕ тронутом этой веткой тесте (`test_issue_244_...`) при отдельном прогоне того же файла — окружение, не диффа. Тест **AC1** (`test_330_config_set_validators_run_in_the_executor`) прошёл исполнением: `1 passed`. |
|
||||
| Перф-контракт §5/AC7 | `npm run benchmark:junction-limits` | `pass: true`; tsFullCandidateMs 150.7/400 (2.65×), pyWarm 67.1/300, pyCold 2463/5000 |
|
||||
| Сборка + идентичность бандла | `npm run build` + `git status --short` (чисто) + `diff dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` | идентичны, `git status` чист после ребилда |
|
||||
| Докс-гейт | `node scripts/check-docs.mjs` | «Documentation checks passed (7 files, 10 external links)» |
|
||||
| Инварианты модели | не гонял отдельной командой — `npm test` уже включает `test/model-invariants.test.mjs` на всех моделях проекта (зелёный); задача не вводит новую конфигурацию для точечной проверки `npm run invariants -- --config` |
|
||||
| Смок-выборка | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` → 7 прямых совпадений + 1 «зарегистрированная связь» (см. §3) | все 8 + 3 названных в AC6 (`island_rooms`, `room_resize`, `resize_pointer_real_plan`) прогнаны — **11/11 OK** (после `node scripts/bundle-sync.mjs`, без которого `demo/srv/assets/houseplan-card.js` отсутствовал и смоки падали таймаутом на билд, не на код) |
|
||||
| Мутационная проверка (ручная, не полный `mutation-gate.mjs`) | вручную применил 3 патча из `scripts/mutation-gate.mjs` (П3-снова-квадратичен, П4-снова-перебор, протухший baseline-кэш) и прогнал их guard-команды | П3 и П4 **ловятся бенчем** (`pass:false`, tsSegmentLengthsMs 402.6/60 и tsNodeDistancesMs 207.4/80 соответственно); третий мутант — см. находку M1 ниже, там дисциплина «тест умеет падать» провалилась на смоке |
|
||||
| golden | не гонял | diff не трогает рендер/геометрию/стили/слои — только порядок вычислений и кэш валидатора; визуальный результат не может измениться этим кодом |
|
||||
| performance-профили сверх §5 | не гонял | не названы в AC сверх §5/AC7 |
|
||||
|
||||
## 3. Смок-выборка — вывод инструмента
|
||||
|
||||
```
|
||||
Изменено файлов src/**: 2 · символов на изменённых строках: 17
|
||||
Прямое совпадение (7): smoke_junction_holes, smoke_decor, smoke_glow_fail_dark,
|
||||
smoke_glow, smoke_grid_scale_invariance, smoke_junction_limits, smoke_space_scale_defaults
|
||||
Зарегистрированная связь (1): smoke_real_plan_masonry ← wallBodiesGeometry
|
||||
```
|
||||
Плюс три смока, названных в AC6 автором (`island_rooms`, `room_resize`,
|
||||
`resize_pointer_real_plan`) — не входят в прямые совпадения инструмента для
|
||||
этого diff, но названы в AC, поэтому прогнаны. Итог: 11 смоков, все **OK**.
|
||||
|
||||
## 4. Находки
|
||||
|
||||
### M1 (в скоупе) — заявленная «поведенческая половина» AC4 не различает наличие и отсутствие кэша
|
||||
|
||||
Файл: `demo/smoke_junction_limits.mjs:185-217`; коммит `c90f5bf0` и
|
||||
подтверждающий комментарий автора («Поведенческая половина AC4 (N move →
|
||||
N+1 вычислений на настоящем pointer-жесте)»).
|
||||
|
||||
Смок считает вызовы `_junctionLimitViolations` за весь жест ресайза и
|
||||
проверяет `resizeBaselineCachedPerGesture = jlCalls > 0 && jlCalls <= 12`.
|
||||
Я вручную воспроизвёл ровно тот мутант, что заведён в
|
||||
`scripts/mutation-gate.mjs` под id `junction-limit-baseline-cache-stale`
|
||||
(отключил запись в `_junctionBaselineCache` — кэш никогда не срабатывает,
|
||||
baseline пересчитывается на каждый move) и прогнал **сам смок** (не юнит,
|
||||
который guard мутанта вызывает вместо него):
|
||||
|
||||
```
|
||||
DEBUG jlCalls= 12 (кэш полностью отключён)
|
||||
DEBUG jlCalls= 11 (немодифицированный код, кэш работает)
|
||||
OK (оба раза)
|
||||
```
|
||||
|
||||
Разница между «кэш работает» и «кэш полностью выключен» — **один вызов из
|
||||
двенадцати**, и порог `<= 12` пропускает оба случая. Смок физически не может
|
||||
отличить рабочий кэш от полностью удалённого — он зелёный в обоих случаях.
|
||||
Причина в том, что счётчик `window.__jlCalls` ловит вообще все обращения к
|
||||
`_junctionLimitViolations` за время жеста (включая вызовы вне
|
||||
`_junctionLimitsIntroduced`, например из рендера/подсветки), а не только
|
||||
экономию на baseline — сигнал тонет в шуме, и заявленная арифметика
|
||||
«N+1 против 2N» на практике не воспроизводится этим жестом ни при каком
|
||||
разумном пороге.
|
||||
|
||||
Это ровно тот класс дефекта, который автор сам поймал для ЮНИТ-теста тем же
|
||||
приёмом в `7aaf5a72` («тест умеет падать» — контракт перепрятали в
|
||||
проверку по исходнику именно потому, что первая версия юнита не ловила
|
||||
мутанты). Для смока тот же самоконтроль не был применён: заявление в
|
||||
коммите, что поведенческая защита AC4 «живёт в смоке», не подтверждается.
|
||||
|
||||
Фактическая защита AC4 сейчас — только regex-проверка по исходнику
|
||||
(`test/junction-limits.test.mjs:380-399`, ищет литеральную строку
|
||||
`cached.epoch === this._cfgEpoch`). Она реально работает как барьер (я
|
||||
проверял: мой мутант меняет именно эту подстроку и юнит падает), но это
|
||||
защита от одной конкретной формы регресса (удаление/подмена этого сравнения
|
||||
в тексте), а не от логической. Сама РЕАЛИЗАЦИЯ кэша в продакшен-коде
|
||||
корректна — я прочитал `_junctionLimitsIntroduced` (houseplan-card.ts:7503-7549)
|
||||
и логика верна: WeakMap по identity `previousConfig`, инвалидация по
|
||||
`_cfgEpoch`, который сам ведёт себя как установившийся паттерн в этом файле
|
||||
(бампается в ~30 местах при любом реальном изменении `_serverCfg`/эпизода
|
||||
редактирования). Это находка про КАЧЕСТВО ТЕСТА, а не про баг в фиче.
|
||||
|
||||
**Почему в скоупе и Medium:** AC4 ТЗ требует доказательства «жест ресайза
|
||||
считает baseline один раз»; заявленное доказательство decorative. Без
|
||||
High это возвращается автору жёлтым, а не заводится отдельным issue —
|
||||
находка про код и тесты ЭТОЙ задачи, не соседнего поведения.
|
||||
|
||||
**Что нужно поправить:** либо ужесточить порог до значения, которое
|
||||
реально разделяет 11 и 12 (например, точное равенство ожидаемому числу
|
||||
вызовов для конкретной синтетической последовательности `mouse.move` — тогда
|
||||
и сам тест обязан быть проверен на этом же мутанте), либо честно
|
||||
переформулировать комментарий/заявление AC4: поведенческая защита не
|
||||
добавляет покрытия сверх юнита по исходнику, и это нужно признать в
|
||||
документе, а не заявлять как отдельную гарантию.
|
||||
|
||||
## 5. Закрытие раунда r1
|
||||
|
||||
| Находка r1 | Чем закрыта | Где видно |
|
||||
|---|---|---|
|
||||
| H1 — докс-гейт красный (screenshots.json/PNG не из одного прогона) | Уже было закрыто `7513f93d` до публикации вердикта; после второго ребейза (`85636a65`) фингерпринт и PNG пересобраны ещё раз тем же правилом | `node scripts/check-docs.mjs` → «Documentation checks passed» на этом SHA (проверено мной заново) |
|
||||
| H2 — бюджеты §5 калиброваны с запасом 1.14× вместо 2-3×, бенч не в CI-воркфлоу | Таблица замеров с обеих машин, бюджеты пересчитаны от худшей; шаг `npm run benchmark:junction-limits` добавлен в job `performance_smoke` в `validate.yml` | `.github/workflows/validate.yml:543-547`; сам прогон на этом SHA — `pass:true`, запас 2.65× (150.7/400) |
|
||||
| M1 — обещанный AC1-тест на самом деле мерил только тёплый `validate_junction_limits`, не `websocket_api.py`/`hass` | Новый тест `test_330_config_set_validators_run_in_the_executor` патчит `validate_junction_limits` обёрткой-шпионом внутри реального HA-харнесса, вердикты (принято/отклонено `junction_limit_angle`) сверены | `tests_backend/test_ha_websocket.py:2322-2385`; **я прогнал его исполнением** (`pytest -k 330` → `1 passed`), не только чтением |
|
||||
| M2 — §4.6-эквивалентность фронта проверена только текстовым паттерном; python-паритет на одной фикстуре | Три граничные фикстуры (as-is==through-migration) на TS (`test/junction-limits.test.mjs:401`) и на python (`tests_backend/test_junction_limits.py:423`, три полигона: spike/box/narrow) | Оба набора прогнаны: TS — часть зелёного `npm test`; python — часть зелёного `pytest tests_backend/test_junction_limits.py` (13/13) |
|
||||
|
||||
Все четыре находки r1 закрыты по существу, подтверждено исполнением (не
|
||||
только чтением) там, где это было в моих силах в этой песочнице.
|
||||
|
||||
## 6. Унаследовано из r1 (без повторной проверки)
|
||||
|
||||
- Общая архитектура срезов §4.1–§4.6 (rev-кэш, byNode-индекс П3,
|
||||
«v9 как есть» §4.6) — признана в code-review r1 корректной по коду и
|
||||
эквивалентной по вердиктам; в этом раунде я перечитал тот же код заново
|
||||
(диф не менялся с r1 в этой части, кроме docs/CHANGELOG и бенч-калибровки)
|
||||
и подтверждаю тот же вывод самостоятельно, а не по доверию к r1 — это не
|
||||
наследование, а совпавший результат независимой проверки.
|
||||
- Ничего из r1 не наследуется слепо: поскольку исходный SHA r1 недостижим
|
||||
после ребейза (см. §0), весь код перечитан заново в этом раунде, а не
|
||||
предположен на основании старого вердикта.
|
||||
|
||||
## 7. Что проверено и корректно
|
||||
|
||||
- **Эквивалентность вердиктов П1–П5** до и после — не изменилась ни в одной
|
||||
строке содержательной логики; подтверждено чтением и параллельно
|
||||
зелёными паритет-тестами TS↔Python (`test_parity_with_the_frontend_checks`,
|
||||
уже существовавший до #330, прошёл в составе 13/13).
|
||||
- **Bucket-решётка П4**: прочитал построчно оба зеркала (`junction_limits.py:223-291`,
|
||||
`junction-limits.ts:188-247|`); граничный случай (узел и сегмент в соседних
|
||||
ячейках решётки, но ближе порога) закрыт паддингом bbox сегмента на
|
||||
`min_units` и проверен тестом `test_330_p4_bucket_matches_bruteforce_on_cell_borders`
|
||||
(4 кейса, включая ровно 4 и 5 см от порога) — прогнан, зелёный.
|
||||
Ассиметрия «узел проверяется в 9 соседних ячейках, сегмент — только в
|
||||
своей» корректна по построению: паддинг сегмента компенсирует то, что для
|
||||
узлов (точек) эквивалентного паддинга нет.
|
||||
- **§4.6 «v9 как есть»**: перечитан барьер в `websocket_api.py:1325` (schema
|
||||
всё равно валидирует документ целиком независимо от пропуска ремиграции) —
|
||||
риск «подделать v9 без каталога» закрыт независимо от нового теста.
|
||||
- **Executor-обёртка** в `ws_config_set`/`ws_plan_optimize`: `write_lock`
|
||||
держится вокруг `await`, поэтому сериализация записей не нарушена; ошибка
|
||||
`too_large` в `ws_plan_optimize` корректно вынесена из executor-функции
|
||||
обратно в вызывающий код перед `connection.send_error` (сеть — дело event
|
||||
loop, не executor-потока).
|
||||
- **`rt.junction_baseline`**: инвалидация «по факту», а не по всем путям
|
||||
записи — `ws_plan_optimize` не обновляет кэш вовсе (и не обязан: он не
|
||||
проверяет ограничения стыков ни до, ни после #330 — не регрессия), поэтому
|
||||
следующий `config/set` просто промахнётся по rev и пересчитает `previous`
|
||||
с нуля; корректность не зависит от того, кто ещё пишет rev.
|
||||
- **§4.7 общий проход**: `preflight.wallGeometry`, переданный в
|
||||
`_junctionLimitsIntroduced` как `candidateGeometry`, построен
|
||||
(`_rszSpaceCandidateGeometry`) из ТОЙ ЖЕ пары (`spaceId`, `sp`), что и
|
||||
`limitCandidate` — не подмена по случайному кэшу; проверено чтением обеих
|
||||
функций (houseplan-card.ts:9104-9121, 9156-9175).
|
||||
- **CHANGELOG** — одна строка в обоих файлах, в том же коммите (`c90f5bf0`),
|
||||
что и код, при `User-Visible: yes`.
|
||||
- **Одно число — один источник**: diff не добавляет и не меняет ни одной
|
||||
видимой пользователю величины (только внутренние тайминги/пороги
|
||||
бенчмарка, невидимые пользователю) — раздел неприменим.
|
||||
|
||||
## 8. Чего не проверял и почему
|
||||
|
||||
- **golden:verify** — не гонял; diff не трогает рендер, геометрию,
|
||||
стили или слои, только порядок/кэш вычислений валидатора стыков.
|
||||
- **Полная матрица `demo/smoke_*.mjs` (194 шт.)** — не гонял; задача не
|
||||
меняет ничего, что затрагивает широкие символы (`_cfgEpoch`, `_serverCfg`,
|
||||
`_space` инструмент явно исключил как «не широкие» в данном случае), 11
|
||||
выбранных/названных в AC смоков — зелёные.
|
||||
- **`benchmark_safe_resize`** — не гонял; это тот самый бенч, про который
|
||||
spec-review r1 (H2) установил, что он не вызывает код лимитов вообще —
|
||||
нерелевантен этой задаче.
|
||||
- **Полный `scripts/mutation-gate.mjs` прогон (все ~40+ мутантов)** — не
|
||||
гонял; это еженедельный гейт (PROCESS.md §8), не гейт ревью. Три НОВЫХ
|
||||
мутанта проверил вручную построчно (см. §2, §4) — этого достаточно для
|
||||
вопроса «умеет ли тест, который я прогонял, падать».
|
||||
- **`npm run invariants -- --config <...>`** отдельной командой — не
|
||||
гонял; задача не вводит новую конфигурацию/фикстуру геометрии за
|
||||
пределами того, что уже покрыто `npm test` (который включает
|
||||
`model-invariants.test.mjs` на моделях проекта).
|
||||
- **Ручное тестирование в браузере** — вне цикла ревью по регламенту; браузерные
|
||||
смоки — единственная замена, они прогнаны.
|
||||
|
||||
## 9. Итог
|
||||
|
||||
Один Medium в скоупе (M1 — заявленная поведенческая защита AC4 не
|
||||
дискриминирует регресс, эмпирически подтверждено), High нет. Основная
|
||||
перф-архитектура (§4.1–§4.7), эквивалентность вердиктов и перф-контракт §5
|
||||
подтверждены исполнением, а не только чтением или доверием к предыдущему
|
||||
раунду.
|
||||
Reference in New Issue
Block a user