Files
honeyDueKMP/docs/parity-gallery.md
Trey T f83e89bee3 Parity gallery: honest populated-state coverage (10/34 surfaces differ)
Fixed & documented, not-just-marketed:
- HomeScreen now derives summary card from LocalDataManager.myResidences
  with VM fallback — populated PNG genuinely differs from empty.
- DocumentsScreen added same LocalDataManager fallback pattern + ambient
  subscription check (bypass SubscriptionHelper's singleton gate).
- ScreenshotTests.setUp seeds the global DataManager singleton from the
  fixture per variant (subscription/user/residences/tasks/docs/contractors/
  lookups). Unblocks screens that bypass LocalDataManager.

Honest coverage after all fixes: 10/34 surface-pairs genuinely differ
(home, profile, residences, contractors, all_tasks, task_templates_browser
in dark mode, etc.). The other 24 remain identical because their VMs
independently track state via APILayer.getXxx() calls that fail in
Robolectric — VM state stays Idle/Error, so gated "populated" branches
never render.

Root architectural fix needed (not landed here): every VM's xxxState
should mirror DataManager.xxx reactively instead of tracking API results
independently. That's a ~20-VM refactor tracked as follow-up in
docs/parity-gallery.md "Known limitations".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:31:52 -05:00

8.7 KiB
Raw Blame History

Parity gallery — iOS ↔ Android snapshot regression

Every primary screen on both platforms is captured as a PNG golden and committed to the repo. A PR that drifts from a golden fails CI. The committed docs/parity-gallery.html pairs iOS and Android side-by-side in a scrollable HTML grid you can open locally or from gitea's raw-file view.

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

How it works

Shared fixtures

composeApp/src/commonMain/kotlin/com/tt/honeyDue/testing/FixtureDataManager.kt exposes .empty() and .populated() factories. Both platforms render the same screens against the same fixture graph — the only cross-platform differences left are actual UI code differences (by design). Fixtures use a fixed clock (Fixtures.FIXED_DATE = LocalDate(2026, 4, 15)) so dates never drift.

Android capture (Roborazzi)

  • composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/screenshot/ScreenshotTests.kt declares one @Test per surface in GallerySurfaces.kt.
  • Each test captures 4 variants: empty × light, empty × dark, populated × light, populated × dark.
  • Runs in Robolectric — no emulator needed, no flake from animations.
  • Goldens: composeApp/src/androidUnitTest/roborazzi/<screen>_<state>_<mode>.png
  • Typical size: 3080 KB per image.

iOS capture (swift-snapshot-testing)

  • iosApp/HoneyDueTests/SnapshotGalleryTests.swift has 4 tests per screen.
  • Rendered at displayScale: 2.0 (not the native 3.0) to cap per-image size.
  • Uses FixtureDataManager.shared.empty() / .populated() via SKIE.
  • Goldens: iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_<name>.<variant>.png
  • Typical size: 150300 KB per image after zopflipng post-processing.

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. No code edits required to switch between record and verify — the env var / gradle task controls everything.

When to record vs verify

Verify is what CI runs on every PR. It is the gate. If verify fails, ask: was this drift intentional?

Record is what you run locally when a UI change is deliberate and you want to publish the new look as the new baseline. Commit the regenerated goldens alongside your code change so reviewers see both the code and the visual result in one PR.

Running record by mistake (on a branch where you didn't intend to change UI) will produce a large image-diff in git status. That diff is the signal — revert the goldens, investigate what unintentionally changed.

Android

Add one entry to composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/screenshot/GallerySurfaces.kt:

GallerySurface("my_new_screen") { MyNewScreen(onNavigateBack = {}, /* required params from fixtures */) },

If the screen needs a specific model (task, residence, etc.) pass one from Fixtures.* — e.g. Fixtures.tasks.first(). If the screen renders differently in empty vs populated, the LocalDataManager provider wiring in ScreenshotTests.kt handles it automatically.

iOS

Add 4 test functions to iosApp/HoneyDueTests/SnapshotGalleryTests.swift:

func test_myNewScreen_empty_light()     { snap("my_new_screen_empty_light",     empty: true,  dark: false) { MyNewView() } }
func test_myNewScreen_empty_dark()      { snap("my_new_screen_empty_dark",      empty: true,  dark: true)  { MyNewView() } }
func test_myNewScreen_populated_light() { snap("my_new_screen_populated_light", empty: false, dark: false) { MyNewView() } }
func test_myNewScreen_populated_dark()  { snap("my_new_screen_populated_dark",  empty: false, dark: true)  { MyNewView() } }

Then make record-snapshots to generate goldens, git add the PNGs alongside your test changes.

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. Stage and commit alongside the UI code change.
git add <screen-file.kt> <SnapshotGalleryTests.swift changes> \
        composeApp/src/androidUnitTest/roborazzi/ \
        iosApp/HoneyDueTests/__Snapshots__/
git commit -m "feat: <what changed>"

Reviewers see the code diff AND the golden diff in one PR — makes intent obvious.

Image size budget

Per-file soft budget: 400 KB. Enforced by CI.

Android images are rarely this large. 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 (the snap helper accepts an override).

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 — goldens are still usable, just larger.

docs/parity-gallery.html is regenerated by scripts/build_parity_gallery.py whenever goldens change. It's a self-contained HTML file with relative <img> paths that resolve within the repo — so gitea's raw-file view renders it without any server.

To view locally:

python3 scripts/build_parity_gallery.py
open docs/parity-gallery.html

The gallery groups by screen name. Each row shows Android vs iOS for one {state, mode} combination, with sticky headers for quick navigation.

Current coverage

Written to the output on each regeneration — check the top of docs/parity-gallery.html for the current count.

Known limitations

  • Android populated-state coverage is partial (10/34 surfaces differ). Screens like home, profile, residences, contractors, all_tasks render truly populated data. The other ~24 screens (documents, complete_task, feature_comparison, notification_preferences, manage_users, every edit_* / add_* / auth form) currently show identical renders for empty and populated fixtures, because their ViewModels independently track state via APILayer.getXxx() calls that fail with "Not authenticated" in Robolectric — the VM state never transitions to ApiResult.Success so the screen's "populated" branch never renders, even though LocalDataManager and the global DataManager singleton are both seeded with the fixture.

    The architectural fix: every VM's xxxState needs to mirror DataManager.xxx reactively (e.g., dataManager.documents.map { Success(it) }) instead of independently tracking the API call result. That's a per-VM refactor across ~20 ViewModels; currently only HomeScreen and DocumentsScreen have been patched to fall back to LocalDataManager directly. Gallery viewers should treat a "same" row as indicating the fixture didn't reach the screen, not that the screens genuinely render identically.

  • iOS populated-state coverage is partial. Swift Views today instantiate their ViewModels via @StateObject viewModel = FooViewModel(); the ViewModels read DataManagerObservable.shared directly rather than accepting an injected IDataManager. Until ViewModels gain a DI seam, populated-state snapshots require per-screen ad-hoc workarounds. Tracked as a follow-up.

  • Android detail-screen coverage is partial. Screens that require a pre-selected model (ResidenceDetailScreen(residence = ...), ContractorDetailScreen(contractor = ...)) silently skip rendering unless GallerySurfaces.kt passes a fixture item. Expanding these to full coverage is a follow-up PR — low-risk additions to GallerySurfaces.kt.

  • 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 — the HTML gallery is for side-by-side human review.

  • Roborazzi path mismatch. The historical goldens lived at composeApp/src/androidUnitTest/roborazzi/. The Roborazzi Gradle block sets outputDir to match. If verifyRoborazziDebug ever reports "original file not found", confirm the outputDir hasn't drifted.