9fa58352c0
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>
178 lines
8.9 KiB
Kotlin
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 }
|
|
}
|