// // SnapshotGalleryTests.swift // HoneyDueTests // // P3 — iOS parity gallery. Records baseline PNGs for primary SwiftUI screens // across {empty-state} × {light, dark}. Complements the Android Roborazzi // gallery (P2); both platforms consume the same Kotlin FixtureDataManager // fixtures (P1), so any layout divergence between Android + iOS renders of // the same screen is a real parity bug — not a test-data mismatch. // // Current state coverage // ---------------------- // * Empty (signed-in user with no residences / tasks / docs / contractors) // is captured for every screen below via the default // `DataManagerObservable.shared`, which has no data loaded when tests run // because no login has occurred in the test host. // * Populated state is BLOCKED on a follow-up ViewModel-injection refactor: // current SwiftUI screens instantiate their ViewModels via // `@StateObject private var viewModel = FooViewModel()`, and those // ViewModels read directly from `DataManagerObservable.shared` rather // than an injected `IDataManager`. Swapping the singleton is unsafe in // parallel tests. Follow-up PR: add an optional `dataManager:` init param // to each `*ViewModel.swift` and thread it from here via // `.environment(\.dataManager, ...)`. // // 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 the `SNAPSHOT_TESTING_RECORD` env var to `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() } } // 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 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 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_add_residence_empty() { snap("add_residence_empty") { AddResidenceView(isPresented: .constant(true), onResidenceCreated: nil) } } func test_join_residence_empty() { snap("join_residence_empty") { JoinResidenceView(onJoined: {}) } } // MARK: - Tasks func test_all_tasks_empty() { snap("all_tasks_empty") { NavigationStack { AllTasksView() } } } func test_add_task_empty() { snap("add_task_empty") { 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_task_suggestions_empty() { snap("task_suggestions_empty") { TaskSuggestionsView( suggestions: [], onSelect: { _ in } ) } } func test_task_templates_browser_empty() { snap("task_templates_browser_empty") { NavigationStack { TaskTemplatesBrowserView(onSelect: { _ in }) } } } // MARK: - Contractor func test_contractors_list_empty() { snap("contractors_list_empty") { NavigationStack { ContractorsListView() } } } // MARK: - Documents func test_documents_warranties_empty() { snap("documents_warranties_empty") { NavigationStack { DocumentsWarrantiesView(residenceId: nil) } } } // MARK: - Profile func test_profile_tab_empty() { snap("profile_tab_empty") { NavigationStack { ProfileTabView() } } } func test_profile_edit_empty() { snap("profile_edit_empty") { NavigationStack { ProfileView() } } } func test_notification_preferences_empty() { snap("notification_preferences_empty") { NavigationStack { NotificationPreferencesView() } } } func test_theme_selection_empty() { snap("theme_selection_empty") { NavigationStack { ThemeSelectionView() } } } // MARK: - Subscription func test_feature_comparison_empty() { snap("feature_comparison_empty") { 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) } }