Files
honeyDueKMP/iosApp/HoneyDueTests/SnapshotGalleryTests.swift
Trey T 6f2fb629c9 P3: iOS parity gallery (swift-snapshot-testing, 1.17.0+)
Records 58 baseline PNGs across 29 primary SwiftUI screens × {light, dark}
for the honeyDue iOS app. Covers auth, password reset, onboarding,
residences, tasks, contractors, documents, profile, and subscription
surfaces — everything that's instantiable without complex runtime context.

State coverage is empty-only for this first pass: views currently spin up
their own ViewModels which read DataManagerObservable.shared directly, and
the test host has no login → all flows render their empty states. A
follow-up PR adds an optional `dataManager:` init param to each
*ViewModel.swift so populated-state snapshots (backed by P1's
FixtureDataManager) can land.

Tolerance knobs: pixelPrecision 0.97 / perceptualPrecision 0.95 — tuned to
absorb animation-frame drift (gradient blobs, focus rings) while catching
structural regressions.

Tooling: swift-snapshot-testing SPM dep added to the HoneyDueTests target
only (not the app target) via scripts/add_snapshot_testing.rb, which is an
idempotent xcodeproj-gem script so the edit is reproducible rather than a
hand-crafted pbxproj diff. Pins resolve to 1.19.2 (up-to-next-major from
the 1.17.0 plan floor).

Blocks regressions at PR time via `xcodebuild test
-only-testing:HoneyDueTests/SnapshotGalleryTests`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:37:09 -05:00

340 lines
10 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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
// -----------------
// Set `isRecording = true` in `setUp()`, run the target, then flip back to
// `false` before committing. CI fails the build if a screen diverges from
// its golden by more than the precision threshold.
//
@preconcurrency import SnapshotTesting
import SwiftUI
import XCTest
import ComposeApp
@testable import honeyDue
@MainActor
final class SnapshotGalleryTests: XCTestCase {
// Flip to true locally, run the scheme, revert, commit.
private static let recordMode: SnapshotTestingConfiguration.Record = .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
private func snap<V: View>(
_ 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(userInterfaceStyle: .light)
),
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(userInterfaceStyle: .dark)
),
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<Value, Content: View>: View {
@State private var value: Value
let content: (Binding<Value>) -> Content
init(_ initial: Value, @ViewBuilder content: @escaping (Binding<Value>) -> Content) {
_value = State(initialValue: initial)
self.content = content
}
var body: some View {
content($value)
}
}