Files
houseplan-card/docs/reviews/CODE-REVIEW-39-r1.md
T
2026-08-29 10:13:09 +03:00

25 KiB
Raw Blame History

CODE-REVIEW-39-r1

  • Issue: #39 «[HP-UX-09] большие подложки»
  • ТЗ: docs/specs/039-large-backdrops.md, ревизия 4.1 (спек-ревью зелёное на r3, SHA 9431a5ce)
  • Ревью-SHA: 0d82ee0a2501fc9a1db0730cc5ae238010d0efbc (ветка issue/39-large-backdrops, уже ребейзнута на origin/dev; предыдущий заход не состоялся из-за конфликта ребейза и цикл не потратил — см. комментарии issue 06:45–06:46 UTC)
  • Заход: r1 · блокирующих циклов до этого разбора: 0/4 (трек — полный, лимит 4)
  • Диапазон: единственный коммит 0d82ee0a поверх origin/dev (git log --oneline origin/dev..HEAD)

Скоуп диффа

50 файлов, +1331/-371. Продуктовый код: src/backdrop-probe.ts (новый, парсер заголовков), src/backdrop-pick.ts (новый, общий pick-флоу и гард-диалог), точечные правки _pickPlanFile в src/houseplan-editor-runtime.ts и src/houseplan-onboarding-runtime.ts, +9 i18n-ключей ×3 языка, src/houseplan-card.ts (поле _backdropGuard, рендер гарда). Остальное — тест (test/backdrop-probe.test.mjs, новый), смок (demo/smoke_backdrop_guard.mjs, новый), 5 новых записей в scripts/mutation-gate.mjs, demo/benchmark_backdrop_decode.mjs, доки (BACKDROP/оба USER-GUIDE/TESTING/оба CHANGELOG) и сгенерированное (dist/**, custom_components/houseplan/frontend/**, скриншоты документации). Ни одного файла custom_components/**/*.py и ни одной правки геометрии (комнаты/рёбра/layout/marker.space/open_spans) — backend-гейт и model-invariants к этому диффу не относятся.

Как проверялось (гейты)

Гейт Команда Результат
typecheck npx tsc --noEmit чисто, без вывода
unit npm test # tests 1518 / pass 1517 / fail 0 / skipped 1 — совпадает с заявленным в хендоффе
build + bundle sync npm run build → cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js + diff -rq dist/houseplan-assets custom_components/houseplan/frontend/houseplan-assets точка входа побайтово совпадает, дерево ассетов идентично; git status после сборки чист — коммит уже содержит пересобранный бандл
bundle budget npm run bundle:budget initial View 273 386 / 282 000 Б gzip, запас 8 614 Б (в бюджете; авторская цифра 273 173 отличается на десятки байт, не критично)
docs fingerprint node scripts/check-docs.mjs (diff трогает src/**) «Documentation checks passed (7 files, 10 external links)»
new-any node scripts/no-new-any.mjs --base origin/dev --head HEAD «Новых any нет» (437 добавленных строк в 5 файлах)
smoke-select node scripts/smoke-select.mjs --base origin/dev --head HEAD 13 «прямое совпадение», 23 «слабая связь» (реестр на 201 смок) — решение по каждой группе ниже
mutation gate (адресно) node scripts/mutation-gate.mjs --id=<4 новых id> (см. ниже) все 4 мутанта поймали свою поломку — тест умеет падать

Решение по smoke-select

Прогнал сам (среда без DNS demo.local — добавил 127.0.0.1 demo.local в /etc/hosts, затем npm run bundle:sync, чтобы обновить demo/srv/assets):

  • node demo/smoke_backdrop_guard.mjs — новый смок, 21/21 ассертов зелёные;
  • node demo/smoke_plan_upload_race.mjs — OK (тот же _pickPlanFile, гонка upload/race не тронута);
  • node demo/smoke_plan_upload_reject.mjs — OK, это же доказательство AC7 (транзакционный контракт: cleanup при reject, диалог не закрывается) — контракт не менялся, смок не редактировался;
  • node demo/smoke_saved_plans.mjs — OK;
  • node demo/smoke_audit_1490.mjs — OK;
  • node demo/smoke_cold_view_toggle.mjs — OK (прямое совпадение по _editorRuntime);
  • node demo/smoke_esc_dialogs.mjs — OK, включая кейсы stacked/stacked2 (несколько hp-dialog друг над другом уже работают штатно, но этот смок не проверяет закрытие диалога ВО ВРЕМЯ busy-операции — см. M1).

Остальные 11 из 13 «прямых совпадений» (smoke_lazy_editor_chunk, smoke_partition_openings, smoke_cold_view_vacuum, smoke_furniture, smoke_grid_scale_invariance, smoke_ha_controls, smoke_help_affordance, smoke_junction_limits, smoke_optimize_coordinate_canonicalization, smoke_room_resize, smoke_zero_wall_migration_unblocked) не прогонял: инструмент отметил их по _editorRuntime/_showToast — общеупотребимым идентификаторам, которые дифф не меняет по смыслу (диалог гарда лишь читает _editorRuntime/_onboardingRuntime для выбора рантайма и вызывает уже существующий _showToast; ни то, ни другое поведение не переопределено). Аналогично не прогонял 23 «слабые связи» по _spaceDialog, кроме перечисленных пяти, ближайших к затронутому коду. Полная матрица — предрелизная обязанность, не гейт этого ревью.

Не прогонял и почему

  • npm run golden:verify — ТЗ прямо оговаривает «golden: не задевается» (диалог не входит в golden-матрицу); дифф не трогает рендерер/геометрию — согласен, не прогонял;
  • python -m pytest tests_backend -q — 0 файлов custom_components/**/*.py в диффе;
  • node scripts/model-invariants.mjs — дифф не трогает рёбра комнат, толщину, layout, marker.space, open_spans;
  • performance-профили — ТЗ явно исключает demo/benchmark_backdrop_decode.mjs из перф-гейта CI (разовая калибровка, не регресс-гейт), в AC перфоманс не назван.

Проверка AC (все девять)

Прогнал юниты и смок сам, проверил, что зарегистрированные мутанты действительно красят smoke_backdrop_guard (адресно, не «весь набор»): node scripts/mutation-gate.mjs --id=backdrop-probe-always-safe, --id=backdrop-downscale-drops-alpha, --id=backdrop-hard-demoted-to-warn, --id=backdrop-phase2-falls-back-to-original — все четыре: «тест покраснел, как обязан». Рабочее дерево после каждого прогона чистое (mutation-gate работает во временном воркчасти).

AC Доказательство Статус
AC1 — warn до decode юнит-граница ±1 у WARN_DECODED_BYTES/HARD_DIMENSION; смок noDecodeBeforeChoice; мутант backdrop-probe-always-safe красит доказано
AC2 — уменьшенная копия 4096/aspect/alpha юнит downscaleDimensions; смок — реальный decode 6200→4096, alpha→PNG, opaque→JPEG; мутант backdrop-downscale-drops-alpha красит доказано
AC3 — «оригинал» байт-в-байт смок b64ParityWithLegacyLoop (не юнит — см. Low ниже) доказано, способом доказательства не тем, что назвало ТЗ
AC4 hard-фаза1 юнит HARD_DIMENSION+1→hard; смок «только Отмена», ноль decode; мутант backdrop-hard-demoted-to-warn красит доказано
AC4б hard-фаза2 смок: reject и вечный hang под тестовым таймаутом, тост, чистый staging, повторный выбор; мутант backdrop-phase2-falls-back-to-original красит доказано для честного пути, но см. M1 — есть путь, которым «отмена» не отменяет
AC5 — SVG байпас смок svgBypassesGuard/svgNoDecode; прочитано в коде: classifyPlanFile возвращает pass для ext==='svg' раньше вызова probeBackdrop доказано
AC6 — битые заголовки → unknown юнит: 11 враждебных фикстур (PNG/JPEG/WebP/GIF, обрезки, нулевые/гигантские значения) → все unknown; прочитано в renderBackdropGuard: ветка unknown не подставляет числа и не является hard (те же три кнопки, явный выбор обязателен) доказано
AC7 — транзакция не поменялась существующий, НЕ тронутый диффом demo/smoke_plan_upload_reject.mjs — прогнан, зелёный доказано наследуемым тестом
AC8 — EXIF-ориентация код передаёт imageOrientation:'from-image' в createImageBitmap — правдоподобно корректно (браузерный контракт), НО ни юнит, ни смок, ни явная запись «проверено чтением» этого не покрывают; grep -rn EXIF по test/**,demo/** — ноль совпадений; чек-лист docs/TESTING.md, добавленный этим же диффом, тоже не содержит пункта про EXIF не доказано — см. M3
AC9 — паритет FileReader то же, что AC3: доказано смоком, не юнитом доказано, способ доказательства расходится с ТЗ

Находки

Все три — Medium, все в скоупе задачи (правятся автором в этой же задаче, без отдельного issue). High нет.

M1 — отмена гард-диалога во время busy не отменяет применение

Файл: src/backdrop-pick.ts, функции original() (строки ~179–184) и reduced() (строки ~185–200).

И original(), и reduced() защищены от повторного клика флагом busy (if (host._backdropGuard?.busy) return;), но внешний <hp-dialog dismiss-on-scrim @hp-close=${() => close()}> не проверяет busy вовсе — ни ESC, ни клик по скриму. HpDialog._onKeyDown (src/hp-dialog.ts:395-406) закрывает верхний диалог по Escape безусловно, _onFallbackClick (src/hp-dialog.ts:433-435) — по клику на скрим тоже безусловно. Значит: пользователь может нажать «Загрузить уменьшенную копию» (busy=true, идёт decode/downscale), затем нажать Escape или кликнуть по фону — диалог гарда пропадает (_backdropGuard = null), но promise внутри reduced()/original() продолжает висеть в памяти и по завершении всё равно вызывает apply(payload), молча подставляя planFile в уже как будто отменённый диалог пространства.

Воспроизведено (одноразовый скрипт на базе demo/serve.mjs, не закоммичен): подменил window.createImageBitmap на промис, зависающий до явного release(); выбрал крупный JPEG → warn-диалог → клик «Загрузить уменьшенную копию» → дождался _backdropGuard.busy === true → отправил Escape в диалог гарда → _backdropGuard стал null, _spaceDialog.planFile в этот момент всё ещё null (ожидаемо) → вызвал release() → через секунду _spaceDialog.planFile заполнился уменьшенной копией, хотя диалог давно закрыт и пользователь не подтверждал результат. Итог прогона:

{"wasBusy": true, "guardDismissedWhileBusy": true,
 "planFileStillNullRightAfterDismiss": true, "planFileAppliedAfterDismiss": true}

Это не потеря сохранённого плана (Save остаётся отдельным явным шагом) и не проблема безопасности — но это ровно тот класс «тихого действия после отмены», который сама фича вводит как принцип для фазы 2 («молча грузить то, от чего пользователь только что отказался, нечестно» — спека, UX/hard). Тут по факту происходит симметричный случай: пользователь отказался (закрыл диалог), а операция всё равно применяется.

Не блокирует (Medium, не High): нет потери данных и нет обхода lock-инварианта; чинится одной проверкой перед apply (например, сверить, что host._backdropGuard всё ещё ссылается на тот же guard/токен, прежде чем коммитить payload, либо не давать dismiss-on-scrim/ESC работать, пока busy). Общий примитив hp-dialog не проверяет busy нигде — менять его глобально не обязательно, чинить локально в backdrop-pick.ts дешевле и безопаснее для остальных потребителей.

M2 — «одно число, один источник» нарушено для HARD_DIMENSION

Файл: src/backdrop-pick.ts:169 (renderBackdropGuard).

const body = hard
  ? host._t('backdrop.too_large_body', {
    w: probe.width ?? 0, h: probe.height ?? 0, limit: 16384,
  })

limit: 16384 — литерал, а не импортированная константа. HARD_DIMENSION уже экспортируется из backdrop-probe.ts и не импортирован в backdrop-pick.ts (в блоке импорта наверху файла есть DOWNSCALE_JPEG_QUALITY, DOWNSCALE_TARGET_PX, DOWNSCALE_TIMEOUT_MS, downscaleDimensions, probeBackdrop — HARD_DIMENSION в списке нет). Само ТЗ формулирует ровно тот принцип, который здесь нарушен: «Пороги... собраны В ОДНОМ модуле... полевая рекалибровка после жалоб = правка одного файла» (раздел «Честная оговорка протокола»). Сейчас правка одного файла (смена HARD_DIMENSION в backdrop-probe.ts) молча разойдётся с текстом диалога, который продолжит показывать старое число. Число не проверено ни одним тестом (юниты гарда/пробы не рендерят диалог, а смок не проверяет текст too_large_body на конкретное число — только заголовок и наличие кнопки «Отмена»).

Фикс — однострочный: добавить HARD_DIMENSION в импорт из ./backdrop-probe и передать limit: HARD_DIMENSION вместо литерала.

M3 — AC8 (EXIF-ориентация) не доказан ни тестом, ни явной записью

См. таблицу AC выше. Код (src/backdrop-pick.ts:114, createImageBitmap(state.file, { imageOrientation: 'from-image' })) делегирует поворот декодеру браузера, что само по себе разумно — но ТЗ называет это отдельным пронумерованным AC8 с обязательным способом доказательства «фикстура», и по PROCESS.md §2.7 «каждый AC либо доказан автотестом..., либо разобран по коду с явной записью "проверено чтением, не исполнением"». Ни то, ни другое не сделано: не нашёл ни одного упоминания EXIF/orientation в test/** или demo/** (grep -rn "exif" --include={test,demo}/** — единственное совпадение это сам комментарий в backdrop-pick.ts), а хендофф-комментарий автора, перечисляя, что покрыто тестами, EXIF не упоминает вовсе. Чек-лист docs/TESTING.md, добавленный этим же диффом, тоже не содержит строки про EXIF — то есть пробел не отмечен и на ручном треке.

Фикс — либо смок/юнит с реально повёрнутым JPEG (EXIF orientation tag ≠ 1) и проверкой, что downscaleDimensions получает уже скорректированные браузером bitmap.width/height (а не сырые SOF-числа), либо явная запись в ТЗ/хендоффе «AC8 проверено чтением: imageOrientation: 'from-image' — стандартный, широко поддерживаемый флаг Canvas API, поэтому корректность делегирована браузеру» — с этим согласился бы, но такой записи сейчас нет.

Low (не блокирует, только к сведению)

AC3/AC9 в ТЗ помечены «паритет-юнит», а по факту паритет base64 (b64ParityWithLegacyLoop) проверяется в demo/smoke_backdrop_guard.mjs, не в test/backdrop-probe.test.mjs. Это осознанный и правильный выбор: FileReader отсутствует в голом node --test (node -e "console.log(typeof FileReader)" → undefined), которым гоняется npm test (package.json: "test": "tsc ... && node --test test/*.test.mjs"), а проект не подключает jsdom/happy-dom. Проверять реальным браузерным раннером в смоке — не хуже, а местами честнее гипотетического юнита. Снимаю находку без правки, отмечаю только неточность формулировки ТЗ.

Что проверено и корректно (сверх таблицы AC)

  • Парсер заголовков (backdrop-probe.ts) — построчно прочитан: все извлечения байт (u32be/u16be/u24le/ascii) безопасны на выходе за границы массива (typed array возвращает undefined, арифметика даёт NaN/0, не исключение); все циклы обхода чанков/маркеров ограничены (hops < 64/< 256); PLAUSIBLE_MAX_SIDE = 1_000_000 отсекает переполнение при width*height*4 (максимум ~4e12, далеко от Number.MAX_SAFE_INTEGER) и явно ловит враждебный WebP VP8X (u24le+1 может достигать 16 777 216 — отсекается плаусибл-фильтром); security-раздел ТЗ подтверждён кодом и fuzz-таблицей юнитов;
  • новый fileToBase64 даёт байт-в-байт тот же результат, что старый ручной цикл (смок b64ParityWithLegacyLoop);
  • оба рантайма (houseplan-editor-runtime.ts, houseplan-onboarding-runtime.ts) используют один и тот же classifyPlanFile/renderBackdropGuard — гард не может разойтись между диалогом пространства и онбордингом, как и заявлено в ТЗ; _backdropGuard не рендерится, если не открыт ни один из лениво загруженных рантаймов — в eager View-граф ничего не попало (подтверждено бюджетом бандла: initial View +698 Б — это словарь i18n, не логика); input.value = '' в начале _pickPlanFile — подтверждено смоком (repickReopensGuard), что повторный выбор того же файла срабатывает;
  • транзакционный контракт (AC7) не тронут и не регрессировал — существующий смок зелёный без правок; i18n-паритет en/ru/de (в т.ч. незадокументированный в ТЗ ключ backdrop.reducing) проверен существующим общим тестом (test/i18n.test.mjs, сверка наборов ключей) — прошёл в составе npm test; несовпадение с перечнем ключей ТЗ — не находка (доп. ключ для текста кнопки в busy-состоянии, не влияет на корректность, наследуется как техническая деталь автора); 4 из 5 заявленных мутантов — новые записи в scripts/mutation-gate.mjs, прогнаны все и подтверждают, что smoke_backdrop_guard.mjs красится именно на тех регрессиях, которые он обязан ловить (5-й, «staging не чистится при отказе», — это существующий мутант транзакции, не новый, соответствует формулировке ТЗ «без изменений»); трейлеры коммита корректны (Issue: #39, User-Visible: yes), оба changelog отредактированы в этом же коммите (git show -s --format=full HEAD).

Чего не проверял

  • полную матрицу смоков (201 шт.) — не по AC этой задачи, предрелизная обязанность;
  • golden, pytest tests_backend, model-invariants, perf-профили — не относятся к диффу (см. таблицу выше и обоснование в ТЗ);
  • ручное тестирование на реальном планшете/WebView — вне цикла ревью по процессу; пороги калибровались десктопным Chromium с честной оговоркой в самом ТЗ, я эту оговорку не оспариваю (уже была предметом спек-ревью r1–r3);
  • demo/benchmark_backdrop_decode.mjs не запускал — калибровочный скрипт, не гейт, замер уже зафиксирован в тексте ТЗ.

Вердикт

Три Medium, все в скоупе, ноль High. M1 и M3 — не косметика: M1 — реальная, воспроизведённая гонка в новом коде (не путать с «не блокирует» — не блокирует именно потому что нет потери сохранённых данных, а не потому что находка слабая); M3 — пронумерованный AC без единого доказательства. M2 — однострочный фикс, но ровно тот класс дефекта, который процесс просит ловить целенаправленно.

Вердикт: жёлтый · заход r1 · блокирующих циклов 1/4 · High: 0 · Medium: 3 → в задаче