# CODE-REVIEW-39-r1 - Issue: [#39 «[HP-UX-09] большие подложки»](https://github.com/Matysh/houseplan-card/issues/39) - ТЗ: `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;`), но внешний ` 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` заполнился уменьшенной копией, хотя диалог давно закрыт и пользователь не подтверждал результат. Итог прогона: ```json {"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`). ```ts 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 → в задаче**