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

View File

@@ -19,6 +19,7 @@ class ContractorViewModel: ObservableObject {
// MARK: - Private Properties
private var cancellables = Set<AnyCancellable>()
private let dataManager: DataManagerObservable
/// Timestamp of the last mutation that already set selectedContractor from its response.
/// Used to suppress redundant detail reloads within 1 second of a mutation.
/// Unlike a boolean flag, this naturally expires and can never get stuck.
@@ -26,9 +27,17 @@ class ContractorViewModel: ObservableObject {
// MARK: - Initialization
init() {
// Observe contractors from DataManagerObservable
DataManagerObservable.shared.$contractors
/// - Parameter dataManager: Observable cache the VM subscribes to.
/// Defaults to the shared singleton.
init(dataManager: DataManagerObservable = .shared) {
self.dataManager = dataManager
// Seed from current cache so snapshot tests/previews render
// populated state without waiting for Combine's async dispatch.
self.contractors = dataManager.contractors
// Observe contractors from injected DataManagerObservable
dataManager.$contractors
.receive(on: DispatchQueue.main)
.sink { [weak self] contractors in
self?.contractors = contractors

View File

@@ -89,6 +89,130 @@ class DataManagerObservable: ObservableObject {
startObserving()
}
/// Test-only initializer that skips observing the Kotlin
/// `DataManager.shared` singleton, so callers can assign the
/// `@Published` properties directly from fixture data. Used by the
/// parity-gallery SnapshotGalleryTests to produce populated-state
/// snapshots. Production code must never call this views always
/// resolve `DataManagerObservable.shared`.
///
/// - Parameter observeSharedDataManager: When `false`, no Kotlin
/// StateFlow observation tasks are spun up. Callers are expected
/// to seed `@Published` properties manually.
init(observeSharedDataManager: Bool) {
if observeSharedDataManager {
startObserving()
}
}
/// Test-only initializer that seeds every `@Published` property from
/// the current value of the matching Kotlin StateFlow on the given
/// `IDataManager`. This is a synchronous snapshot later changes to
/// the Kotlin fixture are NOT observed. Used by the parity-gallery
/// SnapshotGalleryTests to produce populated-state renders from
/// `FixtureDataManager.populated()`.
///
/// Production code must never call this views always resolve
/// `DataManagerObservable.shared`.
convenience init(kotlin fixture: IDataManager) {
self.init(observeSharedDataManager: false)
// Auth
self.currentUser = fixture.currentUser.value
self.isAuthenticated = fixture.currentUser.value != nil
// Residences
self.residences = fixture.residences.value
self.myResidences = fixture.myResidences.value
self.totalSummary = fixture.totalSummary.value
self.residenceSummaries = Self.convertIntMapSync(fixture.residenceSummaries.value)
// Tasks
self.allTasks = fixture.allTasks.value
self.tasksByResidence = Self.convertIntMapSync(fixture.tasksByResidence.value)
// Documents
self.documents = fixture.documents.value
self.documentsByResidence = Self.convertIntArrayMapSync(fixture.documentsByResidence.value)
// Contractors
self.contractors = fixture.contractors.value
// Subscription
self.subscription = fixture.subscription.value
self.upgradeTriggers = Self.convertStringMapSync(fixture.upgradeTriggers.value)
self.featureBenefits = fixture.featureBenefits.value
self.promotions = fixture.promotions.value
// Lookups
self.residenceTypes = fixture.residenceTypes.value
self.taskFrequencies = fixture.taskFrequencies.value
self.taskPriorities = fixture.taskPriorities.value
self.taskCategories = fixture.taskCategories.value
self.contractorSpecialties = fixture.contractorSpecialties.value
// Task Templates
self.taskTemplates = fixture.taskTemplates.value
self.taskTemplatesGrouped = fixture.taskTemplatesGrouped.value
// Lookups are fully populated by both FixtureDataManager.empty()
// and FixtureDataManager.populated(), so surfaces that gate UI on
// `lookupsInitialized` render their non-empty form.
self.lookupsInitialized = !fixture.residenceTypes.value.isEmpty ||
!fixture.taskPriorities.value.isEmpty ||
!fixture.taskCategories.value.isEmpty
self.isInitialized = self.lookupsInitialized
}
// MARK: - Synchronous Map Converters (fixture seeding)
private static func convertIntMapSync<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 typedValue = value as? V else { continue }
if let kotlinKey = key as? KotlinInt {
result[kotlinKey.int32Value] = typedValue
} else if let nsNumberKey = key as? NSNumber {
result[nsNumberKey.int32Value] = typedValue
}
}
return result
}
private static func convertIntArrayMapSync<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 kotlinKey = key as? KotlinInt {
result[kotlinKey.int32Value] = typed
} else if let nsNumberKey = key as? NSNumber {
result[nsNumberKey.int32Value] = typed
}
}
return result
}
private static func convertStringMapSync<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 sKey = key as? String, let value = nsDict[key] as? V else { continue }
result[sKey] = value
}
return result
}
// MARK: - Observation Setup
/// Start observing all DataManager StateFlows

