Files
houseplan-card/docs/reviews/CODE-REVIEW-224-r1.md
T
2026-08-22 12:24:25 +00:00

20 KiB
Raw Blame History

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) возврат в «В разработке».