mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
docs: restore the capture determinism spec lost in the rebase (#424)
User-Visible: no Issue: #424
This commit is contained in:
@@ -0,0 +1,198 @@
|
|||||||
|
# ТЗ #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/**`: разовая пересъёмка всех десяти кадров.
|
||||||
Reference in New Issue
Block a user