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

275 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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;`), но внешний `<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` заполнился уменьшенной
копией, хотя диалог давно закрыт и пользователь не подтверждал результат.
Итог прогона:
```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 → в задаче**