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>
This commit is contained in:
Trey T
2026-04-18 19:37:09 -05:00
parent 47eaf5a0c0
commit 6f2fb629c9
62 changed files with 465 additions and 9 deletions

View File

@@ -0,0 +1,339 @@
//
// 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)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1001 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 808 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 916 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1003 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 896 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 967 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 878 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 895 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 743 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 919 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 820 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 939 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 845 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1001 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 808 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1010 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 909 KiB