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>
7.5 KiB
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.ktdeclares one@Testper surface inGallerySurfaces.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.swifthas 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
zopflipngpost-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:
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:
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
# 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:
- Check whether the screen really needs a full-viewport gradient.
- If yes, consider rendering at
displayScale: 1.0for just that test (thesnaphelper accepts an override).
Tool installation
The optimizer script needs one of:
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:
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 readDataManagerObservable.shareddirectly rather than accepting an injectedIDataManager. 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 unlessGallerySurfaces.ktpasses a fixture item. Expanding these to full coverage is a follow-up PR — low-risk additions toGallerySurfaces.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 setsoutputDirto match. IfverifyRoborazziDebugever reports "original file not found", confirm theoutputDirhasn't drifted.