// // 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. // // 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. // // Recording goldens // ----------------- // Preferred: `make record-snapshots` (or `./scripts/record_snapshots.sh // --ios-only`). The script exports `SNAPSHOT_TESTING_RECORD=1` in the // xcodebuild env, deletes the old `__Snapshots__/SnapshotGalleryTests` // directory, runs the target, then invokes the shared PNG optimizer. // // 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. // // 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 800–1000 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. // @preconcurrency import SnapshotTesting import SwiftUI import XCTest import ComposeApp @testable import honeyDue @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. private static var recordMode: SnapshotTestingConfiguration.Record { let env = ProcessInfo.processInfo.environment["SNAPSHOT_TESTING_RECORD"] ?? "" return (env == "1" || env.lowercased() == "true") ? .all : .missing } override func invokeTest() { withSnapshotTesting(record: Self.recordMode) { super.invokeTest() } } 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() } // 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) } /// Seed `DataManagerObservable.shared` from `FixtureDataManager.populated()` /// so the next view instantiated in the test picks up fully-populated /// caches via its Combine subscription. 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 observable.residences = fixture.residences.value observable.myResidences = fixture.myResidences.value observable.totalSummary = fixture.totalSummary.value observable.residenceSummaries = mapInt(fixture.residenceSummaries.value) observable.allTasks = fixture.allTasks.value observable.tasksByResidence = mapInt(fixture.tasksByResidence.value) observable.documents = fixture.documents.value observable.documentsByResidence = mapIntArray(fixture.documentsByResidence.value) observable.contractors = fixture.contractors.value observable.subscription = fixture.subscription.value observable.upgradeTriggers = mapString(fixture.upgradeTriggers.value) observable.featureBenefits = fixture.featureBenefits.value observable.promotions = fixture.promotions.value observable.residenceTypes = fixture.residenceTypes.value observable.taskFrequencies = fixture.taskFrequencies.value observable.taskPriorities = fixture.taskPriorities.value observable.taskCategories = fixture.taskCategories.value observable.contractorSpecialties = fixture.contractorSpecialties.value observable.taskTemplates = fixture.taskTemplates.value observable.taskTemplatesGrouped = fixture.taskTemplatesGrouped.value let hasLookups = !fixture.residenceTypes.value.isEmpty || !fixture.taskPriorities.value.isEmpty || !fixture.taskCategories.value.isEmpty observable.lookupsInitialized = hasLookups observable.isInitialized = hasLookups } private func mapInt(_ kotlinMap: Any?) -> [Int32: V] { guard let nsDict = kotlinMap as? NSDictionary else { return [:] } var result: [Int32: V] = [:] for key in nsDict.allKeys { guard let value = nsDict[key], let typed = value as? V else { continue } if let ki = key as? KotlinInt { result[ki.int32Value] = typed } else if let ns = key as? NSNumber { result[ns.int32Value] = typed } } return result } private func mapIntArray(_ kotlinMap: Any?) -> [Int32: [V]] { guard let nsDict = kotlinMap as? NSDictionary else { return [:] } var result: [Int32: [V]] = [:] for key in nsDict.allKeys { guard let value = nsDict[key] else { continue } let typed: [V] if let arr = value as? [V] { typed = arr } else if let nsArr = value as? NSArray { typed = nsArr.compactMap { $0 as? V } } else { continue } if let ki = key as? KotlinInt { result[ki.int32Value] = typed } else if let ns = key as? NSNumber { result[ns.int32Value] = typed } } return result } private func mapString(_ kotlinMap: Any?) -> [String: V] { if let direct = kotlinMap as? [String: V] { return direct } guard let nsDict = kotlinMap as? NSDictionary else { return [:] } var result: [String: V] = [:] for key in nsDict.allKeys { guard let s = key as? String, let v = nsDict[key] as? V else { continue } result[s] = v } return result } // MARK: - 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( _ name: String, file: StaticString = #filePath, testName: String = #function, line: UInt = #line, @ViewBuilder content: () -> V ) { let view = content() .environmentObject(ThemeManager.shared) .environment(\.dataManager, DataManagerObservable.shared) assertSnapshot( of: view.environment(\.colorScheme, .light), as: .image( precision: Self.pixelPrecision, perceptualPrecision: Self.perceptualPrecision, layout: .device(config: .iPhone13), traits: .init(traitsFrom: [ UITraitCollection(userInterfaceStyle: .light), UITraitCollection(displayScale: Self.forcedDisplayScale), ]) ), named: "\(name)_light", file: file, testName: testName, line: line ) assertSnapshot( of: view.environment(\.colorScheme, .dark), as: .image( precision: Self.pixelPrecision, perceptualPrecision: Self.perceptualPrecision, layout: .device(config: .iPhone13), traits: .init(traitsFrom: [ UITraitCollection(userInterfaceStyle: .dark), UITraitCollection(displayScale: Self.forcedDisplayScale), ]) ), named: "\(name)_dark", file: file, testName: testName, line: line ) } // MARK: - Auth flow (empty-only; these screens have no backing data) func test_login_empty() { snap("login_empty") { LoginView(resetToken: .constant(nil), onLoginSuccess: nil) } } func test_register_empty() { snap("register_empty") { RegisterView(isPresented: .constant(true), onVerified: nil) } } func test_verify_email_empty() { snap("verify_email_empty") { VerifyEmailView(onVerifySuccess: {}, onLogout: {}) } } func test_forgot_password_empty() { let vm = PasswordResetViewModel() snap("forgot_password_empty") { NavigationStack { ForgotPasswordView(viewModel: vm) } } } func test_verify_reset_code_empty() { let vm = PasswordResetViewModel() vm.email = "user@example.com" vm.currentStep = .verifyCode snap("verify_reset_code_empty") { NavigationStack { VerifyResetCodeView(viewModel: vm) } } } func test_reset_password_empty() { let vm = PasswordResetViewModel() vm.currentStep = .resetPassword snap("reset_password_empty") { NavigationStack { ResetPasswordView(viewModel: vm, onSuccess: {}) } } } // MARK: - Onboarding (empty-only; these screens are pre-data) func test_onboarding_welcome_empty() { snap("onboarding_welcome_empty") { OnboardingWelcomeView(onStartFresh: {}, onJoinExisting: {}, onLogin: {}) } } func test_onboarding_value_props_empty() { snap("onboarding_value_props_empty") { OnboardingValuePropsView(onContinue: {}, onSkip: {}, onBack: {}) } } func test_onboarding_create_account_empty() { snap("onboarding_create_account_empty") { OnboardingCreateAccountView(onAccountCreated: { _ in }, onBack: {}) } } func test_onboarding_verify_email_empty() { snap("onboarding_verify_email_empty") { OnboardingVerifyEmailView(onVerified: {}, onLogout: {}) } } func test_onboarding_name_residence_empty() { snap("onboarding_name_residence_empty") { StatefulPreviewWrapper("") { binding in OnboardingNameResidenceView( residenceName: binding, onContinue: {}, onBack: {} ) } } } func test_onboarding_join_residence_empty() { snap("onboarding_join_residence_empty") { OnboardingJoinResidenceView(onJoined: {}, onSkip: {}) } } func test_onboarding_subscription_empty() { snap("onboarding_subscription_empty") { OnboardingSubscriptionView(onSubscribe: {}, onSkip: {}) } } func test_onboarding_first_task_empty() { snap("onboarding_first_task_empty") { OnboardingFirstTaskView( residenceName: "My House", onTaskAdded: {}, onSkip: {} ) } } // 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: {}) } } // MARK: - Tasks func test_all_tasks_empty() { snap("all_tasks_empty") { NavigationStack { AllTasksView() } } } func test_all_tasks_populated() { seedPopulated() snap("all_tasks_populated") { NavigationStack { AllTasksView() } } } 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_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_task_templates_browser_empty() { snap("task_templates_browser_empty") { NavigationStack { TaskTemplatesBrowserView(onSelect: { _ in }) } } } func test_task_templates_browser_populated() { seedPopulated() snap("task_templates_browser_populated") { NavigationStack { TaskTemplatesBrowserView(onSelect: { _ in }) } } } // MARK: - Contractor func test_contractors_list_empty() { snap("contractors_list_empty") { NavigationStack { ContractorsListView() } } } func test_contractors_list_populated() { seedPopulated() snap("contractors_list_populated") { NavigationStack { ContractorsListView() } } } // MARK: - Documents func test_documents_warranties_empty() { snap("documents_warranties_empty") { NavigationStack { DocumentsWarrantiesView(residenceId: nil) } } } func test_documents_warranties_populated() { seedPopulated() snap("documents_warranties_populated") { NavigationStack { DocumentsWarrantiesView(residenceId: nil) } } } // MARK: - Profile func test_profile_tab_empty() { snap("profile_tab_empty") { NavigationStack { ProfileTabView() } } } func test_profile_tab_populated() { seedPopulated() snap("profile_tab_populated") { NavigationStack { ProfileTabView() } } } func test_profile_edit_empty() { snap("profile_edit_empty") { NavigationStack { ProfileView() } } } func test_profile_edit_populated() { seedPopulated() snap("profile_edit_populated") { NavigationStack { ProfileView() } } } func test_notification_preferences_empty() { snap("notification_preferences_empty") { 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") { NavigationStack { ThemeSelectionView() } } } // MARK: - Subscription func test_feature_comparison_empty() { snap("feature_comparison_empty") { 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 /// Lets us hand a view a `@Binding` backed by a local `@State` so that /// views which mutate bindings (e.g. `OnboardingNameResidenceView`) render /// correctly inside a snapshot test — which has no surrounding state host. private struct StatefulPreviewWrapper: View { @State private var value: Value let content: (Binding) -> Content init(_ initial: Value, @ViewBuilder content: @escaping (Binding) -> Content) { _value = State(initialValue: initial) self.content = content } var body: some View { content($value) } }