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>
113 lines
5.0 KiB
Kotlin
113 lines
5.0 KiB
Kotlin
package com.tt.honeyDue.testing
|
|
|
|
import com.tt.honeyDue.data.IDataManager
|
|
|
|
/**
|
|
* Factories that produce deterministic [IDataManager] instances for the
|
|
* parity-gallery (Android Roborazzi + iOS swift-snapshot-testing) and any
|
|
* other snapshot/preview harness. Both platforms consume the same fixture
|
|
* graph (via SKIE bridging on iOS), so any layout divergence between iOS
|
|
* and Android renders of the same screen is a real parity bug — not a test
|
|
* data mismatch.
|
|
*
|
|
* Use:
|
|
* ```kotlin
|
|
* // Android Compose preview or Roborazzi test
|
|
* CompositionLocalProvider(LocalDataManager provides FixtureDataManager.empty()) {
|
|
* MyScreen()
|
|
* }
|
|
* ```
|
|
*
|
|
* ```swift
|
|
* // iOS SwiftUI preview or snapshot test
|
|
* MyView().environment(\.dataManager,
|
|
* DataManagerObservable(kotlin: FixtureDataManager.shared.populated()))
|
|
* ```
|
|
*/
|
|
object FixtureDataManager {
|
|
|
|
/**
|
|
* Data-free fixture — represents a freshly-signed-in user with no
|
|
* 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(seedLookups: Boolean = true): IDataManager = InMemoryDataManager(
|
|
currentUser = null,
|
|
residences = emptyList(),
|
|
myResidencesResponse = null,
|
|
totalSummary = null,
|
|
residenceSummaries = emptyMap(),
|
|
allTasks = null,
|
|
tasksByResidence = emptyMap(),
|
|
documents = emptyList(),
|
|
documentsByResidence = emptyMap(),
|
|
contractors = emptyList(),
|
|
subscription = Fixtures.freeSubscription,
|
|
upgradeTriggers = emptyMap(),
|
|
featureBenefits = Fixtures.featureBenefits,
|
|
promotions = emptyList(),
|
|
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,
|
|
)
|
|
|
|
/**
|
|
* Fully-populated fixture with realistic content for every screen:
|
|
* 2 residences · 8 tasks (mix of overdue/due-soon/upcoming/completed)
|
|
* · 3 contractors · 5 documents (2 warranties — one expired —
|
|
* + 3 manuals). The user is premium-tier so gated surfaces render
|
|
* their "pro" appearance.
|
|
*/
|
|
fun populated(): IDataManager = InMemoryDataManager(
|
|
currentUser = Fixtures.user,
|
|
residences = Fixtures.residences,
|
|
myResidencesResponse = Fixtures.myResidencesResponse,
|
|
totalSummary = Fixtures.totalSummary,
|
|
residenceSummaries = Fixtures.residenceSummaries,
|
|
allTasks = Fixtures.taskColumnsResponse,
|
|
tasksByResidence = Fixtures.residences.associate { residence ->
|
|
residence.id to Fixtures.taskColumnsResponse.copy(
|
|
columns = Fixtures.taskColumnsResponse.columns.map { column ->
|
|
val filtered = column.tasks.filter { it.residenceId == residence.id }
|
|
column.copy(tasks = filtered, count = filtered.size)
|
|
},
|
|
residenceId = residence.id.toString(),
|
|
)
|
|
},
|
|
documents = Fixtures.documents,
|
|
documentsByResidence = Fixtures.documentsByResidence,
|
|
documentDetail = Fixtures.documents.associateBy { it.id ?: 0 }.filterKeys { it != 0 },
|
|
contractors = Fixtures.contractorSummaries,
|
|
contractorsByResidence = Fixtures.residences.associate { r ->
|
|
r.id to Fixtures.contractorSummaries.filter { it.residenceId == r.id }
|
|
},
|
|
contractorDetail = Fixtures.contractors.associateBy { it.id },
|
|
taskCompletions = emptyMap(), // Fixtures doesn't define task completions; leave empty
|
|
subscription = Fixtures.premiumSubscription,
|
|
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,
|
|
)
|
|
}
|