# 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.
