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>
This commit is contained in:
Trey T
2026-04-20 18:10:32 -05:00
parent 316b1f709d
commit 9fa58352c0
298 changed files with 2496 additions and 1343 deletions
@@ -823,7 +823,12 @@ object DataManager : IDataManager {
_tasksByResidence.value = emptyMap()
_documents.value = emptyList()
_documentsByResidence.value = emptyMap()
_documentDetail.value = emptyMap()
_contractors.value = emptyList()
_contractorsByResidence.value = emptyMap()
_contractorDetail.value = emptyMap()
_taskCompletions.value = emptyMap()
_notificationPreferences.value = null
// Clear subscription
_subscription.value = null
@@ -28,12 +28,20 @@ object FixtureDataManager {
/**
* Data-free fixture — represents a freshly-signed-in user with no
* residences, no tasks, no contractors, no documents. Lookups
* (priorities, categories, frequencies) are still populated because
* empty-state form pickers render them even before the user has any
* entities of their own.
* residences, no tasks, no contractors, no documents.
*
* @param seedLookups When `true` (the default), lookups (priorities,
* categories, frequencies, residence types, contractor specialties,
* task templates) are populated. This matches product behaviour —
* a user with zero residences still sees the priority picker in
* every form.
*
* When `false`, lookups are empty too. Use this for snapshot tests
* that want the `empty` variant of a form to render empty dropdowns
* (so populated vs. empty PNGs diff for form screens). The parity
* gallery's empty variant passes `seedLookups = false`.
*/
fun empty(): IDataManager = InMemoryDataManager(
fun empty(seedLookups: Boolean = true): IDataManager = InMemoryDataManager(
currentUser = null,
residences = emptyList(),
myResidencesResponse = null,
@@ -48,13 +56,13 @@ object FixtureDataManager {
upgradeTriggers = emptyMap(),
featureBenefits = Fixtures.featureBenefits,
promotions = emptyList(),
residenceTypes = Fixtures.residenceTypes,
taskFrequencies = Fixtures.taskFrequencies,
taskPriorities = Fixtures.taskPriorities,
taskCategories = Fixtures.taskCategories,
contractorSpecialties = Fixtures.contractorSpecialties,
taskTemplates = Fixtures.taskTemplates,
taskTemplatesGrouped = Fixtures.taskTemplatesGrouped,
residenceTypes = if (seedLookups) Fixtures.residenceTypes else emptyList(),
taskFrequencies = if (seedLookups) Fixtures.taskFrequencies else emptyList(),
taskPriorities = if (seedLookups) Fixtures.taskPriorities else emptyList(),
taskCategories = if (seedLookups) Fixtures.taskCategories else emptyList(),
contractorSpecialties = if (seedLookups) Fixtures.contractorSpecialties else emptyList(),
taskTemplates = if (seedLookups) Fixtures.taskTemplates else emptyList(),
taskTemplatesGrouped = if (seedLookups) Fixtures.taskTemplatesGrouped else null,
)
/**
@@ -0,0 +1,177 @@
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 }
}
@@ -50,7 +50,12 @@ class AuthViewModel(
val currentUserState: StateFlow<ApiResult<User>> =
dataManager.currentUser
.map { if (it != null) ApiResult.Success(it) else ApiResult.Idle }
.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
dataManager.currentUser.value
?.let { ApiResult.Success(it) } ?: ApiResult.Idle,
)
private val _forgotPasswordState = MutableStateFlow<ApiResult<ForgotPasswordResponse>>(ApiResult.Idle)
val forgotPasswordState: StateFlow<ApiResult<ForgotPasswordResponse>> = _forgotPasswordState
@@ -26,6 +26,7 @@ import kotlinx.coroutines.launch
*/
class ContractorViewModel(
private val dataManager: IDataManager = DataManager,
initialSelectedContractorId: Int? = null,
) : ViewModel() {
// ---------- Read state ----------
@@ -33,15 +34,32 @@ class ContractorViewModel(
val contractorsState: StateFlow<ApiResult<List<ContractorSummary>>> =
dataManager.contractors
.map { list -> if (list.isNotEmpty()) ApiResult.Success(list) else ApiResult.Idle }
.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
dataManager.contractors.value
.takeIf { it.isNotEmpty() }
?.let { ApiResult.Success(it) } ?: ApiResult.Idle,
)
private val _selectedContractorId = MutableStateFlow<Int?>(null)
// `initialSelectedContractorId` seeds this in construction order so the
// `stateIn` initial-value closure below observes the selected id *and*
// the seeded `dataManager.contractorDetail[id]` on first subscription —
// used by the parity-gallery snapshot harness to render populated
// detail screens on the very first composition frame.
private val _selectedContractorId = MutableStateFlow<Int?>(initialSelectedContractorId)
val contractorDetailState: StateFlow<ApiResult<Contractor>> =
combine(_selectedContractorId, dataManager.contractorDetail) { id, map ->
if (id == null) ApiResult.Idle
else map[id]?.let { ApiResult.Success(it) } ?: ApiResult.Idle
}.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
}.stateIn(
viewModelScope,
SharingStarted.Eagerly,
_selectedContractorId.value?.let { id ->
dataManager.contractorDetail.value[id]?.let { ApiResult.Success(it) }
} ?: ApiResult.Idle,
)
// ---------- Loading / error ----------
@@ -27,6 +27,7 @@ import kotlinx.coroutines.launch
*/
class DocumentViewModel(
private val dataManager: IDataManager = DataManager,
initialSelectedDocumentId: Int? = null,
) : ViewModel() {
// ---------- Read state ----------
@@ -34,15 +35,32 @@ class DocumentViewModel(
val documentsState: StateFlow<ApiResult<List<Document>>> =
dataManager.documents
.map { list -> if (list.isNotEmpty()) ApiResult.Success(list) else ApiResult.Idle }
.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
dataManager.documents.value
.takeIf { it.isNotEmpty() }
?.let { ApiResult.Success(it) } ?: ApiResult.Idle,
)
private val _selectedDocumentId = MutableStateFlow<Int?>(null)
// `initialSelectedDocumentId` seeds this in construction order so the
// `stateIn` initial-value closure below observes the selected id *and*
// the seeded `dataManager.documentDetail[id]` on first subscription —
// used by the parity-gallery snapshot harness to render populated
// detail screens on the very first composition frame.
private val _selectedDocumentId = MutableStateFlow<Int?>(initialSelectedDocumentId)
val documentDetailState: StateFlow<ApiResult<Document>> =
combine(_selectedDocumentId, dataManager.documentDetail) { id, map ->
if (id == null) ApiResult.Idle
else map[id]?.let { ApiResult.Success(it) } ?: ApiResult.Idle
}.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
}.stateIn(
viewModelScope,
SharingStarted.Eagerly,
_selectedDocumentId.value?.let { id ->
dataManager.documentDetail.value[id]?.let { ApiResult.Success(it) }
} ?: ApiResult.Idle,
)
fun selectDocument(id: Int?) {
_selectedDocumentId.value = id
@@ -87,7 +87,12 @@ class NotificationPreferencesViewModel(
val preferencesState: StateFlow<ApiResult<NotificationPreference>> =
dataManager.notificationPreferences
.map { if (it != null) ApiResult.Success(it) else ApiResult.Idle }
.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
dataManager.notificationPreferences.value
?.let { ApiResult.Success(it) } ?: ApiResult.Idle,
)
private val _updateState = MutableStateFlow<ApiResult<NotificationPreference>>(ApiResult.Idle)
val updateState: StateFlow<ApiResult<NotificationPreference>> = _updateState.asStateFlow()
@@ -39,17 +39,33 @@ class ResidenceViewModel(
val residencesState: StateFlow<ApiResult<List<Residence>>> =
dataManager.residences
.map { list -> if (list.isNotEmpty()) ApiResult.Success(list) else ApiResult.Idle }
.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
dataManager.residences.value
.takeIf { it.isNotEmpty() }
?.let { ApiResult.Success(it) } ?: ApiResult.Idle,
)
val myResidencesState: StateFlow<ApiResult<MyResidencesResponse>> =
dataManager.myResidences
.map { if (it != null) ApiResult.Success(it) else ApiResult.Idle }
.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
dataManager.myResidences.value
?.let { ApiResult.Success(it) } ?: ApiResult.Idle,
)
val summaryState: StateFlow<ApiResult<TotalSummary>> =
dataManager.totalSummary
.map { if (it != null) ApiResult.Success(it) else ApiResult.Idle }
.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
dataManager.totalSummary.value
?.let { ApiResult.Success(it) } ?: ApiResult.Idle,
)
/** Drives the residence-scoped projections. */
private val _selectedResidenceId = MutableStateFlow<Int?>(null)
@@ -58,13 +74,25 @@ class ResidenceViewModel(
combine(_selectedResidenceId, dataManager.tasksByResidence) { id, map ->
if (id == null) ApiResult.Idle
else map[id]?.let { ApiResult.Success(it) } ?: ApiResult.Idle
}.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
}.stateIn(
viewModelScope,
SharingStarted.Eagerly,
_selectedResidenceId.value?.let { id ->
dataManager.tasksByResidence.value[id]?.let { ApiResult.Success(it) }
} ?: ApiResult.Idle,
)
val residenceContractorsState: StateFlow<ApiResult<List<ContractorSummary>>> =
combine(_selectedResidenceId, dataManager.contractorsByResidence) { id, map ->
if (id == null) ApiResult.Idle
else map[id]?.let { ApiResult.Success(it) } ?: ApiResult.Idle
}.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
}.stateIn(
viewModelScope,
SharingStarted.Eagerly,
_selectedResidenceId.value?.let { id ->
dataManager.contractorsByResidence.value[id]?.let { ApiResult.Success(it) }
} ?: ApiResult.Idle,
)
// ---------- Loading / error feedback ----------
@@ -48,7 +48,12 @@ class TaskViewModel(
val tasksState: StateFlow<ApiResult<TaskColumnsResponse>> =
dataManager.allTasks
.map { if (it != null) ApiResult.Success(it) else ApiResult.Idle }
.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
dataManager.allTasks.value
?.let { ApiResult.Success(it) } ?: ApiResult.Idle,
)
/** Drives the [tasksByResidenceState] projection key. */
private val _selectedResidenceId = MutableStateFlow<Int?>(null)
@@ -58,7 +63,13 @@ class TaskViewModel(
combine(_selectedResidenceId, dataManager.tasksByResidence) { id, map ->
if (id == null) ApiResult.Idle
else map[id]?.let { ApiResult.Success(it) } ?: ApiResult.Idle
}.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
}.stateIn(
viewModelScope,
SharingStarted.Eagerly,
_selectedResidenceId.value?.let { id ->
dataManager.tasksByResidence.value[id]?.let { ApiResult.Success(it) }
} ?: ApiResult.Idle,
)
/** Drives the [taskCompletionsState] projection key. */
private val _selectedTaskId = MutableStateFlow<Int?>(null)
@@ -68,7 +79,13 @@ class TaskViewModel(
combine(_selectedTaskId, dataManager.taskCompletions) { id, map ->
if (id == null) ApiResult.Idle
else map[id]?.let { ApiResult.Success(it) } ?: ApiResult.Idle
}.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
}.stateIn(
viewModelScope,
SharingStarted.Eagerly,
_selectedTaskId.value?.let { id ->
dataManager.taskCompletions.value[id]?.let { ApiResult.Success(it) }
} ?: ApiResult.Idle,
)
// ---------- Loading / error feedback ----------