# Parity gallery — iOS ↔ Android snapshot regression Every user-reachable screen in the HoneyDue app is captured as a PNG golden on both platforms and committed to the repo. A PR that drifts from a golden fails CI. The gallery HTML (`docs/parity-gallery.html`) pairs iOS and Android renders side-by-side so cross-platform UX divergences are visible at a glance. Gaps — screens captured on one platform but not the other — render as explicit red-bordered `[missing — android]` / `[missing — ios]` placeholders rather than silently omitted rows, so the work to close them is obvious. ## 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 ``` ## Canonical manifest — the single source of truth Every screen in the gallery is declared once in `composeApp/src/commonMain/kotlin/com/tt/honeyDue/testing/GalleryManifest.kt`. The manifest is a `commonMain` Kotlin object — readable from both platforms, via SKIE from Swift — listing each screen's canonical name, category, and which platforms capture it: ```kotlin GalleryScreen("contractor_detail", GalleryCategory.DataCarrying, both) GalleryScreen("login", GalleryCategory.DataFree, both) GalleryScreen("home", GalleryCategory.DataCarrying, androidOnly) GalleryScreen("profile_edit", GalleryCategory.DataFree, iosOnly) ``` Two parity tests keep the platforms aligned with the manifest: - `composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/screenshot/GalleryManifestParityTest.kt` fails if the entries in `GallerySurfaces.kt` don't match the subset of the manifest with `Platform.ANDROID` in their `platforms`. - `iosApp/HoneyDueTests/GalleryManifestParityTest.swift` does the same for `SnapshotGalleryTests.swift` against `Platform.IOS`. If you add a screen to either platform without updating the manifest, CI fails with a specific diff message telling you what's drifted. ## Variant matrix — driven by category Every screen captures one of two matrices, chosen by `GalleryCategory` in the manifest: **`DataCarrying` — 4 captures per surface** ``` _empty_light.png _empty_dark.png _populated_light.png _populated_dark.png ``` Empty variants use `FixtureDataManager.empty(seedLookups = false)` so even form screens that only read dropdowns produce a visible diff between empty and populated. **`DataFree` — 2 captures per surface** ``` _light.png _dark.png ``` Used for pure forms, auth flows, onboarding steps, and static chrome that render no entity data. The populated variant is deliberately omitted — it would be byte-identical to empty and add zero signal. The fixture seed still uses `empty(seedLookups = true)` so the priority picker, theme list, and subscription-tier gates render the same as they would for a fresh-signed-in user in production. ## How it works The pipeline is four moving parts: **fixture → DataManager seed → VM derived state → screen capture**. Every snapshot reads the same fixture graph on both platforms, and every VM receives that fixture through the same DI seam. ### 1. Shared fixtures `composeApp/src/commonMain/kotlin/com/tt/honeyDue/testing/FixtureDataManager.kt` implements `IDataManager` with in-memory `StateFlow` fields. Two factories: - **`empty(seedLookups: Boolean = true)`** — no residences, tasks, contractors, or documents. When `seedLookups` is `false` (DataCarrying variant), lookups (priorities, categories, templates) are empty too; when `true` (DataFree variant + default production call sites), lookups are present because the picker UI expects them. - **`populated()`** — every StateFlow is seeded: 2 residences, 8 tasks, 3 contractors, 5 documents, totals, all lookups, detail maps, task completions, notification preferences. Fixtures use a fixed clock (`Fixtures.FIXED_DATE = LocalDate(2026, 4, 15)`) so relative dates like "due in 3 days" never drift between runs. ### 2. DI seam: `IDataManager` injection Every ViewModel accepts `dataManager: IDataManager = DataManager` as a constructor parameter and derives read-state reactively via `stateIn(SharingStarted.Eagerly, initialValue = ...)`. The initial value is computed from `dataManager.x.value` synchronously at VM construction — so when a snapshot captures the first composition frame, the VM already holds populated data, no dispatcher flush required. Detail ViewModels (Contractor, Document, Task) additionally accept an `initialSelectedX: Int? = null` parameter. The parity-gallery harness passes a known fixture id at construction so the `stateIn` initial-value closure — which reads `_selectedX.value` — observes the id and seeds `Success(entity)` on the first frame. Without this, the screen's own `LaunchedEffect(id) { vm.loadX(id) }` dispatches the id assignment to a coroutine that runs *after* capture, leaving both empty and populated captures byte-identical on the `Idle` branch. This DI contract is enforced by a file-scan regression test: `composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/architecture/NoIndependentViewModelStateFileScanTest.kt`. ### 3. Test-time injection (both channels) `ScreenshotTests.kt` (Android) and `SnapshotGalleryTests.swift` (iOS) seed **two** paths per variant because screens read data through two channels: 1. **`LocalDataManager`** (Android CompositionLocal) / `DataManagerObservable.shared` (iOS `@EnvironmentObject`) — screens that read the ambient DataManager pick up the fixture through the composition/environment tree. 2. **`DataManager` singleton** (Android) / same observable (iOS) — VMs instantiated without an explicit `dataManager:` arg default to the singleton. The test clears the singleton then seeds every StateFlow from the fixture before capture. Clearing the singleton between variants is critical — without `dm.clear()` the previous surface's populated data leaks into the next surface's empty capture. ### 4. Android capture (Roborazzi) - Test runner: `ParameterizedRobolectricTestRunner` + `@GraphicsMode(NATIVE)` + `@Config(qualifiers = "w360dp-h800dp-mdpi")`. - `LocalInspectionMode` is provided as `true` so composables that call `FileProvider.getUriForFile` (camera pickers), APNs / FCM registration, or animation tickers short-circuit in the hermetic test environment. - Compose resources bootstrap: `@Before` hook installs the `AndroidContextProvider` static via reflection so `stringResource(...)` works under Robolectric. - Goldens: `composeApp/src/androidUnitTest/roborazzi/_.png`. - Typical size: 30–80 KB per image. ### 5. iOS capture (swift-snapshot-testing) - Uses `FixtureDataManager.shared.empty(seedLookups:)` / `.populated()` via SKIE interop. - Swift VMs subscribe to `DataManagerObservable.shared`; the harness copies fixture StateFlow values onto the observable's `@Published` properties synchronously before the view is instantiated so VMs seed from cache without waiting for Combine's async dispatch. - Rendered at `displayScale: 2.0` (not native 3.0) to cap per-image size. - Goldens: `iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_..png`. - Typical size: 150–300 KB per image after `zopflipng`. ### 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. ## Adding a screen 1. **Declare in the manifest** — `composeApp/src/commonMain/kotlin/com/tt/honeyDue/testing/GalleryManifest.kt`: ```kotlin GalleryScreen("my_new_screen", GalleryCategory.DataCarrying, both), ``` Update the `expected_counts_match_plan` canary in `GalleryManifestTest` to match the new totals. 2. **Wire Android** — add a `GallerySurface(...)` entry in `composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/screenshot/GallerySurfaces.kt`. If the screen is a detail view, pass the VM explicitly with `initialSelectedX = `: ```kotlin GallerySurface("my_new_screen") { val id = Fixtures.xxx.first().id val vm = remember { MyViewModel(initialSelectedId = id) } MyScreen(id = id, viewModel = vm, onNavigateBack = {}) } ``` 3. **Wire iOS** — add a `test_()` function in `iosApp/HoneyDueTests/SnapshotGalleryTests.swift`, using `snapDataCarrying(...)` or `snapDataFree(...)` as appropriate. Add the canonical name to `iosCoveredScreens` in `GalleryManifestParityTest.swift`. 4. **Regenerate goldens** — `make record-snapshots`, then `python3 scripts/build_parity_gallery.py` to rebuild the HTML. 5. **Commit the code change, the goldens, and the regenerated gallery together** so reviewers see the intent + the visual result in one PR. The parity tests fail until both platforms' surface lists match the manifest — you'll know immediately if you miss step 2 or 3. ## 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. Rebuild the HTML gallery. python3 scripts/build_parity_gallery.py # 4. Stage and commit alongside the UI code change. git add \ composeApp/src/androidUnitTest/roborazzi/ \ iosApp/HoneyDueTests/__Snapshots__/ \ docs/parity-gallery.html docs/parity-gallery-grid.md git commit -m "feat: " ``` ## Cleaning up orphan goldens `scripts/cleanup_orphan_goldens.sh` removes PNGs left over from prior test configurations — old multi-theme captures (`*_default_*`, `*_midnight_*`, `*_ocean_*`), Roborazzi comparison artifacts (`*_actual.png`, `*_compare.png`), and legacy empty/populated pairs for DataFree surfaces (which now capture `_light.png` / `_dark.png` only). Dry-runs by default; pass `--execute` to actually delete. ```bash ./scripts/cleanup_orphan_goldens.sh # preview ./scripts/cleanup_orphan_goldens.sh --execute # delete ``` ## Image size budget Per-file soft budget: **400 KB**. Enforced by CI. Android images rarely approach this. 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. ## 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. ## HTML gallery `docs/parity-gallery.html` is regenerated by `scripts/build_parity_gallery.py`, which parses the canonical manifest directly (`GalleryManifest.kt`) and lays out one row per screen in product-flow order (auth → onboarding → home → residences → tasks → contractors → documents → profile → subscription). Platform cells render as: - **Captured PNG** — standard image. - **`[missing — ]` red-bordered box** — screen is in the manifest for this platform but the PNG isn't on disk. Action needed. - **`not on ` muted-border box** — screen is explicitly not-on-this-platform per the manifest (e.g. `home` is Android-only). No action. To view locally: ```bash python3 scripts/build_parity_gallery.py open docs/parity-gallery.html ``` The `docs/parity-gallery-grid.md` variant renders inline in gitea's Markdown viewer (gitea serves raw `.html` as `text/plain`). ## Known limitations - **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. - **`home` is Android-only.** Android has a dedicated dashboard route with aggregate stats; iOS lands directly on the residences list (iOS's first tab plays the product role Android's `home` does, but renders different content). Captured as Android-only; iOS cell shows the `not on ios` placeholder. - **`documents` vs `documents_warranties`.** Android has a single `documents` route; iOS splits the same conceptual screen into a segmented-tab `documents_warranties` view. Captured as two rows rather than coerced into one to keep the structural divergence visible. - **`add_task`, `profile_edit`** are iOS-only — Android presents these flows inline (dialog inside `residence_detail`, inline form inside `profile`). Captured as iOS-only. - **`biometric_lock`** is Android-only — iOS uses the system Face ID prompt directly, not a custom screen.