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

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

View File

@@ -2,30 +2,26 @@
// SnapshotGalleryTests.swift
// HoneyDueTests
//
// P3/P5 iOS parity gallery. Records baseline PNGs for primary SwiftUI
// screens across {empty, populated} × {light, dark}. Complements the
// Android Roborazzi gallery; both platforms consume the same Kotlin
// `FixtureDataManager` fixtures, so any layout divergence between Android
// and iOS renders of the same screen is a real parity bug not a test
// data mismatch.
// iOS parity-gallery Roborazzi-equivalent. Records baseline PNGs for
// every iOS-reachable screen in the canonical
// `GalleryScreens` manifest (defined in
// `composeApp/src/commonMain/.../testing/GalleryManifest.kt`).
//
// State coverage
// --------------
// * Empty (signed-in user with no residences / tasks / docs / contractors)
// is captured by clearing `DataManagerObservable.shared`'s @Published
// caches before the view is instantiated.
// * Populated is captured by synchronously seeding those caches from
// `FixtureDataManager.populated()` via `DataManagerObservable(kotlin:)`
// and copying its values onto `.shared`. The seeded values persist
// because the production Kotlin `DataManager.shared` never emits during
// tests (no API calls are made), so the continuous observation tasks
// never overwrite our seed.
// * Each user-facing ViewModel gained a `dataManager:` init seam in the
// same commit so follow-up tests can inject a dedicated instance
// instead of mutating `.shared`. For the gallery we pick the simpler
// shared-seed path because the existing views use `@StateObject = VM()`
// without an init param, and refactoring every call-site to thread a
// VM through is out of scope.
// Variant matrix (driven by `GalleryCategory` in the manifest):
//
// DataCarrying surfaces 4 captures per surface:
// surface_empty_light.png (empty fixture, no lookups, light)
// surface_empty_dark.png (empty fixture, no lookups, dark)
// surface_populated_light.png (populated fixture, light)
// surface_populated_dark.png (populated fixture, dark)
//
// DataFree surfaces 2 captures per surface:
// surface_light.png (empty fixture, lookups seeded, light)
// surface_dark.png (empty fixture, lookups seeded, dark)
//
// The companion Android Roborazzi harness follows the identical rules,
// so any layout divergence between iOS and Android renders of the same
// screen is a real parity bug not a test data mismatch.
//
// Recording goldens
// -----------------
@@ -36,18 +32,13 @@
//
// Manual override: set `SNAPSHOT_TESTING_RECORD=1` in the Xcode scheme's
// Test action (Edit Scheme Test Arguments Environment Variables)
// and re-run the test target. CI fails the build if a screen diverges
// from its golden by more than the precision threshold.
// and re-run the test target.
//
// Rendering scale
// ---------------
// We force `displayScale: 2.0` on every snapshot. The iPhone 15 /
// iPhone 13 simulators default to the device's native 3x, which on a
// full-screen gradient-heavy SwiftUI view produced 8001000 KB PNGs
// per image. @2x halves the linear dimensions (2.25x fewer pixels) and
// is still plenty to catch layout regressions. Combined with
// `scripts/optimize_goldens.sh` (zopflipng / pngcrush) this keeps us
// under the 150 KB per-image budget enforced by CI.
// We force `displayScale: 2.0` on every snapshot. @3x native on modern
// iPhones produced 8001000 KB PNGs per image on gradient-heavy views.
// @2x keeps captures under the 400 KB CI budget after zopflipng.
//
@preconcurrency import SnapshotTesting
@@ -59,10 +50,11 @@ import ComposeApp
@MainActor
final class SnapshotGalleryTests: XCTestCase {
// Record mode is driven by the `SNAPSHOT_TESTING_RECORD` env var so
// `scripts/record_snapshots.sh` can flip it without mutating this file.
// When the var is unset/empty we only write missing goldens (`.missing`)
// so local dev runs never silently overwrite committed PNGs.
// MARK: - Configuration
/// Record mode is driven by the `SNAPSHOT_TESTING_RECORD` env var.
/// When unset/empty we only write missing goldens (`.missing`) so
/// local dev runs never silently overwrite committed PNGs.
private static var recordMode: SnapshotTestingConfiguration.Record {
let env = ProcessInfo.processInfo.environment["SNAPSHOT_TESTING_RECORD"] ?? ""
return (env == "1" || env.lowercased() == "true") ? .all : .missing
@@ -74,34 +66,34 @@ final class SnapshotGalleryTests: XCTestCase {
}
}
override func setUp() {
super.setUp()
// Default to empty state before every test. Each populated-state
// test explicitly calls `seedPopulated()` at the top of its body.
seedEmpty()
}
// Tuned so that a second run against goldens recorded on the first
// run passes green across SF Pro rendering jitter and anti-alias.
private static let pixelPrecision: Float = 0.97
private static let perceptualPrecision: Float = 0.95
private static let forcedDisplayScale: CGFloat = 2.0
// MARK: - Fixture seeding
/// Reset `DataManagerObservable.shared` to the empty-fixture baseline.
/// ViewModels that `.sink` on `.shared`'s publishers during init will
/// receive these values immediately and render the empty-state UI.
private func seedEmpty() {
copyFixture(FixtureDataManager.shared.empty(), into: DataManagerObservable.shared)
/// Clear and re-seed `DataManagerObservable.shared` with the empty
/// fixture. `seedLookups: false` mirrors the Android harness's
/// empty-variant for DataCarrying surfaces (empty forms show empty
/// dropdowns so populated-vs-empty PNGs differ). DataFree surfaces
/// pass `seedLookups: true` to match production behaviour (a user
/// with zero entities still sees the priority picker).
private func seedEmpty(seedLookups: Bool) {
copyFixture(
FixtureDataManager.shared.empty(seedLookups: seedLookups),
into: DataManagerObservable.shared
)
}
/// Seed `DataManagerObservable.shared` from `FixtureDataManager.populated()`
/// so the next view instantiated in the test picks up fully-populated
/// caches via its Combine subscription.
/// Seed `DataManagerObservable.shared` with the populated fixture.
private func seedPopulated() {
copyFixture(FixtureDataManager.shared.populated(), into: DataManagerObservable.shared)
}
/// Synchronously copy every StateFlow value from an `IDataManager`
/// fixture onto a `DataManagerObservable`'s `@Published` properties.
/// Mirrors the `init(kotlin:)` seed path but targets an existing
/// instance so we can reuse `.shared` (required because views
/// instantiate their ViewModels with the default `.shared` argument).
private func copyFixture(_ fixture: IDataManager, into observable: DataManagerObservable) {
observable.currentUser = fixture.currentUser.value
observable.isAuthenticated = fixture.currentUser.value != nil
@@ -187,38 +179,61 @@ final class SnapshotGalleryTests: XCTestCase {
return result
}
// MARK: - Helpers
// MARK: - Snap helpers
/// Snapshot a SwiftUI view in both light + dark modes under a stable
/// iPhone 13 layout. The screen is wrapped in a `NavigationStack` so that
/// any `.navigationTitle`/`.toolbar` chrome the view declares is rendered
/// identically to how it ships to users.
// Most HoneyDue screens animate on appear (OrganicBlobShape gradients,
// subtle pulses, TextField focus rings). A tiny amount of pixel drift
// between runs is expected; the goal of parity snapshots is to catch
// *structural* regressions, not single-pixel anti-alias differences.
// These thresholds were tuned so that a second run against goldens
// recorded on the first run passes green.
private static let pixelPrecision: Float = 0.97
private static let perceptualPrecision: Float = 0.95
/// Force @2x rendering regardless of the simulator device's native
/// `displayScale`. See the class-level "Rendering scale" comment for
/// rationale. 2.0 keeps PNGs under the size budget without sacrificing
/// the structural detail our parity gallery cares about.
private static let forcedDisplayScale: CGFloat = 2.0
private func snap<V: View>(
/// Capture a DataFree surface: 2 PNGs (`<name>_light`, `<name>_dark`)
/// against the lookups-seeded empty fixture.
private func snapDataFree<V: View>(
_ name: String,
file: StaticString = #filePath,
testName: String = #function,
line: UInt = #line,
@ViewBuilder content: () -> V
) {
seedEmpty(seedLookups: true)
let view = content()
.environmentObject(ThemeManager.shared)
.environment(\.dataManager, DataManagerObservable.shared)
assertLightDark(view, name: name, file: file, testName: testName, line: line)
}
/// Capture a DataCarrying surface: 4 PNGs (`<name>_empty_light`,
/// `<name>_empty_dark`, `<name>_populated_light`, `<name>_populated_dark`).
/// The view closure is invoked *after* each fixture seeding so the
/// view's ViewModels pick up the freshly-seeded `DataManagerObservable`
/// values on init (their `init(dataManager: = .shared)` path seeds
/// synchronously from the shared cache).
private func snapDataCarrying<V: View>(
_ name: String,
file: StaticString = #filePath,
testName: String = #function,
line: UInt = #line,
@ViewBuilder content: () -> V
) {
// Empty variant (no lookups so forms diff against populated).
seedEmpty(seedLookups: false)
let emptyView = content()
.environmentObject(ThemeManager.shared)
.environment(\.dataManager, DataManagerObservable.shared)
assertLightDark(emptyView, name: "\(name)_empty", file: file, testName: testName, line: line)
// Populated variant.
seedPopulated()
let populatedView = content()
.environmentObject(ThemeManager.shared)
.environment(\.dataManager, DataManagerObservable.shared)
assertLightDark(populatedView, name: "\(name)_populated", file: file, testName: testName, line: line)
}
/// Render `view` in both light and dark color schemes, writing
/// `<name>_light` and `<name>_dark` goldens.
private func assertLightDark<V: View>(
_ view: V,
name: String,
file: StaticString,
testName: String,
line: UInt
) {
assertSnapshot(
of: view.environment(\.colorScheme, .light),
as: .image(
@@ -253,78 +268,151 @@ final class SnapshotGalleryTests: XCTestCase {
)
}
// MARK: - Auth flow (empty-only; these screens have no backing data)
// MARK: - Fixture accessors
//
// Pull a realistic id / object out of the populated fixture so detail
// and edit surfaces can be instantiated with values that will actually
// have a match in the seeded observable. Using `.populated()` avoids
// depending on fixture ordering within `.shared`.
func test_login_empty() {
snap("login_empty") {
private var fixtureResidenceId: Int32 {
Int32(FixtureDataManager.shared.populated().residences.value.first?.id ?? 1)
}
private var fixtureResidence: ResidenceResponse? {
FixtureDataManager.shared.populated().residences.value.first
}
private var fixtureTask: TaskResponse? {
FixtureDataManager.shared.populated().allTasks.value?
.columns.first?.tasks.first
}
private var fixtureContractor: Contractor? {
FixtureDataManager.shared.populated().contractorDetail.value.values.first
}
private var fixtureContractorId: Int32 {
Int32(FixtureDataManager.shared.populated().contractors.value.first?.id ?? 1)
}
private var fixtureDocument: Document? {
FixtureDataManager.shared.populated().documents.value.first
}
private var fixtureDocumentId: Int32 {
Int32(fixtureDocument?.id ?? 1)
}
/// Hand-rolled `ResidenceUserResponse` list for `manage_users`.
/// The fixture doesn't seed residence-users (there's no
/// `usersByResidence` StateFlow yet on `IDataManager`), so we build
/// a minimal set here. Matches the Kotlin
/// `ResidenceUserResponse(id, username, email, firstName, lastName)`
/// shape.
private var fixtureResidenceUsers: [ResidenceUserResponse] {
let user = FixtureDataManager.shared.populated().currentUser.value
let ownerId = Int32(user?.id ?? 1)
return [
ResidenceUserResponse(
id: ownerId,
username: user?.username ?? "owner",
email: user?.email ?? "owner@example.com",
firstName: user?.firstName ?? "Sam",
lastName: user?.lastName ?? "Owner"
),
ResidenceUserResponse(
id: ownerId + 1,
username: "partner",
email: "partner@example.com",
firstName: "Taylor",
lastName: "Partner"
),
]
}
// ========================================================================
// MARK: - Auth (DataFree)
// ========================================================================
func test_login() {
snapDataFree("login") {
LoginView(resetToken: .constant(nil), onLoginSuccess: nil)
}
}
func test_register_empty() {
snap("register_empty") {
func test_register() {
snapDataFree("register") {
RegisterView(isPresented: .constant(true), onVerified: nil)
}
}
func test_verify_email_empty() {
snap("verify_email_empty") {
VerifyEmailView(onVerifySuccess: {}, onLogout: {})
}
}
func test_forgot_password_empty() {
func test_forgot_password() {
let vm = PasswordResetViewModel()
snap("forgot_password_empty") {
snapDataFree("forgot_password") {
NavigationStack { ForgotPasswordView(viewModel: vm) }
}
}
func test_verify_reset_code_empty() {
func test_verify_reset_code() {
let vm = PasswordResetViewModel()
vm.email = "user@example.com"
vm.currentStep = .verifyCode
snap("verify_reset_code_empty") {
snapDataFree("verify_reset_code") {
NavigationStack { VerifyResetCodeView(viewModel: vm) }
}
}
func test_reset_password_empty() {
func test_reset_password() {
let vm = PasswordResetViewModel()
vm.currentStep = .resetPassword
snap("reset_password_empty") {
snapDataFree("reset_password") {
NavigationStack { ResetPasswordView(viewModel: vm, onSuccess: {}) }
}
}
// MARK: - Onboarding (empty-only; these screens are pre-data)
func test_verify_email() {
snapDataFree("verify_email") {
VerifyEmailView(onVerifySuccess: {}, onLogout: {})
}
}
func test_onboarding_welcome_empty() {
snap("onboarding_welcome_empty") {
// ========================================================================
// MARK: - Onboarding (DataFree, except first_task)
// ========================================================================
func test_onboarding_welcome() {
snapDataFree("onboarding_welcome") {
OnboardingWelcomeView(onStartFresh: {}, onJoinExisting: {}, onLogin: {})
}
}
func test_onboarding_value_props_empty() {
snap("onboarding_value_props_empty") {
func test_onboarding_value_props() {
snapDataFree("onboarding_value_props") {
OnboardingValuePropsView(onContinue: {}, onSkip: {}, onBack: {})
}
}
func test_onboarding_create_account_empty() {
snap("onboarding_create_account_empty") {
func test_onboarding_create_account() {
snapDataFree("onboarding_create_account") {
OnboardingCreateAccountView(onAccountCreated: { _ in }, onBack: {})
}
}
func test_onboarding_verify_email_empty() {
snap("onboarding_verify_email_empty") {
func test_onboarding_verify_email() {
snapDataFree("onboarding_verify_email") {
OnboardingVerifyEmailView(onVerified: {}, onLogout: {})
}
}
func test_onboarding_name_residence_empty() {
snap("onboarding_name_residence_empty") {
func test_onboarding_location() {
snapDataFree("onboarding_location") {
OnboardingLocationContent(onLocationDetected: { _ in }, onSkip: {})
}
}
func test_onboarding_name_residence() {
snapDataFree("onboarding_name_residence") {
StatefulPreviewWrapper("") { binding in
OnboardingNameResidenceView(
residenceName: binding,
@@ -335,249 +423,336 @@ final class SnapshotGalleryTests: XCTestCase {
}
}
func test_onboarding_join_residence_empty() {
snap("onboarding_join_residence_empty") {
func test_onboarding_home_profile() {
snapDataFree("onboarding_home_profile") {
OnboardingHomeProfileContent(onContinue: {}, onSkip: {})
}
}
func test_onboarding_join_residence() {
snapDataFree("onboarding_join_residence") {
OnboardingJoinResidenceView(onJoined: {}, onSkip: {})
}
}
func test_onboarding_subscription_empty() {
snap("onboarding_subscription_empty") {
func test_onboarding_first_task() {
// Empty uses the default init (VM loads via APILayer, fails
// hermetically, renders error/empty). Populated uses the preview
// init passing a VM seeded with fixture `taskTemplatesGrouped`
// so the "Browse All" tab renders a populated template catalog.
seedEmpty(seedLookups: false)
let emptyView = OnboardingFirstTaskContent(
residenceName: "My House",
onTaskAdded: {}
)
.environmentObject(ThemeManager.shared)
.environment(\.dataManager, DataManagerObservable.shared)
assertLightDark(emptyView, name: "onboarding_first_task_empty", file: #filePath, testName: #function, line: #line)
seedPopulated()
let grouped = FixtureDataManager.shared.populated().taskTemplatesGrouped.value
let seededVM = OnboardingTasksViewModel(
initialSuggestions: [],
initialGrouped: grouped
)
let populatedView = OnboardingFirstTaskContent(
residenceName: "My House",
onTaskAdded: {},
viewModel: seededVM
)
.environmentObject(ThemeManager.shared)
.environment(\.dataManager, DataManagerObservable.shared)
assertLightDark(populatedView, name: "onboarding_first_task_populated", file: #filePath, testName: #function, line: #line)
}
func test_onboarding_subscription() {
snapDataFree("onboarding_subscription") {
OnboardingSubscriptionView(onSubscribe: {}, onSkip: {})
}
}
func test_onboarding_first_task_empty() {
snap("onboarding_first_task_empty") {
OnboardingFirstTaskView(
residenceName: "My House",
onTaskAdded: {},
onSkip: {}
// ========================================================================
// MARK: - Residences
// ========================================================================
func test_residences() {
snapDataCarrying("residences") {
NavigationStack { ResidencesListView() }
}
}
func test_residence_detail() {
snapDataCarrying("residence_detail") {
NavigationStack {
ResidenceDetailView(residenceId: self.fixtureResidenceId, preview: true)
}
}
}
func test_add_residence() {
snapDataFree("add_residence") {
AddResidenceView(isPresented: .constant(true), onResidenceCreated: nil)
}
}
func test_edit_residence() {
// `edit_residence` is DataFree: the form is populated from the
// passed-in `residence` object regardless of DataManager state.
let residence = fixtureResidence ?? FixtureDataManager.shared.populated().residences.value.first!
snapDataFree("edit_residence") {
NavigationStack {
EditResidenceView(residence: residence, isPresented: .constant(true))
}
}
}
func test_join_residence() {
snapDataFree("join_residence") {
JoinResidenceView(onJoined: {})
}
}
func test_manage_users() {
// Empty variant uses the default init (0 users, loading state);
// populated variant uses the preview init with a seeded user
// list. This produces a visible populated-vs-empty diff without
// waiting for `loadUsers()`'s APILayer round-trip.
seedEmpty(seedLookups: false)
let emptyView = NavigationStack {
ManageUsersView(
residenceId: fixtureResidenceId,
residenceName: fixtureResidence?.name ?? "My House",
isPrimaryOwner: true,
residenceOwnerId: Int32(fixtureResidence?.ownerId ?? 1),
residence: nil,
initialUsers: []
)
}
}
.environmentObject(ThemeManager.shared)
.environment(\.dataManager, DataManagerObservable.shared)
assertLightDark(emptyView, name: "manage_users_empty", file: #filePath, testName: #function, line: #line)
// MARK: - Residence
func test_residences_list_empty() {
snap("residences_list_empty") {
NavigationStack { ResidencesListView() }
}
}
func test_residences_list_populated() {
seedPopulated()
snap("residences_list_populated") {
NavigationStack { ResidencesListView() }
}
}
func test_add_residence_empty() {
snap("add_residence_empty") {
AddResidenceView(isPresented: .constant(true), onResidenceCreated: nil)
}
}
func test_add_residence_populated() {
seedPopulated()
snap("add_residence_populated") {
AddResidenceView(isPresented: .constant(true), onResidenceCreated: nil)
}
}
func test_join_residence_empty() {
snap("join_residence_empty") {
JoinResidenceView(onJoined: {})
}
}
func test_join_residence_populated() {
seedPopulated()
snap("join_residence_populated") {
JoinResidenceView(onJoined: {})
let populatedView = NavigationStack {
ManageUsersView(
residenceId: fixtureResidenceId,
residenceName: fixtureResidence?.name ?? "My House",
isPrimaryOwner: true,
residenceOwnerId: Int32(fixtureResidence?.ownerId ?? 1),
residence: fixtureResidence,
initialUsers: fixtureResidenceUsers
)
}
.environmentObject(ThemeManager.shared)
.environment(\.dataManager, DataManagerObservable.shared)
assertLightDark(populatedView, name: "manage_users_populated", file: #filePath, testName: #function, line: #line)
}
// ========================================================================
// MARK: - Tasks
// ========================================================================
func test_all_tasks_empty() {
snap("all_tasks_empty") {
func test_all_tasks() {
snapDataCarrying("all_tasks") {
NavigationStack { AllTasksView() }
}
}
func test_all_tasks_populated() {
seedPopulated()
snap("all_tasks_populated") {
NavigationStack { AllTasksView() }
func test_add_task() {
snapDataFree("add_task") {
AddTaskView(residenceId: self.fixtureResidenceId, isPresented: .constant(true))
}
}
func test_add_task_empty() {
snap("add_task_empty") {
AddTaskView(residenceId: 1, isPresented: .constant(true))
}
}
func test_add_task_populated() {
seedPopulated()
snap("add_task_populated") {
AddTaskView(residenceId: 1, isPresented: .constant(true))
}
}
func test_add_task_with_residence_empty() {
snap("add_task_with_residence_empty") {
AddTaskWithResidenceView(isPresented: .constant(true), residences: [])
}
}
func test_add_task_with_residence_populated() {
seedPopulated()
let fixtureResidences = DataManagerObservable.shared.myResidences?.residences ?? []
snap("add_task_with_residence_populated") {
AddTaskWithResidenceView(isPresented: .constant(true), residences: fixtureResidences)
}
}
func test_task_suggestions_empty() {
snap("task_suggestions_empty") {
TaskSuggestionsView(
suggestions: [],
onSelect: { _ in }
func test_add_task_with_residence() {
snapDataFree("add_task_with_residence") {
AddTaskWithResidenceView(
isPresented: .constant(true),
residences: DataManagerObservable.shared.myResidences?.residences ?? []
)
}
}
func test_task_suggestions_populated() {
seedPopulated()
// TaskSuggestionsView accepts [TaskTemplate] directly; pulling the
// first few templates from the populated fixture exercises the
// same layout as production's "For You" tab.
let templates = Array(DataManagerObservable.shared.taskTemplates.prefix(4))
snap("task_suggestions_populated") {
TaskSuggestionsView(suggestions: templates, onSelect: { _ in })
func test_edit_task() {
// `edit_task` is DataFree: the form is populated from the passed-in
// `task` object regardless of DataManager state.
let task = fixtureTask ?? FixtureDataManager.shared.populated()
.allTasks.value!.columns.first!.tasks.first!
snapDataFree("edit_task") {
NavigationStack {
EditTaskView(task: task, isPresented: .constant(true))
}
}
}
func test_task_templates_browser_empty() {
snap("task_templates_browser_empty") {
func test_complete_task() {
// DataFree: task and residence name are static props; the
// contractor picker is collapsed on first paint, so nothing
// visible diffs between empty and populated.
let task = fixtureTask ?? FixtureDataManager.shared.populated()
.allTasks.value!.columns.first!.tasks.first!
snapDataFree("complete_task") {
NavigationStack {
CompleteTaskView(task: task, onComplete: { _ in })
}
}
}
func test_task_suggestions() {
snapDataCarrying("task_suggestions") {
// TaskSuggestionsView accepts `suggestions: [TaskTemplate]`
// directly; pulling from `DataManagerObservable.shared` lets
// the populated variant show seeded templates and the empty
// variant show the empty state.
let templates = Array(DataManagerObservable.shared.taskTemplates.prefix(4))
return TaskSuggestionsView(suggestions: templates, onSelect: { _ in })
}
}
func test_task_templates_browser() {
snapDataCarrying("task_templates_browser") {
NavigationStack { TaskTemplatesBrowserView(onSelect: { _ in }) }
}
}
func test_task_templates_browser_populated() {
seedPopulated()
snap("task_templates_browser_populated") {
NavigationStack { TaskTemplatesBrowserView(onSelect: { _ in }) }
}
}
// ========================================================================
// MARK: - Contractors
// ========================================================================
// MARK: - Contractor
func test_contractors_list_empty() {
snap("contractors_list_empty") {
func test_contractors() {
snapDataCarrying("contractors") {
NavigationStack { ContractorsListView() }
}
}
func test_contractors_list_populated() {
seedPopulated()
snap("contractors_list_populated") {
NavigationStack { ContractorsListView() }
func test_contractor_detail() {
// Empty variant uses the default init (no pre-seeded detail);
// populated variant passes a VM built with a fixture contractor
// so `selectedContractor` is non-nil on the first frame.
seedEmpty(seedLookups: false)
let emptyView = NavigationStack {
ContractorDetailView(contractorId: self.fixtureContractorId)
}
.environmentObject(ThemeManager.shared)
.environment(\.dataManager, DataManagerObservable.shared)
assertLightDark(emptyView, name: "contractor_detail_empty", file: #filePath, testName: #function, line: #line)
seedPopulated()
let populatedView = NavigationStack {
ContractorDetailView(
contractorId: self.fixtureContractorId,
viewModel: ContractorViewModel(initialSelectedContractor: self.fixtureContractor)
)
}
.environmentObject(ThemeManager.shared)
.environment(\.dataManager, DataManagerObservable.shared)
assertLightDark(populatedView, name: "contractor_detail_populated", file: #filePath, testName: #function, line: #line)
}
// ========================================================================
// MARK: - Documents
// ========================================================================
func test_documents_warranties_empty() {
snap("documents_warranties_empty") {
func test_documents_warranties() {
snapDataCarrying("documents_warranties") {
NavigationStack { DocumentsWarrantiesView(residenceId: nil) }
}
}
func test_documents_warranties_populated() {
func test_document_detail() {
// Empty uses the default init (no pre-seeded detail); populated
// passes a VM pre-seeded with a fixture document so the detail
// renders on the first frame.
seedEmpty(seedLookups: false)
let emptyDocId = Int32(FixtureDataManager.shared.populated().documents.value.first?.id?.int32Value ?? 1)
let emptyView = NavigationStack {
DocumentDetailView(documentId: emptyDocId)
}
.environmentObject(ThemeManager.shared)
.environment(\.dataManager, DataManagerObservable.shared)
assertLightDark(emptyView, name: "document_detail_empty", file: #filePath, testName: #function, line: #line)
seedPopulated()
snap("documents_warranties_populated") {
NavigationStack { DocumentsWarrantiesView(residenceId: nil) }
// Pull the document off the freshly-seeded shared observable so
// we're definitely using the same instance the fixture pushed
// in. `FixtureDataManager.populated()` returns a *new* IDataManager
// on each call, so calling `.documents.value.first` on a separate
// `populated()` invocation isn't guaranteed to match (and in
// practice was returning nil here due to the KotlinSwift bridge
// not retaining the flow's element type).
guard let populatedDoc = DataManagerObservable.shared.documents.first,
let populatedId = populatedDoc.id?.int32Value else {
XCTFail("Populated fixture should seed at least one document onto DataManagerObservable.shared")
return
}
let populatedView = NavigationStack {
DocumentDetailView(
documentId: populatedId,
viewModel: DocumentViewModelWrapper(initialDocument: populatedDoc)
)
}
.environmentObject(ThemeManager.shared)
.environment(\.dataManager, DataManagerObservable.shared)
assertLightDark(populatedView, name: "document_detail_populated", file: #filePath, testName: #function, line: #line)
}
func test_add_document() {
snapDataFree("add_document") {
AddDocumentView(
residenceId: self.fixtureResidenceId,
initialDocumentType: "other",
isPresented: .constant(true),
documentViewModel: DocumentViewModel()
)
}
}
// MARK: - Profile
func test_edit_document() {
let doc = fixtureDocument ?? FixtureDataManager.shared.populated().documents.value.first!
snapDataFree("edit_document") {
NavigationStack {
EditDocumentView(document: doc)
}
}
}
func test_profile_tab_empty() {
snap("profile_tab_empty") {
// ========================================================================
// MARK: - Profile / settings
// ========================================================================
func test_profile() {
snapDataCarrying("profile") {
NavigationStack { ProfileTabView() }
}
}
func test_profile_tab_populated() {
seedPopulated()
snap("profile_tab_populated") {
NavigationStack { ProfileTabView() }
}
}
func test_profile_edit_empty() {
snap("profile_edit_empty") {
func test_profile_edit() {
snapDataFree("profile_edit") {
NavigationStack { ProfileView() }
}
}
func test_profile_edit_populated() {
seedPopulated()
snap("profile_edit_populated") {
NavigationStack { ProfileView() }
}
}
func test_notification_preferences_empty() {
snap("notification_preferences_empty") {
func test_notification_preferences() {
snapDataFree("notification_preferences") {
NavigationStack { NotificationPreferencesView() }
}
}
func test_notification_preferences_populated() {
seedPopulated()
snap("notification_preferences_populated") {
NavigationStack { NotificationPreferencesView() }
}
}
func test_theme_selection_empty() {
snap("theme_selection_empty") {
NavigationStack { ThemeSelectionView() }
}
}
func test_theme_selection_populated() {
seedPopulated()
snap("theme_selection_populated") {
func test_theme_selection() {
snapDataFree("theme_selection") {
NavigationStack { ThemeSelectionView() }
}
}
// ========================================================================
// MARK: - Subscription
// ========================================================================
func test_feature_comparison_empty() {
snap("feature_comparison_empty") {
func test_feature_comparison() {
snapDataFree("feature_comparison") {
FeatureComparisonView(isPresented: .constant(true))
}
}
func test_feature_comparison_populated() {
seedPopulated()
snap("feature_comparison_populated") {
FeatureComparisonView(isPresented: .constant(true))
}
}
// NOTE: UpgradeFeatureView is intentionally excluded from the parity
// gallery. It reads `SubscriptionCacheWrapper.shared` and
// `StoreKitManager.shared` on appear, both of which populate
// asynchronously in a way that isn't deterministic inside a snapshot
// test. Follow-up PR should add a fixture-friendly init override
// mirroring the `dataManager:` pattern.
}
// MARK: - StatefulPreviewWrapper

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 549 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 435 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 555 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 793 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 709 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 888 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 748 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 520 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 700 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 740 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Some files were not shown because too many files have changed in this diff Show More