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>
This commit is contained in:
Trey T
2026-04-19 01:45:04 -05:00
parent 3944223a5e
commit 6c3c9d3e0c
92 changed files with 469 additions and 50 deletions

View File

@@ -2,26 +2,30 @@
// SnapshotGalleryTests.swift
// HoneyDueTests
//
// P3 iOS parity gallery. Records baseline PNGs for primary SwiftUI screens
// across {empty-state} × {light, dark}. Complements the Android Roborazzi
// gallery (P2); both platforms consume the same Kotlin FixtureDataManager
// fixtures (P1), so any layout divergence between Android + iOS renders of
// the same screen is a real parity bug not a test-data mismatch.
// 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.
//
// Current state coverage
// ----------------------
// State coverage
// --------------
// * Empty (signed-in user with no residences / tasks / docs / contractors)
// is captured for every screen below via the default
// `DataManagerObservable.shared`, which has no data loaded when tests run
// because no login has occurred in the test host.
// * Populated state is BLOCKED on a follow-up ViewModel-injection refactor:
// current SwiftUI screens instantiate their ViewModels via
// `@StateObject private var viewModel = FooViewModel()`, and those
// ViewModels read directly from `DataManagerObservable.shared` rather
// than an injected `IDataManager`. Swapping the singleton is unsafe in
// parallel tests. Follow-up PR: add an optional `dataManager:` init param
// to each `*ViewModel.swift` and thread it from here via
// `.environment(\.dataManager, ...)`.
// 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
// -----------------
@@ -30,11 +34,10 @@
// xcodebuild env, deletes the old `__Snapshots__/SnapshotGalleryTests`
// directory, runs the target, then invokes the shared PNG optimizer.
//
// Manual override: set the `SNAPSHOT_TESTING_RECORD` env var to `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.
// 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
// ---------------
@@ -71,6 +74,119 @@ final class SnapshotGalleryTests: XCTestCase {
}
}
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
@@ -137,7 +253,7 @@ final class SnapshotGalleryTests: XCTestCase {
)
}
// MARK: - Auth flow
// MARK: - Auth flow (empty-only; these screens have no backing data)
func test_login_empty() {
snap("login_empty") {
@@ -181,7 +297,7 @@ final class SnapshotGalleryTests: XCTestCase {
}
}
// MARK: - Onboarding
// MARK: - Onboarding (empty-only; these screens are pre-data)
func test_onboarding_welcome_empty() {
snap("onboarding_welcome_empty") {
@@ -249,18 +365,39 @@ final class SnapshotGalleryTests: XCTestCase {
}
}
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() {
@@ -269,18 +406,40 @@ final class SnapshotGalleryTests: XCTestCase {
}
}
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(
@@ -290,12 +449,30 @@ final class SnapshotGalleryTests: XCTestCase {
}
}
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() {
@@ -304,6 +481,13 @@ final class SnapshotGalleryTests: XCTestCase {
}
}
func test_contractors_list_populated() {
seedPopulated()
snap("contractors_list_populated") {
NavigationStack { ContractorsListView() }
}
}
// MARK: - Documents
func test_documents_warranties_empty() {
@@ -312,6 +496,13 @@ final class SnapshotGalleryTests: XCTestCase {
}
}
func test_documents_warranties_populated() {
seedPopulated()
snap("documents_warranties_populated") {
NavigationStack { DocumentsWarrantiesView(residenceId: nil) }
}
}
// MARK: - Profile
func test_profile_tab_empty() {
@@ -320,24 +511,52 @@ final class SnapshotGalleryTests: XCTestCase {
}
}
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() {
@@ -346,6 +565,13 @@ final class SnapshotGalleryTests: XCTestCase {
}
}
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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 KiB

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 KiB

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 352 KiB

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 370 KiB

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 329 KiB

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 480 KiB

After

Width:  |  Height:  |  Size: 480 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 435 KiB

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 KiB

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 383 KiB

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 443 KiB

After

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 369 KiB

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 KiB

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 KiB

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

After

Width:  |  Height:  |  Size: 230 KiB