View File

@@ -14,10 +14,19 @@ class DocumentViewModel: ObservableObject {
// MARK: - Private Properties
private var cancellables = Set<AnyCancellable>()
private let dataManager: DataManagerObservable
init() {
// Observe documents from DataManagerObservable
DataManagerObservable.shared.$documents
/// - Parameter dataManager: Observable cache the VM subscribes to.
/// Defaults to the shared singleton.
init(dataManager: DataManagerObservable = .shared) {
self.dataManager = dataManager
// Seed from current cache so snapshot tests/previews render
// populated state without waiting for Combine's async dispatch.
self.documents = dataManager.documents
// Observe documents from injected DataManagerObservable
dataManager.$documents
.receive(on: DispatchQueue.main)
.sink { [weak self] documents in
self?.documents = documents

View File

@@ -23,18 +23,28 @@ class LoginViewModel: ObservableObject {
// MARK: - Private Properties
private var cancellables = Set<AnyCancellable>()
private let dataManager: DataManagerObservable
// MARK: - Initialization
init() {
// Observe DataManagerObservable for authentication state
DataManagerObservable.shared.$currentUser
/// - Parameter dataManager: Observable cache the VM subscribes to.
/// Defaults to the shared singleton.
init(dataManager: DataManagerObservable = .shared) {
self.dataManager = dataManager
// Seed from current cache so snapshot tests/previews render
// populated state without waiting for Combine's async dispatch.
self.currentUser = dataManager.currentUser
self.isAuthenticated = dataManager.isAuthenticated
// Observe injected DataManagerObservable for authentication state
dataManager.$currentUser
.receive(on: DispatchQueue.main)
.sink { [weak self] user in
self?.currentUser = user
}
.store(in: &cancellables)
DataManagerObservable.shared.$isAuthenticated
dataManager.$isAuthenticated
.receive(on: DispatchQueue.main)
.sink { [weak self] isAuth in
self?.isAuthenticated = isAuth

View File

@@ -18,14 +18,30 @@ class ProfileViewModel: ObservableObject {
// MARK: - Private Properties
private let tokenStorage: TokenStorageProtocol
private let dataManager: DataManagerObservable
private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization
init(tokenStorage: TokenStorageProtocol? = nil) {
/// - Parameter dataManager: Observable cache the VM subscribes to.
/// Defaults to the shared singleton.
init(
tokenStorage: TokenStorageProtocol? = nil,
dataManager: DataManagerObservable = .shared
) {
self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage()
self.dataManager = dataManager
// Observe current user from DataManagerObservable
DataManagerObservable.shared.$currentUser
// Seed from current cache so snapshot tests/previews render
// populated state without waiting for Combine's async dispatch.
if let user = dataManager.currentUser {
self.firstName = user.firstName ?? ""
self.lastName = user.lastName ?? ""
self.email = user.email
self.isLoadingUser = false
}
// Observe current user from injected DataManagerObservable
dataManager.$currentUser
.receive(on: DispatchQueue.main)
.sink { [weak self] user in
guard let self else { return }
@@ -51,7 +67,7 @@ class ProfileViewModel: ObservableObject {
}
// Check if we already have user data
if DataManagerObservable.shared.currentUser != nil {
if dataManager.currentUser != nil {
isLoadingUser = false
return
}

View File

@@ -24,11 +24,26 @@ class ResidenceViewModel: ObservableObject {
// MARK: - Private Properties
private var cancellables = Set<AnyCancellable>()
private let dataManager: DataManagerObservable
// MARK: - Initialization
init() {
// Observe DataManagerObservable for residence data
DataManagerObservable.shared.$myResidences
/// - Parameter dataManager: Observable cache the VM subscribes to.
/// Defaults to the shared singleton. Tests and the parity-gallery
/// pass a fixture-backed instance instead.
init(dataManager: DataManagerObservable = .shared) {
self.dataManager = dataManager
// Seed the VM's @Published mirrors synchronously from the current
// cache values so snapshot tests and previews render populated
// state without waiting for Combine's async dispatch. Production
// runs hit this path too but the values are identical to what
// the `.sink` closure would assign moments later on the main queue.
self.myResidences = dataManager.myResidences
self.residences = dataManager.residences
self.totalSummary = dataManager.totalSummary
// Observe injected DataManagerObservable for residence data
dataManager.$myResidences
.receive(on: DispatchQueue.main)
.sink { [weak self] myResidences in
self?.myResidences = myResidences
@@ -44,7 +59,7 @@ class ResidenceViewModel: ObservableObject {
}
.store(in: &cancellables)
DataManagerObservable.shared.$residences
dataManager.$residences
.receive(on: DispatchQueue.main)
.sink { [weak self] residences in
self?.residences = residences
@@ -56,7 +71,7 @@ class ResidenceViewModel: ObservableObject {
}
.store(in: &cancellables)
DataManagerObservable.shared.$totalSummary
dataManager.$totalSummary
.receive(on: DispatchQueue.main)
.sink { [weak self] summary in
self?.totalSummary = summary
@@ -97,7 +112,7 @@ class ResidenceViewModel: ObservableObject {
/// Load my residences - checks cache first, then fetches if needed
func loadMyResidences(forceRefresh: Bool = false) {
// Ensure lookups are initialized (may not be during onboarding)
if !DataManagerObservable.shared.lookupsInitialized {
if !dataManager.lookupsInitialized {
Task {
_ = try? await APILayer.shared.initializeLookups()
}
@@ -118,7 +133,7 @@ class ResidenceViewModel: ObservableObject {
errorMessage = nil
// Check if we have cached data and don't need to refresh
if !forceRefresh && DataManagerObservable.shared.myResidences != nil {
if !forceRefresh && dataManager.myResidences != nil {
// Data already available via observation, no API call needed
return
}

View File

@@ -39,11 +39,21 @@ class TaskViewModel: ObservableObject {
// MARK: - Private Properties
private var cancellables = Set<AnyCancellable>()
private let dataManager: DataManagerObservable
// MARK: - Initialization
init() {
// Observe DataManagerObservable for all tasks data
DataManagerObservable.shared.$allTasks
/// - Parameter dataManager: Observable cache the VM subscribes to.
/// Defaults to the shared singleton. Tests inject a fixture-backed
/// instance so populated-state snapshots render real data.
init(dataManager: DataManagerObservable = .shared) {
self.dataManager = dataManager
// Seed from current cache so snapshot tests/previews render
// populated state without waiting for Combine's async dispatch.
self.tasksResponse = dataManager.allTasks
// Observe injected DataManagerObservable for all tasks data
dataManager.$allTasks
.receive(on: DispatchQueue.main)
.sink { [weak self] allTasks in
// Skip DataManager updates during completion animation to prevent
@@ -60,7 +70,7 @@ class TaskViewModel: ObservableObject {
.store(in: &cancellables)
// Observe tasks by residence
DataManagerObservable.shared.$tasksByResidence
dataManager.$tasksByResidence
.receive(on: DispatchQueue.main)
.sink { [weak self] tasksByResidence in
guard self?.isAnimatingCompletion != true else { return }