20 KiB
CODE-REVIEW-224-r1
- Issue: #224 — канонические координаты на каждой записи
- ТЗ:
docs/specs/224-config-coordinate-canonicalization.md, ревью ТЗ зелёное (docs/reviews/SPEC-REVIEW-224-r1.md) - Диапазон:
origin/dev...HEAD, HEAD =4a798e3e13275c0820aec2db1e63d1fae31a41f3 - Заход: r1 (первый код-ревью этой задачи, дельта не применяется — разбор полный)
- Вердикт: красный · High: 1 · Medium: 2 (обе в скоупе) · Low: 1
Скоуп
28 файлов, +2050/-196: общий Python/TypeScript контракт квантования
(custom_components/houseplan/coordinate_canonicalization.py,
src/coordinate-canonicalization.ts), подключение к CONFIG_SCHEMA /
LAYOUT_SCHEMA / POS_SCHEMA и к storage barrier (store.py), no-op ветки в
websocket_api.py (config/set, layout/set, layout/update), фронтенд
(_writeConfig, _persistLayout), общая fixture, backend/frontend тесты,
четыре мутанта из issue, документация (CANVAS/CONFIG-COMPATIBILITY/USER-GUIDE/
CHANGELOG×2/TESTING/STATUS).
Как проверялось
| Гейт | Результат |
|---|---|
npx tsc --noEmit |
зелёный |
npm test |
1061 тестов, 0 упало |
npm run build + сверка dist / custom_components/.../frontend / demo/srv/assets |
все три копии побайтово идентичны |
node scripts/check-docs.mjs (обязателен: diff трогает src/**) |
красный — ERROR screenshot source fingerprint is stale; run npm run build && node demo/docs/capture.mjs (см. Finding M1) |
python -m pytest tests_backend -q (обязателен: diff трогает custom_components/**/*.py) |
346 passed, 1 error — ошибка та же в изоляции на tests_backend/test_ha_upload.py::test_upload_ok и воспроизводится на голом запуске этого файла: teardown-квирк pytest-homeassistant-custom-component (_run_safe_shutdown_loop thread-name assert), не связан с диффом. Целевые файлы (test_coordinate_canonicalization.py, test_ha_websocket.py, test_ha_import_export.py) — 159 passed, тот же посторонний teardown-error один раз |
npm run golden:verify (по необходимости: diff меняет геометрию/рендер) |
красный — 23 сцены different из ~82 (см. Finding H1) |
Мутанты из issue (node scripts/mutation-gate.mjs --id=<...>) |
все 4 названных в issue id пойманы 1 из 1: schema-quantization-removed, frontend-writes-raw-coords, quantization-hits-allowlist, import-path-bypasses-schema |
Смоки по grep (_writeConfig/canonicaliz) |
node demo/smoke_config_writer.mjs — OK; node demo/smoke_optimize_coordinate_canonicalization.mjs — OK (мокает callWS, не проходит через реальный backend, поэтому не свидетельствует ни за, ни против canonicalization) |
Полный mutation-gate.mjs (сверх обязательного, для очистки совести) |
один посторонний бейзлайн-фейл вне #224 (demo/smoke_hide_room_names.mjs, trueRestoresExistingCard), файл диффом не тронут — вне скоупа этой задачи, не блокирует |
Не проверялось: demo/smoke_* полным набором (163 штук — задача не задевает всё
поле); performance-профили (не названы в AC и не тронуты чувствительные пути,
спецификация явно говорит "нет отдельного perf-артефакта").
Находки
H1 — golden не остаётся прежним, вопреки AC12 и §2/§15 ТЗ (High, в скоупе)
npm run golden:verify на этой ветке даёт 23 different сцены из ~82.
Сравнение с origin/dev в том же окружении (тот же Chromium/Linux, тот же
прогон подряд дважды — набор стабилен, не флейк) показывает, что 3 из них
(opening-placement-door-thick-wall-*) уже отличаются на dev и к #224
отношения не имеют; но 20 возникают именно на этой ветке:
geometry-view-dark-fit, geometry-view-light-fit,
day-cycle-dawn/day/dusk/night-dark (4),
geometry-plan-editor-dark, plan-snap-endpoint-light, plan-snap-line-gaps-dark,
geometry-devices-editor-dark, geometry-decor-editor-dark,
tray-wide-selection-en, tray-wide-tool-ru, tray-medium-group-en,
tray-medium-selection-ru, tray-narrow-tool-ru,
geometry-diagonal-45-opening-dark, hover-nested-room-dark,
backup-full-preview-desktop-en, backup-plan-only-export-desktop-en
Отличия не косметические: maxObservedDelta до 210 (порог сцены — 10),
diffRatio до 0.7% (порог — 0.05–0.08%), т.е. golden-report.json показывает
реальный, не пороговый шум. Пиксельный разбор geometry-view-dark-fit
(artifacts/golden/actual против demo/golden/baselines) в районе
x=467–486, y=44–375 показывает не микросдвиг канта, а качественно другой
результат: там, где baseline рисует широкое пятно (иконка/бейдж устройства,
цвета 92–230), actual даёт двухпиксельную линию — похоже на элемент,
который перестал полноценно рендериться, а не на ULP-дребезг стены.
Это прямо противоречит:
- AC12 «Visual golden не меняется»;
- §2 ТЗ «Внешний вид и точность размещения не меняются»;
- §15 ТЗ «Новых ... golden baseline ... нет».
Диагностика, которую успел провести: canonicalizeConfigGeometry(),
прогнанный оффлайн на фикстуре golden-geometry
(demo/fixtures/visual-matrix.mjs), возвращает побитово идентичный
объект — то есть сам алгоритм квантования не трогает эти конкретные числа.
Мок golden-харнесса (demo/golden/harness.mjs) также не проходит через
_writeConfig/_persistLayout (config/get/layout/get отдают
structuredClone(fixture...) напрямую, config/set в моке — заглушка
{ ok: true }), поэтому новый код квантования в View-сценах в принципе не
выполняется. Значит причина не в «шум стал виден», а в каком-то другом
побочном эффекте этого диффа на рендер (кандидат, который я не успел
исключить: лишний авто-инкремент _cfgEpoch при каждой синхронной
переприсвоении _serverCfg, см. Low L1, — но это не объясняет чисто
View-сцены без единого _writeConfig). Причина остаётся на автора: ревью
только фиксирует и воспроизводит факт регресса, не диагностирует его до
конца.
Воспроизведение: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js && npm run golden:verify, сравнить с тем
же прогоном на origin/dev.
M1 — check-docs.mjs красный: отпечаток скриншотов не обновлён (Medium, в скоупе)
node scripts/check-docs.mjs падает: ERROR screenshot source fingerprint is stale; run npm run build && node demo/docs/capture.mjs. Diff трогает
src/houseplan-card.ts и добавляет src/coordinate-canonicalization.ts, а
docs/images/screenshots.json в этом диффе не менялся (git diff origin/dev...HEAD --stat -- docs/images пуст). Это ровно тот класс
регрессии, который PROCESS.md §8 называет неусловным: «любая правка
фронтенда делает [отпечаток] устаревшим», и который уже стоил dev
красного job docs на #230 и #234 (issue #237). Правка механическая:
npm run build && node demo/docs/capture.mjs, закоммитить обновлённый
docs/images/screenshots.json (и сами скриншоты, если изменились) в этой же
задаче.
M2 — AC6 заявляет доказательство, которого нет (Medium, в скоупе)
Спецификация (§12, AC6) называет способ доказательства: «Queue/debounce unit
с controlled promises». В диффе такого теста нет — единственная фронтенд-
проверка соответствующего кода это
test('frontend write paths adopt canonical candidates before persistence (#224)', ...) в test/coordinate-canonicalization.test.mjs, которая
сверяет текст исходника регулярным выражением, а не поведение очереди
записи (не создаёт конкурентных промисов, не проверяет, что правка,
сделанная во время await callWS, не теряется).
По коду поведение действительно верное — проверено чтением, не
исполнением: _writeConfig() (src/houseplan-card.ts:6948) присваивает
this._serverCfg = candidate синхронно, до await this.hass.callWS(...), и
каждое звено _writeChain читает this._serverCfg в момент своего
выполнения (комментарий над полем прямо описывает этот контракт), поэтому
правка, сделанная во время отправки, действительно войдёт в следующее звено
очереди. Функционально AC6 похоже выполнен. Но раз обещанного тестом
доказательства нет, а регресс-тест на конкурентную запись мог бы поймать
именно такие ошибки (в т.ч. в паре с H1, где причина рендер-регресса пока не
установлена), это заявленный, но не выполненный пункт AC — правится тестом
в этой же задаче либо явной пометкой в ТЗ/хендоффе, что доказательство
заменено на «проверено чтением».
L1 — двойной инкремент _cfgEpoch в _writeConfig (Low)
src/houseplan-card.ts:6955-6958:
const candidate = canonicalizeConfigGeometry(this._serverCfg);
const candidateFingerprint = contentFingerprint(candidate);
if (candidateFingerprint !== contentFingerprint(this._serverCfg)) this._cfgEpoch++;
this._serverCfg = candidate;
_serverCfg зарегистрирован реактивным (static properties = { _serverCfg: { state: true }, ... }), и willUpdate() уже безусловно делает
this._cfgEpoch++ при changed.has('_serverCfg')
(src/houseplan-card.ts:3589). Поскольку canonicalizeConfigGeometry
всегда возвращает свежий клон (новую ссылку) — даже когда контент не
изменился, — присваивание this._serverCfg = candidate само по себе
уже гарантирует один инкремент эпохи на следующем рендере. Ручной инкремент
в строке 6957 срабатывает только когда контент реально изменился, то есть
именно тогда сверху добавляется второй, лишний инкремент. Эффект не
наблюдаем пользователем (эпоха используется только для сравнения на
равенство в кэшах), но это мёртвая/дублирующая логика — можно снять решением
ревьюера с записью здесь, без правки.
Что проверено и корректно
- Контракт квантования (
canonicalize_number/canonicalizeNumber): 9 знаков, round-half-away-from-zero, нормализация-0, побитовое совпадение Python/TypeScript на общей fixture (AC1) — тест умеет падать (мутантquantization-hits-allowlistловится). - Allowlist (§6.2) и negative contract (§6.3) реализованы идентично на обеих
сторонах, unknown-поля не материализуются, вход не мутируется (проверено
тестами
test_coordinate_canonicalization.pyиcoordinate-canonicalization.test.mjs, идемпотентность подтверждена). - Backend schema barrier (
CONFIG_SCHEMA/LAYOUT_SCHEMA/POS_SCHEMA) и общий storage barrier (async_save_config_state/layout_store_payload) канонизируют независимо от источника записи — включая внутренних writer-ов (import apply/rollback, Optimize backup/pending, geometry repair backup, Optimize Undo) — подтверждено чтением всех точек вызова вwebsocket_api.pyи тестомtest_storage_helpers_are_the_final_canonical_barrier(AC5, AC7, AC8). - No-op контракт для
config/set/layout/set/layout/update: сравнение канонического live-кандидата со стором,ok:trueс прежнимrev, store не пишется, event не публикуется, Optimize-снимок не теряется, CAS проверяется ДО сравнения содержимого (устаревшийexpected_revвсё ещё конфликт) — воспроизведено и подтверждено тестомtest_canonical_rewrites_are_noops_without_events_or_undo_loss, тест умеет падать (мутация: убратьif— тест сразу поймает лишнюю ревизию/событие). Отдельно проверено, что no-op вws_config_setне вызывает file collection (collect_plans/collect_attachments) — только best-effort обновление Repairs (async_check_plan_files, не удаляет файлы), что согласуется с инвариантом SCOPE.md «никогда не удалять файл по догадке» (AC4). - Регресс #218 (шесть комнат, общие ULP-вершины): новый тест в
test/physical-geometry.test.mjsподтверждает непустой union и непустой Glow-клип после канонизации, а исходный editor-candidate не мутируется (AC9); тест умеет падать (тот же union/clip раньше требовал ручной round-обёртки, без канонизации фиктура давала пустое пересечение — это и есть исходная жалоба #218). - Диагональные/не-решёточные координаты не «прилипают» к сетке — числовой допуск 5e-10 подтверждён юнитами на обеих сторонах (AC10).
- Denylist (температуры/цвета/калибровка/
view_box/presentation) не затрагивается — негативная fixture плюс мутантquantization-hits-allowlist(AC11). - Импорт (
import_export.py:582,592) идёт через те жеCONFIG_SCHEMA/LAYOUT_SCHEMA, что и обычная запись — новый backend-тест плюс мутантimport-path-bypasses-schema(AC7). - Формулировка Undo из #223 обновлена в
docs/TESTING.mdиdocs/USER-GUIDE.ru.mdв соответствии с §9 ТЗ («canonical representation» вместо «noisy bits»); backend-тестtest_optimize_undo_restores_geometry_but_not_legacy_noisy_bitsподтверждает, что Undo восстанавливает каноническое значение, читая напрямую из pre-#224 (noisy) снимка стора — тест умеет падать (AC8). - Трейлеры коммита:
Issue: #224,User-Visible: yes, оба changelog (docs/CHANGELOG.md,docs/CHANGELOG.ru.md) правлены в том же коммите4a798e3. Терминология вdocs/USER-GUIDE.ru.md(«не привязка к сетке», «Оптимизировать планы») согласована с разделом 19 того же документа. - Три копии продакшн-бандла синхронны.
Чего не проверял
- Полный набор
demo/smoke_*.mjs(163 шт.) — задача не касается всех поверхностей; прогнаны только те, что совпали с изменёнными именами/полями по grep (smoke_config_writer,smoke_optimize_coordinate_canonicalization). - Performance-профили — не названы в AC, спецификация явно отказывается от отдельного perf-артефакта, изменённый код — один линейный обход allowlist.
- Полный
mutation-gate.mjsсверх четырёх мутантов issue не является обязательным гейтом ревью; прогнан частично из любопытства, найденный посторонний фейл (smoke_hide_room_names.mjs) вне скоупа #224 и не влияет на вердикт. - WSL/Windows-специфичные смоки — ревью идёт в Linux CI-подобном окружении, что ближе к канону, чем локальная машина автора.
Итог
High-находка (H1) блокирует: заявленное в AC12 и §2/§15 ТЗ «внешний вид не
меняется» эмпирически неверно на этой ветке — 20 golden-сцен реально
отличаются от baseline, включая то, что похоже на пропавший визуальный
элемент, а не на дребезг последнего бита. Причину не установил до конца
(сам алгоритм канонизации — доказанно no-op на этих данных, а
задействованные write-пути не выполняются в View-режиме харнесса), поэтому
прошу автора локализовать источник рендер-регресса, а не просто принять
новые baseline. Вместе с двумя Medium (гейт docs и недостающий тест AC6)
возврат в «В разработке».