Files
houseplan-card/docs/specs/424-capture-determinism.md
T
2026-09-02 21:59:43 +03:00

199 lines
14 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.
# ТЗ #424 — Съёмка документации перестаёт зависеть от прогона
- Issue: https://github.com/Matysh/houseplan-card/issues/424
- Приоритет: P2, bug + infra; полный трек — одна поверхность (съёмка
документации), но правка меняет содержимое всех закоммиченных кадров, а это
требует объявленного решения, а не молчаливой пересъёмки. Класс файлов — B
(`demo/**`, `test/**`) плюс C/D (`docs/images/**`)
- Ревизия: 1 (2026-09-02)
## Сценарий
Приёмка скриншотов построена на одном правиле: каждый необъявленный кадр обязан
совпасть с закоммиченным **байт-в-байт**. Пока съёмка недетерминирована, правило
означает случайность — «изменились три кадра» может значить и правку продукта, и
невезение на прогоне. Порог кадров-свидетелей (#408/#409) опирается на ту же
посылку: свидетель — необъявленный кадр, совпавший байт-в-байт.
Проверить это стало возможно только после #422: кросс-прогонный гейт покраснел в
первый же прогон на `dev`.
## Что человек увидит до и после
Видимого поведения продукта задача не меняет.
**До**: два прогона съёмки на одном коммите дают разные кадры (в CI разошлись
`01-view-desktop`, `03-space-create`, `06-device-editor`; локально — те же
хеши). Приёмка при этом молчит, потому что сравнивает с закоммиченным, а не
прогоны между собой.
**После**: восемь прогонов подряд дают один и тот же набор; «кадр изменился»
снова означает «изменился продукт».
## Проблема и контракт
### Что именно расходится
Попиксельное сравнение двух вариантов `06-device-editor` (1180×1100): 76
пикселей, максимум 2 уровня, четыре кластера по краям двух элементов шириной
466 и высотой 31 — скруглённые углы двух `select.areasel`, стоящих на
полупиксельной границе (`y = 433.5` и `496.5`, `border-radius: 6px`).
Расходится один-два случайных кадра из десяти, каждый раз разных, а внутри
процесса всё стабильно: `capture.mjs --stability=3` зелёный. То есть страница
приходит в один из нескольких **устойчивых** исходов ещё до снимка — это не
«не успело устояться».
### Чем это не оказалось (проверено исполнением)
- **не CSS-переходы**: гашение `transition`/`animation` инъекцией во все shadow
root стабилизирует `06`, но дрейф переезжает на `06-device-display-preview`,
затем на `01-view-desktop`;
- **не шрифты**: ожидание `document.fonts.ready` после применения состояния и
ожидание пустого `getAnimations({subtree:true})` дрейф не убирают;
- **не `select`**: со скрытыми `select` `06` стабилен, но дрейф уходит на
соседний кадр;
- **не упаковщик**: расхождение сохраняется при полностью отключённом `oxipng`;
- **не кэш шрифтов**: после `rm -rf ~/.cache/fontconfig` расходится не первый
прогон, а произвольный;
- **не обрезка**: `08-room-card`, ради которого вводили целочисленный клип
(#410), стабилен во всех прогонах.
### Причина
Композитор Chromium. Два его свойства делают кадр зависимым от истории прогона:
**частичная растеризация** переиспользует ранее нарисованные куски тайла, а
снимок берётся **до завершения всех стадий композитора**.
**Контракт**: съёмка документации фиксирует состояние композитора так, чтобы
кадр зависел только от коммита. Достигается двумя флагами запуска в
`DETERMINISTIC_ARGS` (`demo/docs/capture.mjs`):
```
'--run-all-compositor-stages-before-draw', '--disable-partial-raster'
```
Подбор проверен перебором, агрегированный хеш всех десяти кадров за восемь
прогонов:
| Конфигурация | Результат |
|---|---|
| как сейчас | 3+ варианта |
| `--num-raster-threads=1` | не помогает |
| `--disable-gpu` | не помогает |
| только `--run-all-compositor-stages-before-draw` | 2 варианта |
| `run-all` + `--num-raster-threads=1` | 3 варианта |
| **`run-all` + `--disable-partial-raster`** | **один хеш ×8** |
Оба флага обязательны: каждый по отдельности дрейф не убирает. Это и есть
причина, по которой решение закрепляется тестом, а не комментарием.
### Почему golden не затронут
`demo/golden/run.mjs` сравнивает с допуском (`maxChannelDelta: 10`,
`maxDiffRatio: 0.0005` для сцены), а наш дрейф — 2 уровня на 76 пикселях из
1,3 млн, то есть 0,006% при пороге 0,05%. Проверено: `npm run golden:verify`
проходит целиком (153 сценария) в обоих прогонах. Менять golden эта задача не
требует; если его когда-нибудь переведут на побайтовое сравнение, флаги
понадобятся и там — это записано здесь, а не оставлено на догадку.
## Скоуп / не-скоуп
**В скоупе**: флаги запуска (вынесенные в `demo/docs/browser-args.mjs` и
используемые `capture.mjs`), контрактный тест на них, мутанты, разовая
пересъёмка и приёмка десяти кадров, строка в `docs/TESTING.md`.
**Не в скоупе**: `demo/golden/**` (сравнение с допуском, дрейф ниже порога);
кросс-прогонный гейт (#422 — мержится вместе с этой задачей); полупиксельная
позиция `select.areasel` в продукте (симптом, а не причина: дрейф переезжает на
другие кадры при её устранении).
## UX, модель данных, i18n
Не применимо.
## Критерии приёмки
- **AC1**. Восемь прогонов съёмки подряд на одном коммите дают один и тот же
набор кадров. Доказательство: агрегированный хеш `docs/images/*.png` совпал
восемь раз из восьми; отдельно — кросс-прогонный гейт `#422` зелёный.
- **AC2**. Решение закреплено тестом, а не комментарием: контрактный тест
требует обоих флагов в `DETERMINISTIC_ARGS` и объясняет, почему их два.
Доказательство: тест в `test/`.
- **AC3**. **Отрицательный прогон обязателен**: снятие любого из двух флагов
роняет контрактный тест. Доказательство: два мутанта в
`scripts/mutation-gate.mjs`, прогнанные штатным раннером.
Мутант проверяет именно тест, а не съёмку: дрейф стохастический (один-два
кадра из десяти, не каждый прогон), и мутант, зависящий от везения, был бы
флейковым — то есть худшим видом гейта.
- **AC4**. Кадры пересняты в каноническом окружении и приняты штатной
процедурой; манифест содержит новый отпечаток скрипта. Доказательство:
артефакт прогона «Скриншоты документации» и `npm run docs:accept`.
- **AC5**. Съёмка не стала медленнее более чем на **15%**:
`--disable-partial-raster` выключает оптимизацию перерисовки, и это могло бы
стоить времени. Порог, а не «заметно»: критерий без числа не умеет
провалиться. Доказательство: время полного прогона `capture.mjs` до и после,
по два замера. Предварительно измерено на этапе ТЗ — 11 711 / 11 595 мс без
флагов против 11 589 / 11 567 мс с флагами, то есть разница в пределах шума;
если на канонической машине окажется иначе, порог и есть та граница, за
которой решение пересматривается.
- **AC6**. `golden:verify` проходит без правок эталонов. Доказательство: полный
прогон 153 сценариев.
## План автотестов
**Как тест доберётся до констант.** `capture.mjs` — исполняемый скрипт: с
строки 247 он безусловно поднимает Chromium, поэтому импортировать его в тест
значит запустить съёмку внутри `npm test` (сегодня ни один тест браузер не
поднимает, и нарушать это нельзя). Набор флагов выносится в чистый модуль
`demo/docs/browser-args.mjs`, откуда `capture.mjs` его импортирует — тот же
приём и по той же причине, что вынос `clip.mjs` в #422. Текстовый разбор файла
через `readFileSync` (прецеденты — `test/smoke-harness-contract.test.mjs`,
`test/golden-matrix.test.mjs`) тоже подошёл бы, но он проверяет строку, а не
значение, и переживает опечатку в имени флага.
**Юнит** (`test/capture-determinism-args.test.mjs`):
1. `DETERMINISTIC_ARGS` из `demo/docs/browser-args.mjs` содержит оба флага
(AC2, AC3).
2. Флаги перечислены вместе с уже принятыми (`--force-color-profile=srgb`,
`--font-render-hinting=none`, `--disable-lcd-text`) — набор не разъезжается.
3. `capture.mjs` берёт набор из этого модуля, а не объявляет свой: иначе тест
стерёг бы константу, которой никто не пользуется.
**Мутанты** (`scripts/mutation-gate.mjs`):
- `capture-allows-partial-raster`: убрать `--disable-partial-raster` → тест
красный;
- `capture-draws-before-compositor-settles`: убрать
`--run-all-compositor-stages-before-draw` → тест красный.
**Проверка реальностью**: кросс-прогонный гейт из #422 — он и есть измерение
AC1; в конвейере оба изменения оказываются одновременно.
## Риски
- **Флаги — деталь реализации Chromium и могут исчезнуть в будущей версии.**
Смягчение: версия Chromium в проекте закреплена; исчезновение флага проявится
как падение кросс-прогонного гейта, то есть громко, а не молча.
- **`--disable-partial-raster` замедляет съёмку.** Смягчение: AC5 требует
замера; десять сценариев — не самая долгая часть конвейера.
- **Разовая пересъёмка меняет все десять кадров сразу.** Это тот самый случай,
который вердикт конвейера называет «изменилось окружение съёмки»; смягчение:
приёмка идёт штатной командой с явным решением человека, а порог свидетелей
для неё обходится через `--no-witnesses --reason`, как и задумано.
- **Дрейф может вернуться из другого источника.** Смягчение: он теперь
измеряется на каждом прогоне съёмки (#422), а не обнаруживается через месяц
по странной приёмке.
## Откат
Снятие двух флагов возвращает прежнее поведение; кадры при этом придётся
переснять обратно. Продуктовый код не затрагивается.
## Release-артефакты
- `docs/CHANGELOG.md` / `docs/CHANGELOG.ru.md`: **не требуется**
(User-Visible: no).
- `docs/TESTING.md`: строка о том, почему съёмка запускается с этими флагами.
- `docs/images/**`: разовая пересъёмка всех десяти кадров.