Files
honeyDueKMP/iosApp/HoneyDueTests/GalleryManifestParityTest.swift
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

96 lines
3.9 KiB
Swift

//
// GalleryManifestParityTest.swift
// HoneyDueTests
//
// Parity gate asserts `SnapshotGalleryTests.swift`'s covered-screen
// set matches exactly the subset of screens in the canonical
// `GalleryScreens` manifest (in
// `composeApp/src/commonMain/.../testing/GalleryManifest.kt`) with
// `Platform.IOS` in their `platforms`.
//
// If this fails, either:
// - A new `test_<name>()` was added to `SnapshotGalleryTests.swift`
// but the name isn't in the canonical manifest add it to
// `GalleryScreens.all`.
// - A new screen was added to the manifest but there's no matching
// `test_<name>()` function in the Swift test file write one.
// - A rename landed on only one side reconcile.
//
// Together with the Android `GalleryManifestParityTest`, this keeps
// the two platforms from silently drifting apart in coverage.
//
import XCTest
import ComposeApp
@MainActor
final class GalleryManifestParityTest: XCTestCase {
/// Canonical names of every surface covered by
/// `SnapshotGalleryTests.swift`. This must be updated whenever a
/// new `test_<name>()` is added to the suite. The parity assertion
/// below catches a missed update.
///
/// Using a hand-maintained list (rather than runtime introspection
/// of `XCTestCase` selectors) keeps the contract explicit and makes
/// drifts obvious in a diff.
private static let iosCoveredScreens: Set<String> = [
// Auth
"login", "register", "forgot_password", "verify_reset_code",
"reset_password", "verify_email",
// Onboarding
"onboarding_welcome", "onboarding_value_props",
"onboarding_create_account", "onboarding_verify_email",
"onboarding_location", "onboarding_name_residence",
"onboarding_home_profile", "onboarding_join_residence",
"onboarding_first_task", "onboarding_subscription",
// Residences
"residences", "residence_detail", "add_residence",
"edit_residence", "join_residence", "manage_users",
// Tasks
"all_tasks", "add_task", "add_task_with_residence",
"edit_task", "complete_task", "task_suggestions",
"task_templates_browser",
// Contractors
"contractors", "contractor_detail",
// Documents
"documents_warranties", "document_detail", "add_document",
"edit_document",
// Profile / settings
"profile", "profile_edit", "notification_preferences",
"theme_selection",
// Subscription
"feature_comparison",
]
func test_ios_surfaces_match_canonical_manifest() {
// `GalleryScreens.shared.forIos` is the Swift-bridged map of
// `GalleryScreen` keyed by canonical name. SKIE exposes the
// Kotlin `object GalleryScreens` as a Swift type with a
// `shared` instance accessor.
let manifestKeys = Set(GalleryScreens.shared.forIos.keys.compactMap { $0 as? String })
let missing = manifestKeys.subtracting(Self.iosCoveredScreens)
let extra = Self.iosCoveredScreens.subtracting(manifestKeys)
if !missing.isEmpty || !extra.isEmpty {
var message = "iOS SnapshotGalleryTests drifted from canonical manifest.\n"
if !missing.isEmpty {
message += "\nScreens in manifest but missing test_<name>() in Swift:\n"
for name in missing.sorted() {
message += " - \(name)\n"
}
}
if !extra.isEmpty {
message += "\nScreens covered by Swift tests but missing from manifest:\n"
for name in extra.sorted() {
message += " - \(name)\n"
}
}
message += "\nReconcile by editing com.tt.honeyDue.testing.GalleryScreens and/or\n"
message += "SnapshotGalleryTests.swift (plus iosCoveredScreens above) so all three agree.\n"
XCTFail(message)
}
}
}