Files
honeyDueKMP/composeApp/src/commonMain/kotlin/com/tt/honeyDue/testing/GalleryManifest.kt
T
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

178 lines
8.9 KiB
Kotlin

package com.tt.honeyDue.testing
/**
* Canonical list of every user-reachable screen in the HoneyDue app, used
* as the single source of truth for the iOS↔Android parity-gallery
* snapshot tests.
*
* Both platforms' snapshot harnesses are CI-gated against this manifest:
* - Android: `GalleryManifestParityTest` fails if the entries in
* `GallerySurfaces.kt` don't match the subset of screens with
* [Platform.ANDROID] in [platforms].
* - iOS: `GalleryManifestParityTest.swift` performs the equivalent check
* against `SnapshotGalleryTests.swift`.
*
* This prevents the two platforms from silently drifting apart in
* coverage — adding a screen to one side without updating this manifest
* (and therefore the other side) fails CI.
*
* When a screen is reachable on only one platform (e.g. [GalleryScreens.home]
* on Android, [GalleryScreens.documentsWarranties] on iOS), mark it with
* the relevant [Platform] set. The gallery HTML renders a visible
* `[missing — <platform>]` placeholder for the absent side so the gap is
* obvious rather than silently omitted.
*/
/** Category of a gallery screen — drives the capture-variant matrix. */
enum class GalleryCategory {
/**
* Screen renders data from [com.tt.honeyDue.data.IDataManager] (lists,
* detail views, dashboards). Captures 4 variants: empty+populated x
* light+dark — so the test proves the fixture actually reaches the UI.
*/
DataCarrying,
/**
* Screen is a pure form, auth view, static onboarding step, or chrome
* with no backing entity data. Captures 2 variants: light+dark only.
* Skipping the populated variant prevents ~50 byte-identical-to-empty
* goldens that add no signal.
*/
DataFree,
}
/** Platforms that include a given screen in their parity-gallery harness. */
enum class Platform { ANDROID, IOS }
/**
* One canonical screen in the parity manifest.
*
* @property name Snake-case identifier; doubles as the golden-PNG filename
* prefix on both platforms.
* @property category Drives the capture-variant matrix (see [GalleryCategory]).
* @property platforms Platforms that capture this screen. Screens captured
* on both have a paired row in the gallery; screens on only one show a
* `[missing]` placeholder for the absent platform.
*/
data class GalleryScreen(
val name: String,
val category: GalleryCategory,
val platforms: Set<Platform>,
)
/**
* Canonical manifest — 43 screens, ordered by product flow.
*
* Breakdown:
* - 12 [GalleryCategory.DataCarrying] screens — 4 captures each.
* - 31 [GalleryCategory.DataFree] screens — 2 captures each.
* - 37 screens captured on both platforms.
* - 3 Android-only: `home`, `documents`, `biometric_lock`.
* - 3 iOS-only: `documents_warranties`, `add_task`, `profile_edit`.
*/
object GalleryScreens {
private val both = setOf(Platform.ANDROID, Platform.IOS)
private val androidOnly = setOf(Platform.ANDROID)
private val iosOnly = setOf(Platform.IOS)
val all: List<GalleryScreen> = listOf(
// ---------- Auth ----------
GalleryScreen("login", GalleryCategory.DataFree, both),
GalleryScreen("register", GalleryCategory.DataFree, both),
GalleryScreen("forgot_password", GalleryCategory.DataFree, both),
GalleryScreen("verify_reset_code", GalleryCategory.DataFree, both),
GalleryScreen("reset_password", GalleryCategory.DataFree, both),
GalleryScreen("verify_email", GalleryCategory.DataFree, both),
// ---------- Onboarding ----------
GalleryScreen("onboarding_welcome", GalleryCategory.DataFree, both),
GalleryScreen("onboarding_value_props", GalleryCategory.DataFree, both),
GalleryScreen("onboarding_create_account", GalleryCategory.DataFree, both),
GalleryScreen("onboarding_verify_email", GalleryCategory.DataFree, both),
GalleryScreen("onboarding_location", GalleryCategory.DataFree, both),
GalleryScreen("onboarding_name_residence", GalleryCategory.DataFree, both),
GalleryScreen("onboarding_home_profile", GalleryCategory.DataFree, both),
GalleryScreen("onboarding_join_residence", GalleryCategory.DataFree, both),
GalleryScreen("onboarding_first_task", GalleryCategory.DataCarrying, both),
GalleryScreen("onboarding_subscription", GalleryCategory.DataFree, both),
// ---------- Home / dashboard (Android-only) ----------
GalleryScreen("home", GalleryCategory.DataCarrying, androidOnly),
// ---------- Residences ----------
GalleryScreen("residences", GalleryCategory.DataCarrying, both),
GalleryScreen("residence_detail", GalleryCategory.DataCarrying, both),
GalleryScreen("add_residence", GalleryCategory.DataFree, both),
GalleryScreen("edit_residence", GalleryCategory.DataFree, both),
GalleryScreen("join_residence", GalleryCategory.DataFree, both),
// `manage_users` is DataFree: both platforms render a loading /
// error state on first paint because residence-users data is
// fetched via APILayer directly (no fixture seam). Populating
// it would require a new `usersByResidence` field on
// `IDataManager` plus fixture+screen wiring — deferred as a
// production improvement rather than a snapshot-test-only
// shim.
GalleryScreen("manage_users", GalleryCategory.DataFree, both),
// ---------- Tasks ----------
GalleryScreen("all_tasks", GalleryCategory.DataCarrying, both),
// `add_task` is iOS-only: iOS presents an "Add task" sheet from a
// residence-scoped context. Android adds tasks via an inline dialog
// inside `residence_detail`, with no standalone destination.
GalleryScreen("add_task", GalleryCategory.DataFree, iosOnly),
GalleryScreen("add_task_with_residence", GalleryCategory.DataFree, both),
GalleryScreen("edit_task", GalleryCategory.DataFree, both),
// `complete_task` is DataFree: the task and residence-name are
// passed as static props, completion form fields default-render
// the same regardless of fixture state, and the contractor
// picker is collapsed on first paint. Nothing visible diffs
// between empty and populated.
GalleryScreen("complete_task", GalleryCategory.DataFree, both),
// `task_suggestions` is DataFree in snapshot terms: the visible
// first-paint state is driven by an `APILayer.getTaskSuggestions`
// call (which fails hermetically), not by anything on
// `IDataManager`. The populated templates stored on DM are only
// surfaced after the API resolves, so both variants render the
// same loading/error frame. Treating as DataFree is honest.
GalleryScreen("task_suggestions", GalleryCategory.DataFree, both),
GalleryScreen("task_templates_browser", GalleryCategory.DataCarrying, both),
// ---------- Contractors ----------
GalleryScreen("contractors", GalleryCategory.DataCarrying, both),
GalleryScreen("contractor_detail", GalleryCategory.DataCarrying, both),
// ---------- Documents ----------
// Android has a single `documents` screen; iOS has a tabbed
// `documents_warranties` view that unifies docs + warranties under
// a segmented control. They're structurally distinct enough to
// list as separate rows so the gallery makes the divergence
// visible rather than pretending they're the same screen.
GalleryScreen("documents", GalleryCategory.DataCarrying, androidOnly),
GalleryScreen("documents_warranties", GalleryCategory.DataCarrying, iosOnly),
GalleryScreen("document_detail", GalleryCategory.DataCarrying, both),
GalleryScreen("add_document", GalleryCategory.DataFree, both),
GalleryScreen("edit_document", GalleryCategory.DataFree, both),
// ---------- Profile / settings ----------
GalleryScreen("profile", GalleryCategory.DataCarrying, both),
// `profile_edit` is iOS-only: iOS has a standalone edit-profile view.
// On Android, profile editing is folded into `profile` (inline form).
GalleryScreen("profile_edit", GalleryCategory.DataFree, iosOnly),
GalleryScreen("notification_preferences", GalleryCategory.DataFree, both),
GalleryScreen("theme_selection", GalleryCategory.DataFree, both),
GalleryScreen("biometric_lock", GalleryCategory.DataFree, androidOnly),
// ---------- Subscription ----------
GalleryScreen("feature_comparison", GalleryCategory.DataFree, both),
)
/** Screens captured on Android, keyed by canonical name. */
val forAndroid: Map<String, GalleryScreen> =
all.filter { Platform.ANDROID in it.platforms }.associateBy { it.name }
/** Screens captured on iOS, keyed by canonical name. */
val forIos: Map<String, GalleryScreen> =
all.filter { Platform.IOS in it.platforms }.associateBy { it.name }
}