P4: HTML parity gallery generator + comprehensive docs
scripts/build_parity_gallery.py walks both golden directories and pairs Android↔iOS PNGs by filename convention into docs/parity-gallery.html — a self-contained HTML file with relative <img> paths that renders directly from gitea's raw-file view (no server needed). Current output: 34 screens × 71 Android + 58 iOS images, grouped per screen with sticky headers and per-screen anchor nav. docs/parity-gallery.md: full workflow guide — verify vs record, adding screens to both platforms, approving intentional drift, tool install, size budget, known limitations. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
181
docs/parity-gallery.md
Normal file
181
docs/parity-gallery.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# Parity gallery — iOS ↔ Android snapshot regression
|
||||
|
||||
Every primary screen on both platforms is captured as a PNG golden and
|
||||
committed to the repo. A PR that drifts from a golden fails CI. The
|
||||
committed `docs/parity-gallery.html` pairs iOS and Android side-by-side in
|
||||
a scrollable HTML grid you can open locally or from gitea's raw-file view.
|
||||
|
||||
## Quick reference
|
||||
|
||||
```
|
||||
make verify-snapshots # PR gate; fast. Both platforms diff against goldens.
|
||||
make record-snapshots # Regenerate everything + optimize. Slow (~5 min).
|
||||
make optimize-goldens # Rerun zopflipng over existing PNGs. Idempotent.
|
||||
python3 scripts/build_parity_gallery.py # Rebuild docs/parity-gallery.html
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
### Shared fixtures
|
||||
`composeApp/src/commonMain/kotlin/com/tt/honeyDue/testing/FixtureDataManager.kt`
|
||||
exposes `.empty()` and `.populated()` factories. Both platforms render the
|
||||
same screens against the same fixture graph — the only cross-platform
|
||||
differences left are actual UI code differences (by design). Fixtures use
|
||||
a fixed clock (`Fixtures.FIXED_DATE = LocalDate(2026, 4, 15)`) so dates
|
||||
never drift.
|
||||
|
||||
### Android capture (Roborazzi)
|
||||
- `composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/screenshot/ScreenshotTests.kt`
|
||||
declares one `@Test` per surface in `GallerySurfaces.kt`.
|
||||
- Each test captures 4 variants: `empty × light`, `empty × dark`,
|
||||
`populated × light`, `populated × dark`.
|
||||
- Runs in Robolectric — no emulator needed, no flake from animations.
|
||||
- Goldens: `composeApp/src/androidUnitTest/roborazzi/<screen>_<state>_<mode>.png`
|
||||
- Typical size: 30–80 KB per image.
|
||||
|
||||
### iOS capture (swift-snapshot-testing)
|
||||
- `iosApp/HoneyDueTests/SnapshotGalleryTests.swift` has 4 tests per screen.
|
||||
- Rendered at `displayScale: 2.0` (not the native 3.0) to cap per-image size.
|
||||
- Uses `FixtureDataManager.shared.empty()` / `.populated()` via SKIE.
|
||||
- Goldens: `iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_<name>.<variant>.png`
|
||||
- Typical size: 150–300 KB per image after `zopflipng` post-processing.
|
||||
|
||||
### Record-mode trigger
|
||||
Both platforms record only when explicitly requested:
|
||||
- Android: `./gradlew :composeApp:recordRoborazziDebug`
|
||||
- iOS: `SNAPSHOT_TESTING_RECORD=1 xcodebuild test …`
|
||||
|
||||
`make record-snapshots` does both, plus runs `scripts/optimize_goldens.sh`
|
||||
to shrink the output PNGs. No code edits required to switch between record
|
||||
and verify — the env var / gradle task controls everything.
|
||||
|
||||
## When to record vs verify
|
||||
|
||||
**Verify** is what CI runs on every PR. It is the gate. If verify fails,
|
||||
ask: *was this drift intentional?*
|
||||
|
||||
**Record** is what you run locally when a UI change is deliberate and you
|
||||
want to publish the new look as the new baseline. Commit the regenerated
|
||||
goldens alongside your code change so reviewers see both the code and the
|
||||
visual result in one PR.
|
||||
|
||||
Running record by mistake (on a branch where you didn't intend to change
|
||||
UI) will produce a large image-diff in `git status`. That diff is the
|
||||
signal — revert the goldens, investigate what unintentionally changed.
|
||||
|
||||
## Adding a screen to the gallery
|
||||
|
||||
### Android
|
||||
Add one entry to
|
||||
`composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/screenshot/GallerySurfaces.kt`:
|
||||
|
||||
```kotlin
|
||||
GallerySurface("my_new_screen") { MyNewScreen(onNavigateBack = {}, /* required params from fixtures */) },
|
||||
```
|
||||
|
||||
If the screen needs a specific model (`task`, `residence`, etc.) pass one
|
||||
from `Fixtures.*` — e.g. `Fixtures.tasks.first()`. If the screen renders
|
||||
differently in empty vs populated, the `LocalDataManager` provider wiring
|
||||
in `ScreenshotTests.kt` handles it automatically.
|
||||
|
||||
### iOS
|
||||
Add 4 test functions to `iosApp/HoneyDueTests/SnapshotGalleryTests.swift`:
|
||||
|
||||
```swift
|
||||
func test_myNewScreen_empty_light() { snap("my_new_screen_empty_light", empty: true, dark: false) { MyNewView() } }
|
||||
func test_myNewScreen_empty_dark() { snap("my_new_screen_empty_dark", empty: true, dark: true) { MyNewView() } }
|
||||
func test_myNewScreen_populated_light() { snap("my_new_screen_populated_light", empty: false, dark: false) { MyNewView() } }
|
||||
func test_myNewScreen_populated_dark() { snap("my_new_screen_populated_dark", empty: false, dark: true) { MyNewView() } }
|
||||
```
|
||||
|
||||
Then `make record-snapshots` to generate goldens, `git add` the PNGs
|
||||
alongside your test changes.
|
||||
|
||||
## Approving intentional UI drift
|
||||
|
||||
```bash
|
||||
# 1. Regenerate goldens against your new UI.
|
||||
make record-snapshots
|
||||
|
||||
# 2. Review the PNG diff — did only the intended screens change?
|
||||
git status composeApp/src/androidUnitTest/roborazzi/ iosApp/HoneyDueTests/__Snapshots__/
|
||||
git diff --stat composeApp/src/androidUnitTest/roborazzi/ iosApp/HoneyDueTests/__Snapshots__/
|
||||
|
||||
# 3. Stage and commit alongside the UI code change.
|
||||
git add <screen-file.kt> <SnapshotGalleryTests.swift changes> \
|
||||
composeApp/src/androidUnitTest/roborazzi/ \
|
||||
iosApp/HoneyDueTests/__Snapshots__/
|
||||
git commit -m "feat: <what changed>"
|
||||
```
|
||||
|
||||
Reviewers see the code diff AND the golden diff in one PR — makes intent
|
||||
obvious.
|
||||
|
||||
## Image size budget
|
||||
|
||||
Per-file soft budget: **400 KB**. Enforced by CI.
|
||||
|
||||
Android images are rarely this large. iOS images can exceed 400 KB for
|
||||
gradient-heavy screens (Onboarding welcome, organic blob backgrounds).
|
||||
If a new screen exceeds budget:
|
||||
1. Check whether the screen really needs a full-viewport gradient.
|
||||
2. If yes, consider rendering at `displayScale: 1.0` for just that test
|
||||
(the `snap` helper accepts an override).
|
||||
|
||||
## Tool installation
|
||||
|
||||
The optimizer script needs one of:
|
||||
```bash
|
||||
brew install zopfli # preferred — better compression
|
||||
brew install pngcrush # fallback
|
||||
```
|
||||
|
||||
Neither installed? `make record-snapshots` warns and skips optimization —
|
||||
goldens are still usable, just larger.
|
||||
|
||||
## HTML gallery
|
||||
|
||||
`docs/parity-gallery.html` is regenerated by
|
||||
`scripts/build_parity_gallery.py` whenever goldens change. It's a
|
||||
self-contained HTML file with relative `<img>` paths that resolve within
|
||||
the repo — so gitea's raw-file view renders it without any server.
|
||||
|
||||
To view locally:
|
||||
```bash
|
||||
python3 scripts/build_parity_gallery.py
|
||||
open docs/parity-gallery.html
|
||||
```
|
||||
|
||||
The gallery groups by screen name. Each row shows Android vs iOS for one
|
||||
{state, mode} combination, with sticky headers for quick navigation.
|
||||
|
||||
## Current coverage
|
||||
|
||||
Written to the output on each regeneration — check the top of
|
||||
`docs/parity-gallery.html` for the current count.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **iOS populated-state coverage is partial**. Swift Views today instantiate
|
||||
their ViewModels via `@StateObject viewModel = FooViewModel()`; the
|
||||
ViewModels read `DataManagerObservable.shared` directly rather than
|
||||
accepting an injected `IDataManager`. Until ViewModels gain a DI seam,
|
||||
populated-state snapshots require per-screen ad-hoc workarounds.
|
||||
Tracked as a follow-up.
|
||||
|
||||
- **Android detail-screen coverage is partial**. Screens that require a
|
||||
pre-selected model (`ResidenceDetailScreen(residence = ...)`,
|
||||
`ContractorDetailScreen(contractor = ...)`) silently skip rendering
|
||||
unless `GallerySurfaces.kt` passes a fixture item. Expanding these to
|
||||
full coverage is a follow-up PR — low-risk additions to
|
||||
`GallerySurfaces.kt`.
|
||||
|
||||
- **Cross-platform diff is visual, not pixel-exact**. SF Pro (iOS) vs
|
||||
SansSerif (Android) render different glyph shapes by design. Pixel-diff
|
||||
is only used within a platform — the HTML gallery is for side-by-side
|
||||
human review.
|
||||
|
||||
- **Roborazzi path mismatch**. The historical goldens lived at
|
||||
`composeApp/src/androidUnitTest/roborazzi/`. The Roborazzi Gradle block
|
||||
sets `outputDir` to match. If `verifyRoborazziDebug` ever reports
|
||||
"original file not found", confirm the `outputDir` hasn't drifted.
|
||||
Reference in New Issue
Block a user