Single source of truth: `com.tt.honeyDue.testing.GalleryScreens` lists every user-reachable screen with its category (DataCarrying / DataFree) and per-platform reachability. Both platforms' test harnesses are CI-gated against it — `GalleryManifestParityTest` on each side fails if the surface list drifts from the manifest. Variant matrix by category: DataCarrying captures 4 PNGs (empty/populated × light/dark), DataFree captures 2 (light/dark only). Empty variants for DataCarrying use `FixtureDataManager.empty(seedLookups = false)` so form screens that only read DM lookups can diff against populated. Detail-screen rendering fixed on both platforms. Root cause: VM `stateIn(Eagerly, initialValue = …)` closures evaluated `_selectedX.value` before screen-side `LaunchedEffect` / `.onAppear` could set the id, leaving populated captures byte-identical to empty. Kotlin: `ContractorViewModel` + `DocumentViewModel` accept `initialSelectedX: Int? = null` so the id is set in the primary constructor before `stateIn` computes its seed. Swift: `ContractorViewModel`, `DocumentViewModelWrapper`, `ResidenceViewModel`, `OnboardingTasksViewModel` gained pre-seed init params. `ContractorDetailView`, `DocumentDetailView`, `ResidenceDetailView`, `OnboardingFirstTaskContent` gained test/preview init overloads that accept the pre-seeded VM. Corresponding view bodies prefer cached success state over loading/error — avoids a spinner flashing over already-visible content during background refreshes (production benefit too). Real production bug fixed along the way: `DataManager.clear()` was missing `_contractorDetail`, `_documentDetail`, `_contractorsByResidence`, `_taskCompletions`, `_notificationPreferences`. On logout these maps leaked across user sessions; in the gallery they leaked the previous surface's populated state into the next surface's empty capture. `ImagePicker.android.kt` guards `rememberCameraPicker` with `LocalInspectionMode` — `FileProvider.getUriForFile` can't resolve the Robolectric test-cache path, so `add_document` / `edit_document` previously failed the entire capture. Honest reclassifications: `complete_task`, `manage_users`, and `task_suggestions` moved to DataFree. Their first-paint visible state is driven by static props or APILayer calls, not by anything on `IDataManager` — populated would be byte-identical to empty without a significant production rewire. The manifest comments call this out. Manifest counts after all moves: 43 screens = 12 DataCarrying + 31 DataFree, 37 on both platforms + 3 Android-only (home, documents, biometric_lock) + 3 iOS-only (documents_warranties, add_task, profile_edit). Test results after full record: Android: 11/11 DataCarrying diff populated vs empty iOS: 12/12 DataCarrying diff populated vs empty Also in this change: - `scripts/build_parity_gallery.py` parses the Kotlin manifest directly, renders rows in product-flow order, shows explicit `[missing — <platform>]` placeholders for expected-but-absent captures and muted `not on <platform>` placeholders for platform-specific screens. Docs regenerated. - `scripts/cleanup_orphan_goldens.sh` safely removes PNGs from prior test configurations (theme-named, compare artifacts, legacy empty/populated pairs for what is now DataFree). Dry-run by default. - `docs/parity-gallery.md` rewritten: canonical-manifest workflow, adding-a-screen guide, variant matrix explained. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
776 lines
29 KiB
Swift
776 lines
29 KiB
Swift
//
|
||
// SnapshotGalleryTests.swift
|
||
// HoneyDueTests
|
||
//
|
||
// iOS parity-gallery Roborazzi-equivalent. Records baseline PNGs for
|
||
// every iOS-reachable screen in the canonical
|
||
// `GalleryScreens` manifest (defined in
|
||
// `composeApp/src/commonMain/.../testing/GalleryManifest.kt`).
|
||
//
|
||
// Variant matrix (driven by `GalleryCategory` in the manifest):
|
||
//
|
||
// DataCarrying surfaces — 4 captures per surface:
|
||
// surface_empty_light.png (empty fixture, no lookups, light)
|
||
// surface_empty_dark.png (empty fixture, no lookups, dark)
|
||
// surface_populated_light.png (populated fixture, light)
|
||
// surface_populated_dark.png (populated fixture, dark)
|
||
//
|
||
// DataFree surfaces — 2 captures per surface:
|
||
// surface_light.png (empty fixture, lookups seeded, light)
|
||
// surface_dark.png (empty fixture, lookups seeded, dark)
|
||
//
|
||
// The companion Android Roborazzi harness follows the identical rules,
|
||
// so any layout divergence between iOS and Android renders of the same
|
||
// screen is a real parity bug — not a test data mismatch.
|
||
//
|
||
// 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.
|
||
//
|
||
// Rendering scale
|
||
// ---------------
|
||
// We force `displayScale: 2.0` on every snapshot. @3x native on modern
|
||
// iPhones produced 800–1000 KB PNGs per image on gradient-heavy views.
|
||
// @2x keeps captures under the 400 KB CI budget after zopflipng.
|
||
//
|
||
|
||
@preconcurrency import SnapshotTesting
|
||
import SwiftUI
|
||
import XCTest
|
||
import ComposeApp
|
||
@testable import honeyDue
|
||
|
||
@MainActor
|
||
final class SnapshotGalleryTests: XCTestCase {
|
||
|
||
// MARK: - Configuration
|
||
|
||
/// Record mode is driven by the `SNAPSHOT_TESTING_RECORD` env var.
|
||
/// When 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()
|
||
}
|
||
}
|
||
|
||
// Tuned so that a second run against goldens recorded on the first
|
||
// run passes green across SF Pro rendering jitter and anti-alias.
|
||
private static let pixelPrecision: Float = 0.97
|
||
private static let perceptualPrecision: Float = 0.95
|
||
private static let forcedDisplayScale: CGFloat = 2.0
|
||
|
||
// MARK: - Fixture seeding
|
||
|
||
/// Clear and re-seed `DataManagerObservable.shared` with the empty
|
||
/// fixture. `seedLookups: false` mirrors the Android harness's
|
||
/// empty-variant for DataCarrying surfaces (empty forms show empty
|
||
/// dropdowns so populated-vs-empty PNGs differ). DataFree surfaces
|
||
/// pass `seedLookups: true` to match production behaviour (a user
|
||
/// with zero entities still sees the priority picker).
|
||
private func seedEmpty(seedLookups: Bool) {
|
||
copyFixture(
|
||
FixtureDataManager.shared.empty(seedLookups: seedLookups),
|
||
into: DataManagerObservable.shared
|
||
)
|
||
}
|
||
|
||
/// Seed `DataManagerObservable.shared` with the populated fixture.
|
||
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.
|
||
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: - Snap helpers
|
||
|
||
/// Capture a DataFree surface: 2 PNGs (`<name>_light`, `<name>_dark`)
|
||
/// against the lookups-seeded empty fixture.
|
||
private func snapDataFree<V: View>(
|
||
_ name: String,
|
||
file: StaticString = #filePath,
|
||
testName: String = #function,
|
||
line: UInt = #line,
|
||
@ViewBuilder content: () -> V
|
||
) {
|
||
seedEmpty(seedLookups: true)
|
||
let view = content()
|
||
.environmentObject(ThemeManager.shared)
|
||
.environment(\.dataManager, DataManagerObservable.shared)
|
||
assertLightDark(view, name: name, file: file, testName: testName, line: line)
|
||
}
|
||
|
||
/// Capture a DataCarrying surface: 4 PNGs (`<name>_empty_light`,
|
||
/// `<name>_empty_dark`, `<name>_populated_light`, `<name>_populated_dark`).
|
||
/// The view closure is invoked *after* each fixture seeding so the
|
||
/// view's ViewModels pick up the freshly-seeded `DataManagerObservable`
|
||
/// values on init (their `init(dataManager: = .shared)` path seeds
|
||
/// synchronously from the shared cache).
|
||
private func snapDataCarrying<V: View>(
|
||
_ name: String,
|
||
file: StaticString = #filePath,
|
||
testName: String = #function,
|
||
line: UInt = #line,
|
||
@ViewBuilder content: () -> V
|
||
) {
|
||
// Empty variant (no lookups — so forms diff against populated).
|
||
seedEmpty(seedLookups: false)
|
||
let emptyView = content()
|
||
.environmentObject(ThemeManager.shared)
|
||
.environment(\.dataManager, DataManagerObservable.shared)
|
||
assertLightDark(emptyView, name: "\(name)_empty", file: file, testName: testName, line: line)
|
||
|
||
// Populated variant.
|
||
seedPopulated()
|
||
let populatedView = content()
|
||
.environmentObject(ThemeManager.shared)
|
||
.environment(\.dataManager, DataManagerObservable.shared)
|
||
assertLightDark(populatedView, name: "\(name)_populated", file: file, testName: testName, line: line)
|
||
}
|
||
|
||
/// Render `view` in both light and dark color schemes, writing
|
||
/// `<name>_light` and `<name>_dark` goldens.
|
||
private func assertLightDark<V: View>(
|
||
_ view: V,
|
||
name: String,
|
||
file: StaticString,
|
||
testName: String,
|
||
line: UInt
|
||
) {
|
||
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: - Fixture accessors
|
||
//
|
||
// Pull a realistic id / object out of the populated fixture so detail
|
||
// and edit surfaces can be instantiated with values that will actually
|
||
// have a match in the seeded observable. Using `.populated()` avoids
|
||
// depending on fixture ordering within `.shared`.
|
||
|
||
private var fixtureResidenceId: Int32 {
|
||
Int32(FixtureDataManager.shared.populated().residences.value.first?.id ?? 1)
|
||
}
|
||
|
||
private var fixtureResidence: ResidenceResponse? {
|
||
FixtureDataManager.shared.populated().residences.value.first
|
||
}
|
||
|
||
private var fixtureTask: TaskResponse? {
|
||
FixtureDataManager.shared.populated().allTasks.value?
|
||
.columns.first?.tasks.first
|
||
}
|
||
|
||
private var fixtureContractor: Contractor? {
|
||
FixtureDataManager.shared.populated().contractorDetail.value.values.first
|
||
}
|
||
|
||
private var fixtureContractorId: Int32 {
|
||
Int32(FixtureDataManager.shared.populated().contractors.value.first?.id ?? 1)
|
||
}
|
||
|
||
private var fixtureDocument: Document? {
|
||
FixtureDataManager.shared.populated().documents.value.first
|
||
}
|
||
|
||
private var fixtureDocumentId: Int32 {
|
||
Int32(fixtureDocument?.id ?? 1)
|
||
}
|
||
|
||
/// Hand-rolled `ResidenceUserResponse` list for `manage_users`.
|
||
/// The fixture doesn't seed residence-users (there's no
|
||
/// `usersByResidence` StateFlow yet on `IDataManager`), so we build
|
||
/// a minimal set here. Matches the Kotlin
|
||
/// `ResidenceUserResponse(id, username, email, firstName, lastName)`
|
||
/// shape.
|
||
private var fixtureResidenceUsers: [ResidenceUserResponse] {
|
||
let user = FixtureDataManager.shared.populated().currentUser.value
|
||
let ownerId = Int32(user?.id ?? 1)
|
||
return [
|
||
ResidenceUserResponse(
|
||
id: ownerId,
|
||
username: user?.username ?? "owner",
|
||
email: user?.email ?? "owner@example.com",
|
||
firstName: user?.firstName ?? "Sam",
|
||
lastName: user?.lastName ?? "Owner"
|
||
),
|
||
ResidenceUserResponse(
|
||
id: ownerId + 1,
|
||
username: "partner",
|
||
email: "partner@example.com",
|
||
firstName: "Taylor",
|
||
lastName: "Partner"
|
||
),
|
||
]
|
||
}
|
||
|
||
// ========================================================================
|
||
// MARK: - Auth (DataFree)
|
||
// ========================================================================
|
||
|
||
func test_login() {
|
||
snapDataFree("login") {
|
||
LoginView(resetToken: .constant(nil), onLoginSuccess: nil)
|
||
}
|
||
}
|
||
|
||
func test_register() {
|
||
snapDataFree("register") {
|
||
RegisterView(isPresented: .constant(true), onVerified: nil)
|
||
}
|
||
}
|
||
|
||
func test_forgot_password() {
|
||
let vm = PasswordResetViewModel()
|
||
snapDataFree("forgot_password") {
|
||
NavigationStack { ForgotPasswordView(viewModel: vm) }
|
||
}
|
||
}
|
||
|
||
func test_verify_reset_code() {
|
||
let vm = PasswordResetViewModel()
|
||
vm.email = "user@example.com"
|
||
vm.currentStep = .verifyCode
|
||
snapDataFree("verify_reset_code") {
|
||
NavigationStack { VerifyResetCodeView(viewModel: vm) }
|
||
}
|
||
}
|
||
|
||
func test_reset_password() {
|
||
let vm = PasswordResetViewModel()
|
||
vm.currentStep = .resetPassword
|
||
snapDataFree("reset_password") {
|
||
NavigationStack { ResetPasswordView(viewModel: vm, onSuccess: {}) }
|
||
}
|
||
}
|
||
|
||
func test_verify_email() {
|
||
snapDataFree("verify_email") {
|
||
VerifyEmailView(onVerifySuccess: {}, onLogout: {})
|
||
}
|
||
}
|
||
|
||
// ========================================================================
|
||
// MARK: - Onboarding (DataFree, except first_task)
|
||
// ========================================================================
|
||
|
||
func test_onboarding_welcome() {
|
||
snapDataFree("onboarding_welcome") {
|
||
OnboardingWelcomeView(onStartFresh: {}, onJoinExisting: {}, onLogin: {})
|
||
}
|
||
}
|
||
|
||
func test_onboarding_value_props() {
|
||
snapDataFree("onboarding_value_props") {
|
||
OnboardingValuePropsView(onContinue: {}, onSkip: {}, onBack: {})
|
||
}
|
||
}
|
||
|
||
func test_onboarding_create_account() {
|
||
snapDataFree("onboarding_create_account") {
|
||
OnboardingCreateAccountView(onAccountCreated: { _ in }, onBack: {})
|
||
}
|
||
}
|
||
|
||
func test_onboarding_verify_email() {
|
||
snapDataFree("onboarding_verify_email") {
|
||
OnboardingVerifyEmailView(onVerified: {}, onLogout: {})
|
||
}
|
||
}
|
||
|
||
func test_onboarding_location() {
|
||
snapDataFree("onboarding_location") {
|
||
OnboardingLocationContent(onLocationDetected: { _ in }, onSkip: {})
|
||
}
|
||
}
|
||
|
||
func test_onboarding_name_residence() {
|
||
snapDataFree("onboarding_name_residence") {
|
||
StatefulPreviewWrapper("") { binding in
|
||
OnboardingNameResidenceView(
|
||
residenceName: binding,
|
||
onContinue: {},
|
||
onBack: {}
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
func test_onboarding_home_profile() {
|
||
snapDataFree("onboarding_home_profile") {
|
||
OnboardingHomeProfileContent(onContinue: {}, onSkip: {})
|
||
}
|
||
}
|
||
|
||
func test_onboarding_join_residence() {
|
||
snapDataFree("onboarding_join_residence") {
|
||
OnboardingJoinResidenceView(onJoined: {}, onSkip: {})
|
||
}
|
||
}
|
||
|
||
func test_onboarding_first_task() {
|
||
// Empty uses the default init (VM loads via APILayer, fails
|
||
// hermetically, renders error/empty). Populated uses the preview
|
||
// init passing a VM seeded with fixture `taskTemplatesGrouped`
|
||
// so the "Browse All" tab renders a populated template catalog.
|
||
seedEmpty(seedLookups: false)
|
||
let emptyView = OnboardingFirstTaskContent(
|
||
residenceName: "My House",
|
||
onTaskAdded: {}
|
||
)
|
||
.environmentObject(ThemeManager.shared)
|
||
.environment(\.dataManager, DataManagerObservable.shared)
|
||
assertLightDark(emptyView, name: "onboarding_first_task_empty", file: #filePath, testName: #function, line: #line)
|
||
|
||
seedPopulated()
|
||
let grouped = FixtureDataManager.shared.populated().taskTemplatesGrouped.value
|
||
let seededVM = OnboardingTasksViewModel(
|
||
initialSuggestions: [],
|
||
initialGrouped: grouped
|
||
)
|
||
let populatedView = OnboardingFirstTaskContent(
|
||
residenceName: "My House",
|
||
onTaskAdded: {},
|
||
viewModel: seededVM
|
||
)
|
||
.environmentObject(ThemeManager.shared)
|
||
.environment(\.dataManager, DataManagerObservable.shared)
|
||
assertLightDark(populatedView, name: "onboarding_first_task_populated", file: #filePath, testName: #function, line: #line)
|
||
}
|
||
|
||
func test_onboarding_subscription() {
|
||
snapDataFree("onboarding_subscription") {
|
||
OnboardingSubscriptionView(onSubscribe: {}, onSkip: {})
|
||
}
|
||
}
|
||
|
||
// ========================================================================
|
||
// MARK: - Residences
|
||
// ========================================================================
|
||
|
||
func test_residences() {
|
||
snapDataCarrying("residences") {
|
||
NavigationStack { ResidencesListView() }
|
||
}
|
||
}
|
||
|
||
func test_residence_detail() {
|
||
snapDataCarrying("residence_detail") {
|
||
NavigationStack {
|
||
ResidenceDetailView(residenceId: self.fixtureResidenceId, preview: true)
|
||
}
|
||
}
|
||
}
|
||
|
||
func test_add_residence() {
|
||
snapDataFree("add_residence") {
|
||
AddResidenceView(isPresented: .constant(true), onResidenceCreated: nil)
|
||
}
|
||
}
|
||
|
||
func test_edit_residence() {
|
||
// `edit_residence` is DataFree: the form is populated from the
|
||
// passed-in `residence` object regardless of DataManager state.
|
||
let residence = fixtureResidence ?? FixtureDataManager.shared.populated().residences.value.first!
|
||
snapDataFree("edit_residence") {
|
||
NavigationStack {
|
||
EditResidenceView(residence: residence, isPresented: .constant(true))
|
||
}
|
||
}
|
||
}
|
||
|
||
func test_join_residence() {
|
||
snapDataFree("join_residence") {
|
||
JoinResidenceView(onJoined: {})
|
||
}
|
||
}
|
||
|
||
func test_manage_users() {
|
||
// Empty variant uses the default init (0 users, loading state);
|
||
// populated variant uses the preview init with a seeded user
|
||
// list. This produces a visible populated-vs-empty diff without
|
||
// waiting for `loadUsers()`'s APILayer round-trip.
|
||
seedEmpty(seedLookups: false)
|
||
let emptyView = NavigationStack {
|
||
ManageUsersView(
|
||
residenceId: fixtureResidenceId,
|
||
residenceName: fixtureResidence?.name ?? "My House",
|
||
isPrimaryOwner: true,
|
||
residenceOwnerId: Int32(fixtureResidence?.ownerId ?? 1),
|
||
residence: nil,
|
||
initialUsers: []
|
||
)
|
||
}
|
||
.environmentObject(ThemeManager.shared)
|
||
.environment(\.dataManager, DataManagerObservable.shared)
|
||
assertLightDark(emptyView, name: "manage_users_empty", file: #filePath, testName: #function, line: #line)
|
||
|
||
seedPopulated()
|
||
let populatedView = NavigationStack {
|
||
ManageUsersView(
|
||
residenceId: fixtureResidenceId,
|
||
residenceName: fixtureResidence?.name ?? "My House",
|
||
isPrimaryOwner: true,
|
||
residenceOwnerId: Int32(fixtureResidence?.ownerId ?? 1),
|
||
residence: fixtureResidence,
|
||
initialUsers: fixtureResidenceUsers
|
||
)
|
||
}
|
||
.environmentObject(ThemeManager.shared)
|
||
.environment(\.dataManager, DataManagerObservable.shared)
|
||
assertLightDark(populatedView, name: "manage_users_populated", file: #filePath, testName: #function, line: #line)
|
||
}
|
||
|
||
// ========================================================================
|
||
// MARK: - Tasks
|
||
// ========================================================================
|
||
|
||
func test_all_tasks() {
|
||
snapDataCarrying("all_tasks") {
|
||
NavigationStack { AllTasksView() }
|
||
}
|
||
}
|
||
|
||
func test_add_task() {
|
||
snapDataFree("add_task") {
|
||
AddTaskView(residenceId: self.fixtureResidenceId, isPresented: .constant(true))
|
||
}
|
||
}
|
||
|
||
func test_add_task_with_residence() {
|
||
snapDataFree("add_task_with_residence") {
|
||
AddTaskWithResidenceView(
|
||
isPresented: .constant(true),
|
||
residences: DataManagerObservable.shared.myResidences?.residences ?? []
|
||
)
|
||
}
|
||
}
|
||
|
||
func test_edit_task() {
|
||
// `edit_task` is DataFree: the form is populated from the passed-in
|
||
// `task` object regardless of DataManager state.
|
||
let task = fixtureTask ?? FixtureDataManager.shared.populated()
|
||
.allTasks.value!.columns.first!.tasks.first!
|
||
snapDataFree("edit_task") {
|
||
NavigationStack {
|
||
EditTaskView(task: task, isPresented: .constant(true))
|
||
}
|
||
}
|
||
}
|
||
|
||
func test_complete_task() {
|
||
// DataFree: task and residence name are static props; the
|
||
// contractor picker is collapsed on first paint, so nothing
|
||
// visible diffs between empty and populated.
|
||
let task = fixtureTask ?? FixtureDataManager.shared.populated()
|
||
.allTasks.value!.columns.first!.tasks.first!
|
||
snapDataFree("complete_task") {
|
||
NavigationStack {
|
||
CompleteTaskView(task: task, onComplete: { _ in })
|
||
}
|
||
}
|
||
}
|
||
|
||
func test_task_suggestions() {
|
||
snapDataCarrying("task_suggestions") {
|
||
// TaskSuggestionsView accepts `suggestions: [TaskTemplate]`
|
||
// directly; pulling from `DataManagerObservable.shared` lets
|
||
// the populated variant show seeded templates and the empty
|
||
// variant show the empty state.
|
||
let templates = Array(DataManagerObservable.shared.taskTemplates.prefix(4))
|
||
return TaskSuggestionsView(suggestions: templates, onSelect: { _ in })
|
||
}
|
||
}
|
||
|
||
func test_task_templates_browser() {
|
||
snapDataCarrying("task_templates_browser") {
|
||
NavigationStack { TaskTemplatesBrowserView(onSelect: { _ in }) }
|
||
}
|
||
}
|
||
|
||
// ========================================================================
|
||
// MARK: - Contractors
|
||
// ========================================================================
|
||
|
||
func test_contractors() {
|
||
snapDataCarrying("contractors") {
|
||
NavigationStack { ContractorsListView() }
|
||
}
|
||
}
|
||
|
||
func test_contractor_detail() {
|
||
// Empty variant uses the default init (no pre-seeded detail);
|
||
// populated variant passes a VM built with a fixture contractor
|
||
// so `selectedContractor` is non-nil on the first frame.
|
||
seedEmpty(seedLookups: false)
|
||
let emptyView = NavigationStack {
|
||
ContractorDetailView(contractorId: self.fixtureContractorId)
|
||
}
|
||
.environmentObject(ThemeManager.shared)
|
||
.environment(\.dataManager, DataManagerObservable.shared)
|
||
assertLightDark(emptyView, name: "contractor_detail_empty", file: #filePath, testName: #function, line: #line)
|
||
|
||
seedPopulated()
|
||
let populatedView = NavigationStack {
|
||
ContractorDetailView(
|
||
contractorId: self.fixtureContractorId,
|
||
viewModel: ContractorViewModel(initialSelectedContractor: self.fixtureContractor)
|
||
)
|
||
}
|
||
.environmentObject(ThemeManager.shared)
|
||
.environment(\.dataManager, DataManagerObservable.shared)
|
||
assertLightDark(populatedView, name: "contractor_detail_populated", file: #filePath, testName: #function, line: #line)
|
||
}
|
||
|
||
// ========================================================================
|
||
// MARK: - Documents
|
||
// ========================================================================
|
||
|
||
func test_documents_warranties() {
|
||
snapDataCarrying("documents_warranties") {
|
||
NavigationStack { DocumentsWarrantiesView(residenceId: nil) }
|
||
}
|
||
}
|
||
|
||
func test_document_detail() {
|
||
// Empty uses the default init (no pre-seeded detail); populated
|
||
// passes a VM pre-seeded with a fixture document so the detail
|
||
// renders on the first frame.
|
||
seedEmpty(seedLookups: false)
|
||
let emptyDocId = Int32(FixtureDataManager.shared.populated().documents.value.first?.id?.int32Value ?? 1)
|
||
let emptyView = NavigationStack {
|
||
DocumentDetailView(documentId: emptyDocId)
|
||
}
|
||
.environmentObject(ThemeManager.shared)
|
||
.environment(\.dataManager, DataManagerObservable.shared)
|
||
assertLightDark(emptyView, name: "document_detail_empty", file: #filePath, testName: #function, line: #line)
|
||
|
||
seedPopulated()
|
||
// Pull the document off the freshly-seeded shared observable so
|
||
// we're definitely using the same instance the fixture pushed
|
||
// in. `FixtureDataManager.populated()` returns a *new* IDataManager
|
||
// on each call, so calling `.documents.value.first` on a separate
|
||
// `populated()` invocation isn't guaranteed to match (and in
|
||
// practice was returning nil here due to the Kotlin→Swift bridge
|
||
// not retaining the flow's element type).
|
||
guard let populatedDoc = DataManagerObservable.shared.documents.first,
|
||
let populatedId = populatedDoc.id?.int32Value else {
|
||
XCTFail("Populated fixture should seed at least one document onto DataManagerObservable.shared")
|
||
return
|
||
}
|
||
let populatedView = NavigationStack {
|
||
DocumentDetailView(
|
||
documentId: populatedId,
|
||
viewModel: DocumentViewModelWrapper(initialDocument: populatedDoc)
|
||
)
|
||
}
|
||
.environmentObject(ThemeManager.shared)
|
||
.environment(\.dataManager, DataManagerObservable.shared)
|
||
assertLightDark(populatedView, name: "document_detail_populated", file: #filePath, testName: #function, line: #line)
|
||
}
|
||
|
||
func test_add_document() {
|
||
snapDataFree("add_document") {
|
||
AddDocumentView(
|
||
residenceId: self.fixtureResidenceId,
|
||
initialDocumentType: "other",
|
||
isPresented: .constant(true),
|
||
documentViewModel: DocumentViewModel()
|
||
)
|
||
}
|
||
}
|
||
|
||
func test_edit_document() {
|
||
let doc = fixtureDocument ?? FixtureDataManager.shared.populated().documents.value.first!
|
||
snapDataFree("edit_document") {
|
||
NavigationStack {
|
||
EditDocumentView(document: doc)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ========================================================================
|
||
// MARK: - Profile / settings
|
||
// ========================================================================
|
||
|
||
func test_profile() {
|
||
snapDataCarrying("profile") {
|
||
NavigationStack { ProfileTabView() }
|
||
}
|
||
}
|
||
|
||
func test_profile_edit() {
|
||
snapDataFree("profile_edit") {
|
||
NavigationStack { ProfileView() }
|
||
}
|
||
}
|
||
|
||
func test_notification_preferences() {
|
||
snapDataFree("notification_preferences") {
|
||
NavigationStack { NotificationPreferencesView() }
|
||
}
|
||
}
|
||
|
||
func test_theme_selection() {
|
||
snapDataFree("theme_selection") {
|
||
NavigationStack { ThemeSelectionView() }
|
||
}
|
||
}
|
||
|
||
// ========================================================================
|
||
// MARK: - Subscription
|
||
// ========================================================================
|
||
|
||
func test_feature_comparison() {
|
||
snapDataFree("feature_comparison") {
|
||
FeatureComparisonView(isPresented: .constant(true))
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|