Files
honeyDueKMP/docs/parity-gallery.md
Trey T 9fa58352c0 Parity gallery: unify around canonical manifest, fix populated-state rendering
Single source of truth: `com.tt.honeyDue.testing.GalleryScreens` lists
every user-reachable screen with its category (DataCarrying / DataFree)
and per-platform reachability. Both platforms' test harnesses are
CI-gated against it — `GalleryManifestParityTest` on each side fails
if the surface list drifts from the manifest.

Variant matrix by category: DataCarrying captures 4 PNGs
(empty/populated × light/dark), DataFree captures 2 (light/dark only).
Empty variants for DataCarrying use `FixtureDataManager.empty(seedLookups = false)`
so form screens that only read DM lookups can diff against populated.

Detail-screen rendering fixed on both platforms. Root cause: VM
`stateIn(Eagerly, initialValue = …)` closures evaluated
`_selectedX.value` before screen-side `LaunchedEffect` / `.onAppear`
could set the id, leaving populated captures byte-identical to empty.

  Kotlin: `ContractorViewModel` + `DocumentViewModel` accept
  `initialSelectedX: Int? = null` so the id is set in the primary
  constructor before `stateIn` computes its seed.

  Swift: `ContractorViewModel`, `DocumentViewModelWrapper`,
  `ResidenceViewModel`, `OnboardingTasksViewModel` gained pre-seed
  init params. `ContractorDetailView`, `DocumentDetailView`,
  `ResidenceDetailView`, `OnboardingFirstTaskContent` gained
  test/preview init overloads that accept the pre-seeded VM.
  Corresponding view bodies prefer cached success state over
  loading/error — avoids a spinner flashing over already-visible
  content during background refreshes (production benefit too).

Real production bug fixed along the way: `DataManager.clear()` was
missing `_contractorDetail`, `_documentDetail`, `_contractorsByResidence`,
`_taskCompletions`, `_notificationPreferences`. On logout these maps
leaked across user sessions; in the gallery they leaked the previous
surface's populated state into the next surface's empty capture.

`ImagePicker.android.kt` guards `rememberCameraPicker` with
`LocalInspectionMode` — `FileProvider.getUriForFile` can't resolve the
Robolectric test-cache path, so `add_document` / `edit_document`
previously failed the entire capture.

Honest reclassifications: `complete_task`, `manage_users`, and
`task_suggestions` moved to DataFree. Their first-paint visible state
is driven by static props or APILayer calls, not by anything on
`IDataManager` — populated would be byte-identical to empty without
a significant production rewire. The manifest comments call this out.

Manifest counts after all moves: 43 screens = 12 DataCarrying + 31
DataFree, 37 on both platforms + 3 Android-only (home, documents,
biometric_lock) + 3 iOS-only (documents_warranties, add_task,
profile_edit).

Test results after full record:
  Android: 11/11 DataCarrying diff populated vs empty
  iOS:     12/12 DataCarrying diff populated vs empty

Also in this change:
- `scripts/build_parity_gallery.py` parses the Kotlin manifest
  directly, renders rows in product-flow order, shows explicit
  `[missing — <platform>]` placeholders for expected-but-absent
  captures and muted `not on <platform>` placeholders for
  platform-specific screens. Docs regenerated.
- `scripts/cleanup_orphan_goldens.sh` safely removes PNGs from prior
  test configurations (theme-named, compare artifacts, legacy
  empty/populated pairs for what is now DataFree). Dry-run by default.
- `docs/parity-gallery.md` rewritten: canonical-manifest workflow,
  adding-a-screen guide, variant matrix explained.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:10:32 -05:00

13 KiB
Raw Blame History

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:

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

<screen>_empty_light.png      <screen>_empty_dark.png
<screen>_populated_light.png  <screen>_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

<screen>_light.png   <screen>_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/<screen>_<suffix>.png.
  • Typical size: 3080 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_<func>.<suffix>.png.
  • Typical size: 150300 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 manifestcomposeApp/src/commonMain/kotlin/com/tt/honeyDue/testing/GalleryManifest.kt:

    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 = <fixtureId>:

    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_<name>() function in iosApp/HoneyDueTests/SnapshotGalleryTests.swift, using snapDataCarrying(...) or snapDataFree(...) as appropriate. Add the canonical name to iosCoveredScreens in GalleryManifestParityTest.swift.

  4. Regenerate goldensmake 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

# 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 <screen-file> \
        composeApp/src/androidUnitTest/roborazzi/ \
        iosApp/HoneyDueTests/__Snapshots__/ \
        docs/parity-gallery.html docs/parity-gallery-grid.md
git commit -m "feat: <what changed>"

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 <name>_light.png / <name>_dark.png only). Dry-runs by default; pass --execute to actually delete.

./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:

brew install zopfli          # preferred — better compression
brew install pngcrush        # fallback

Neither installed? make record-snapshots warns and skips optimization.

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 — <platform>] red-bordered box — screen is in the manifest for this platform but the PNG isn't on disk. Action needed.
  • not on <platform> muted-border box — screen is explicitly not-on-this-platform per the manifest (e.g. home is Android-only). No action.

To view locally:

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.