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

316 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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: 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 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.