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
@@ -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,
)
/**