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

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,
)
}