Files
honeyDueKMP/iosApp/HoneyDueTests/SnapshotGalleryTests.swift
Trey T 6c3c9d3e0c Coverage: iOS ViewModel DI seam + populated-state snapshots
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>
2026-04-19 01:45:04 -05:00

601 lines
21 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/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 8001000 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)
}
}