Files
houseplan-card/docs/reviews/CODE-REVIEW-39-r2.md
T
2026-08-29 07:23:36 +00:00

168 lines
16 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-r2
- Issue: [#39 «[HP-UX-09] большие подложки»](https://github.com/Matysh/houseplan-card/issues/39)
- ТЗ: `docs/specs/039-large-backdrops.md`, ревизия 4.1 (спек-ревью зелёное на r3)
- Предыдущий раунд: `docs/reviews/CODE-REVIEW-39-r1.md`, вердикт **жёлтый**, заход r1,
ревью-SHA `0d82ee0a` (этот SHA отсутствует в текущей истории — см. ниже).
- Ревью-SHA этого раунда: `7c45372c32e89f143845445fecd02936c261df5a` (`git rev-parse HEAD`
сверен непосредственно перед выводом вердикта)
- Заход: **r2** · блокирующих циклов до этого разбора: **1/4** (трек — полный, лимит 4;
зелёный вердикт r1 не образовал бы цикл, но r1 был жёлтым — цикл потрачен)
- Диапазон дельты: `git diff 7c31725a..7c45372c` (см. примечание про SHA ниже)
## Примечание про несовпадающий SHA из r1
Документ r1 называет ревью-SHA `0d82ee0a`, которого в текущей истории нет
(`git cat-file -t 0d82ee0a` → `fatal: Not a valid object name`). Это находка
по инструкции («SHA в вердикте не назван / не воспроизводится — тоже
находка»), но не блокирующая: `origin/dev` сейчас указывает на `4d737747`, и
его прямой родитель — коммит `7c31725a` («feat: warn about huge backdrops…»),
который побайтово совпадает по содержанию с тем, что описывает r1 (тот же
diffstat «50 files, +1331/-371», то же сообщение коммита, тот же список
файлов). Иными словами: `7c31725a` — это `0d82ee0a` после переписывания
(git commit hash сменился, дерево — нет), и ветка уже стоит на актуальном
`dev` без дополнительного ребейза (`git merge-base origin/dev 7c31725a` ==
вершина `origin/dev`). Это не ребейз «на ушедший вперёд dev» в смысле §7.2 —
код после `0d82ee0a` не менялся, менялся только адрес коммита. Дельта этого
раунда поэтому корректно считается как `7c31725a..7c45372c`, что совпадает
с единственным новым коммитом в `git log --oneline origin/dev..HEAD` (не
считая docs-коммита публикации `e84af749`).
## Скоуп дельты
Один продуктовый коммит `7c45372c` поверх зелёного `7c31725a`:
- `src/backdrop-pick.ts` (+13/-4) — правки M1 и M2;
- `demo/smoke_backdrop_guard.mjs` (+77) — новые сценарии для M1 и M3;
- `scripts/mutation-gate.mjs` (+17) — новый мутант для M1, обновлён `find`
существующего мутанта под изменившийся текст того же catch-блока;
- `docs/TESTING.md` (+5) — чек-лист для EXIF-сценария;
- `docs/images/01-view-desktop.png` + `docs/images/screenshots.json` —
пересчитан отпечаток документации (класс D/автоген, ожидаемо после правки
`src/**`);
- `docs/reviews/CODE-REVIEW-39-r1.md` — сам документ r1 (класс C, добавлен
шагом публикации, не автором).
Геометрии, `custom_components/**/*.py`, конфиг-миграций дельта не касается —
model-invariants и backend-гейт по-прежнему не относятся к этому диффу.
## Как проверялось (гейты)
| Гейт | Команда | Результат |
|---|---|---|
| typecheck | `npx tsc --noEmit` | чисто |
| unit | `npm test` | `# tests 1523 / pass 1522 / fail 0 / skipped 1` (было 1518/1517 на r1; разница объясняется новым мутантом — `test/mutation-gate.test.mjs` итерирует реестр `MUTANT_DEFINITIONS` и генерирует по нему подтесты, отдельный тестовый файл руками не трогали) |
| 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 385 / 282 000 Б gzip, запас 8 615 Б |
| docs fingerprint | `node scripts/check-docs.mjs` (дельта трогает `src/**`) | «Documentation checks passed (7 files, 10 external links)» |
| smoke-select (по дельте) | `node scripts/smoke-select.mjs --base 7c31725a --head HEAD` | 1 «прямое совпадение»: `smoke_backdrop_guard.mjs` (← `_backdropGuard`); других смоков дельта не задевает — локализована в `backdrop-pick.ts` |
| smoke (адресный, из выборки) | `node demo/smoke_backdrop_guard.mjs` | все 27 ассертов зелёные, включая новые `busyDismissIgnored`, `staleFlowNeverApplies`, `exifFixtureRotates`, `exifProbeReadsSof`, `exifReduceApplied`, `exifOptionPassed`, `exifReducedRotated`; плюс переподтверждены не тронутые дельтой `phase2RejectToast/phase2RejectCleanStaging/phase2TimeoutToast/phase2CleanStaging` (AC4б) |
| mutation gate (адресно, новый id) | `node scripts/mutation-gate.mjs --id=backdrop-busy-dismiss-races-decision` | «тест покраснел, как обязан»; рабочее дерево чистое после прогона (воркчасть временная) |
### Не прогонял и почему
- полную матрицу 201 смока — предрелизная обязанность, не гейт ревью;
`smoke-select` по дельте называет только один прямой смок, дельта локальна;
- `npm run golden:verify`, `python -m pytest tests_backend`,
`node scripts/model-invariants.mjs`, perf-профили — дельта не трогает
рендерер/геометрию/`custom_components/**/*.py`/перф-пути; то же обоснование,
что в r1, дельта его не меняет;
- `demo/benchmark_backdrop_decode.mjs` — калибровочный скрипт, не гейт, не
тронут дельтой.
## Закрытие раунда r1
| Находка r1 | Чем закрыта | Где это видно |
|---|---|---|
| **M1** — отмена гард-диалога во время busy не отменяет применение | `dismiss()` теперь игнорирует Escape/скрим/Cancel, пока `busy`; `original()`/`reduced()` (обе ветки — успех и catch) дополнительно проверяют `stillCurrent()` перед `apply()`, так что даже принудительно снесённый гард не подставит устаревший результат | `src/backdrop-pick.ts:179-186` (`dismiss`, `stillCurrent`), `:190` (`original`), `:201` и в catch-блоке (`reduced`); новый мутант `backdrop-busy-dismiss-races-decision` в `scripts/mutation-gate.mjs` красит `smoke_backdrop_guard.mjs`, прогнан и подтверждён; смок-ассерты `busyDismissIgnored`/`staleFlowNeverApplies` — оба `true` в реальном прогоне |
| **M2** — «одно число, один источник» нарушено для `HARD_DIMENSION` | Литерал `16384` заменён на импортированную `HARD_DIMENSION` из `backdrop-probe.ts` — дублирования числа больше не существует физически, рекалибровка снова правка одного файла | `src/backdrop-pick.ts:10` (импорт), `:171` (`limit: HARD_DIMENSION`) |
| **M3** — AC8 не доказан ни тестом, ни записью | Новый смок-блок собирает настоящий JPEG с APP1/EXIF (orientation 6), проверяет, что probe читает неповёрнутые SOF-размеры (8200×4100), что `createImageBitmap` реально получает `imageOrientation:'from-image'` (перехвачено хуком), и что итоговая уменьшенная копия — портрет 2048×4096, а не пейзаж | `demo/smoke_backdrop_guard.mjs` (блок «AC8: EXIF-ориентация»); `docs/TESTING.md` — новая строка чек-листа со ссылкой `[auto: smoke_backdrop_guard]`; прогнан, `exifProbeReadsSof`/`exifOptionPassed`/`exifReducedRotated` — все `true` |
Все три Medium-находки r1 закрыты кодом и тестом, не только заявлением автора.
## Унаследовано из r1
Без повторной проверки принято (документ `docs/reviews/CODE-REVIEW-39-r1.md`,
SHA `0d82ee0a` / содержимое идентично `7c31725a`, см. примечание выше) —
дельта этих доказательств не касается:
- **AC1** (warn до decode), **AC2** (уменьшенная копия 4096/aspect/alpha),
**AC5** (SVG байпас), **AC6** (битые заголовки → unknown) — код, который их
доказывает (`backdrop-probe.ts`, ветки `classifyPlanFile`/`renderBackdropGuard`
вне тронутых строк), дельтой не задет;
- **AC7** (транзакция не поменялась) — доказывается нетронутым
`demo/smoke_plan_upload_reject.mjs`, дельта его не касается;
- **AC3/AC9** (байт-в-байт паритет «оригинала» и `FileReader`) — доказаны
смоком `b64ParityWithLegacyLoop`, не юнитом; расхождение со словом
«юнит» в ТЗ снято в r1 как Low без правки, дельта эту часть не трогает;
- построчный разбор `backdrop-probe.ts` на безопасность (границы typed
array, ограничение циклов, `PLAUSIBLE_MAX_SIDE`, WebP VP8X) — файл не
входит в дельту;
- согласованность обоих рантаймов (`houseplan-editor-runtime.ts`,
`houseplan-onboarding-runtime.ts`) с одним `classifyPlanFile` — не тронуты;
- i18n-паритет en/ru/de, бюджет бандла (initial View, где i18n-словарь гарда
не в eager-графе View) — числа пересчитаны в этом раунде (см. таблицу
гейтов) и совпадают по порядку величины с r1, отдельно не переразбирались;
- трейлеры исходного коммита `7c31725a` (`Issue: #39`, `User-Visible: yes`,
оба changelog в том же коммите) — не тронуты этим раундом.
**AC4б** (честная фаза 2 — reject/hang → тост, чистый staging) формально
задета дельтой (правка внутри `reduced()`), поэтому передоказана заново в
этом раунде, а не унаследована молча — см. таблицу гейтов
(`phase2RejectToast`/`phase2RejectCleanStaging`/`phase2TimeoutToast`/
`phase2CleanStaging`, все `true`).
**AC8** передоказан заново (был «не доказано» в r1, теперь «доказано») —
см. таблицу «Закрытие раунда r1», M3.
## Находки этого раунда
Новых High и Medium нет.
### L1 — два из трёх новых `stillCurrent()`-чеков не имеют собственного мутанта
**Файл:** `src/backdrop-pick.ts:190` (`original()`) и `:201`
(успешная ветка `reduced()`, до входа в `catch`).
Зарегистрированный мутант `backdrop-busy-dismiss-races-decision` бьёт только
по busy-гейту в `dismiss()`. Существующий мутант, чей `find` обновили под
новую строку, целится в `catch`-ветку `reduced()` (проверяет другое — что
там нет молчаливого отката к оригиналу, а не что там есть `stillCurrent()`)
— но по факту его текст теперь включает и новую строку `stillCurrent()`,
так что *эта* ветка фактически покрыта смоком: `staleFlowNeverApplies`
специально форсирует `_backdropGuard = null` перед тем, как «зависший»
decode обязан упасть по таймауту и попасть в `catch` — я прочитал
`downscaleBackdrop` (`src/backdrop-probe.ts` импорт, `Promise.race` с
`setTimeout(...reject('decode timeout'))`, `src/backdrop-pick.ts:115-120`)
и подтвердил, что режим `hang` + `__HP_BACKDROP_TIMEOUT_MS` гарантированно
уходит в `catch`, никогда в успех — то есть `staleFlowNeverApplies`
реально проверяет именно `catch`-ветку `stillCurrent()`.
Непроверенными остаются ровно два места: `stillCurrent()` в `original()`
(строка 190) и в успешной ветке `reduced()` (строка 201, между
`encodePlanFile` и `apply`) — ни один текущий сценарий не форсирует гард в
`null` во время настоящего (не зависающего) decode/encode, так что при
разрешении промиса код всегда попадает в них с `stillCurrent() === true`.
Прочитал обе строки: логика идентична уже проверенной (`host._backdropGuard
?.file === guard.file`), копипаст-ошибки не вижу — оцениваю риск как
низкий.
**Не блокирует** (Low, не Medium): поведенческого дефекта не нашёл, это
пробел в глубине доказательства, а не в коде. Не правлю и не завожу
отдельной находкой правку — фиксирую с запиской для истории; если решится
чинить, самый дешёвый путь — расширить `smoke_backdrop_guard.mjs` сценарием
«клик Keep original → форс-снести гард → убедиться, что planFile не
проставился», аналогично уже существующему для `reduced()`.
## Вердикт
Три Medium из r1 закрыты кодом и подтверждены прогоном (не заявлением
автора): M1 — новым мутантом и двумя смок-ассертами, M2 — устранением самого
дублирования числа, M3 — новым end-to-end EXIF-сценарием. Новых
High/Medium нет. Одна Low-находка (L1) — пробел в глубине покрытия двух из
трёх защитных строк, не поведенческий дефект — зафиксирована без правки.
Все дешёвые гейты и адресованные тяжёлые гейты (smoke по выборке,
мутация по новому id) прогнаны лично на SHA `7c45372c` и зелёные.
**Вердикт: зелёный · заход r2 · блокирующих циклов 1/4 · High: 0 · Medium: 0**