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:
95
iosApp/HoneyDueTests/GalleryManifestParityTest.swift
Normal file
95
iosApp/HoneyDueTests/GalleryManifestParityTest.swift
Normal file
@@ -0,0 +1,95 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user