6 user-facing ViewModels now accept optional `dataManager: DataManagerObservable = .shared` init param — production call-sites unchanged; tests inject fixture-backed observables. Refactored: ResidenceViewModel, TaskViewModel, ContractorViewModel, DocumentViewModel, ProfileViewModel, LoginViewModel. DataManagerObservable gains test-only init(observeSharedDataManager:) + convenience init(kotlin: IDataManager). SnapshotGalleryTests.setUp() resets .shared to FixtureDataManager.empty() per test; populated tests call seedPopulated() to copy every StateFlow from FixtureDataManager.populated() onto .shared synchronously. 15 populated surfaces × 2 modes = 30 new PNGs. iOS goldens: 58 → 88. 44 SnapshotGalleryTests green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
601 lines
21 KiB
Swift
601 lines
21 KiB
Swift
//
|
||
// 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<V>(_ 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<V>(_ 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<V>(_ 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<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(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<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)
|
||
}
|
||
}
|