// // SnapshotGalleryTests.swift // HoneyDueTests // // 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`). // // 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 // ----------------- // 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. // // Rendering scale // --------------- // We force `displayScale: 2.0` on every snapshot. @3x native on modern // iPhones produced 800–1000 KB PNGs per image on gradient-heavy views. // @2x keeps captures under the 400 KB CI budget after zopflipng. // @preconcurrency import SnapshotTesting import SwiftUI import XCTest import ComposeApp @testable import honeyDue @MainActor final class SnapshotGalleryTests: XCTestCase { // 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 } override func invokeTest() { withSnapshotTesting(record: Self.recordMode) { super.invokeTest() } } // 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 /// 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` 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. 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: - Snap helpers /// Capture a DataFree surface: 2 PNGs (`_light`, `_dark`) /// against the lookups-seeded empty fixture. private func snapDataFree( _ 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 (`_empty_light`, /// `_empty_dark`, `_populated_light`, `_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( _ 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 /// `_light` and `_dark` goldens. private func assertLightDark( _ view: V, name: String, file: StaticString, testName: String, line: UInt ) { 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: - 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`. 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() { snapDataFree("register") { RegisterView(isPresented: .constant(true), onVerified: nil) } } func test_forgot_password() { let vm = PasswordResetViewModel() snapDataFree("forgot_password") { NavigationStack { ForgotPasswordView(viewModel: vm) } } } func test_verify_reset_code() { let vm = PasswordResetViewModel() vm.email = "user@example.com" vm.currentStep = .verifyCode snapDataFree("verify_reset_code") { NavigationStack { VerifyResetCodeView(viewModel: vm) } } } func test_reset_password() { let vm = PasswordResetViewModel() vm.currentStep = .resetPassword snapDataFree("reset_password") { NavigationStack { ResetPasswordView(viewModel: vm, onSuccess: {}) } } } func test_verify_email() { snapDataFree("verify_email") { VerifyEmailView(onVerifySuccess: {}, onLogout: {}) } } // ======================================================================== // MARK: - Onboarding (DataFree, except first_task) // ======================================================================== func test_onboarding_welcome() { snapDataFree("onboarding_welcome") { OnboardingWelcomeView(onStartFresh: {}, onJoinExisting: {}, onLogin: {}) } } func test_onboarding_value_props() { snapDataFree("onboarding_value_props") { OnboardingValuePropsView(onContinue: {}, onSkip: {}, onBack: {}) } } func test_onboarding_create_account() { snapDataFree("onboarding_create_account") { OnboardingCreateAccountView(onAccountCreated: { _ in }, onBack: {}) } } func test_onboarding_verify_email() { snapDataFree("onboarding_verify_email") { OnboardingVerifyEmailView(onVerified: {}, onLogout: {}) } } 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, onContinue: {}, onBack: {} ) } } } 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_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: {}) } } // ======================================================================== // 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) seedPopulated() 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() { snapDataCarrying("all_tasks") { NavigationStack { AllTasksView() } } } func test_add_task() { snapDataFree("add_task") { AddTaskView(residenceId: self.fixtureResidenceId, isPresented: .constant(true)) } } func test_add_task_with_residence() { snapDataFree("add_task_with_residence") { AddTaskWithResidenceView( isPresented: .constant(true), residences: DataManagerObservable.shared.myResidences?.residences ?? [] ) } } 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_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 }) } } } // ======================================================================== // MARK: - Contractors // ======================================================================== func test_contractors() { snapDataCarrying("contractors") { 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() { snapDataCarrying("documents_warranties") { NavigationStack { DocumentsWarrantiesView(residenceId: nil) } } } 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() // 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 Kotlin→Swift 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() ) } } func test_edit_document() { let doc = fixtureDocument ?? FixtureDataManager.shared.populated().documents.value.first! snapDataFree("edit_document") { NavigationStack { EditDocumentView(document: doc) } } } // ======================================================================== // MARK: - Profile / settings // ======================================================================== func test_profile() { snapDataCarrying("profile") { NavigationStack { ProfileTabView() } } } func test_profile_edit() { snapDataFree("profile_edit") { NavigationStack { ProfileView() } } } func test_notification_preferences() { snapDataFree("notification_preferences") { NavigationStack { NotificationPreferencesView() } } } func test_theme_selection() { snapDataFree("theme_selection") { NavigationStack { ThemeSelectionView() } } } // ======================================================================== // MARK: - Subscription // ======================================================================== func test_feature_comparison() { snapDataFree("feature_comparison") { FeatureComparisonView(isPresented: .constant(true)) } } } // 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) } }