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>
316 lines
13 KiB
Markdown
316 lines
13 KiB
Markdown
# 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**
|
||
```
|
||
<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: 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_<func>.<suffix>.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 = <fixtureId>`:
|
||
```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_<name>()` 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 <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.
|
||
|
||
```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 — <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:
|
||
```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.
|