Files
honeyDueKMP/iosApp/HoneyDueTests/SnapshotGalleryTests.swift
Trey T 9fa58352c0 Parity gallery: unify around canonical manifest, fix populated-state rendering
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>
2026-04-20 18:10:32 -05:00

776 lines
29 KiB
Swift
Raw Permalink 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
//
// 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 8001000 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 KotlinSwift 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)
}
}