From 47eaf5a0c05788d00929e89fb7a4ff017cc2f5b0 Mon Sep 17 00:00:00 2001 From: Trey T Date: Sat, 18 Apr 2026 19:22:41 -0500 Subject: [PATCH] P1: Shared FixtureDataManager (empty + populated) for cross-platform snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InMemoryDataManager + Fixtures with deterministic data (fixed clock 2026-04-15, 2 residences, 8 tasks, 3 contractors, 5 documents). FixtureDataManager.empty() and .populated() factories. Exposed to Swift via SKIE. Expanded IDataManager surface (5 -> 22 members) so fixtures cover every StateFlow and lookup helper screens read: myResidences, allTasks, tasksByResidence, documents, documentsByResidence, contractors, residenceTypes, taskFrequencies, taskPriorities, taskCategories, contractorSpecialties, taskTemplates, taskTemplatesGrouped, residenceSummaries, upgradeTriggers, promotions, plus get{ResidenceType,TaskFrequency,TaskPriority,TaskCategory, ContractorSpecialty}(id) lookup helpers. DataManager implementation is a pure override-keyword addition — no behavior change. Enables P2 (Android gallery) + P3 (iOS gallery) to render real screens against identical inputs. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/tt/honeyDue/data/DataManager.kt | 42 +- .../com/tt/honeyDue/data/IDataManager.kt | 93 ++- .../tt/honeyDue/testing/FixtureDataManager.kt | 98 +++ .../com/tt/honeyDue/testing/Fixtures.kt | 734 ++++++++++++++++++ .../honeyDue/testing/InMemoryDataManager.kt | 123 +++ .../testing/FixtureDataManagerTest.kt | 204 +++++ 6 files changed, 1263 insertions(+), 31 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/tt/honeyDue/testing/FixtureDataManager.kt create mode 100644 composeApp/src/commonMain/kotlin/com/tt/honeyDue/testing/Fixtures.kt create mode 100644 composeApp/src/commonMain/kotlin/com/tt/honeyDue/testing/InMemoryDataManager.kt create mode 100644 composeApp/src/commonTest/kotlin/com/tt/honeyDue/testing/FixtureDataManagerTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt index 9139932..c59f629 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt @@ -125,35 +125,35 @@ object DataManager : IDataManager { override val residences: StateFlow> = _residences.asStateFlow() private val _myResidences = MutableStateFlow(null) - val myResidences: StateFlow = _myResidences.asStateFlow() + override val myResidences: StateFlow = _myResidences.asStateFlow() private val _totalSummary = MutableStateFlow(null) override val totalSummary: StateFlow = _totalSummary.asStateFlow() private val _residenceSummaries = MutableStateFlow>(emptyMap()) - val residenceSummaries: StateFlow> = _residenceSummaries.asStateFlow() + override val residenceSummaries: StateFlow> = _residenceSummaries.asStateFlow() // ==================== TASKS ==================== private val _allTasks = MutableStateFlow(null) - val allTasks: StateFlow = _allTasks.asStateFlow() + override val allTasks: StateFlow = _allTasks.asStateFlow() private val _tasksByResidence = MutableStateFlow>(emptyMap()) - val tasksByResidence: StateFlow> = _tasksByResidence.asStateFlow() + override val tasksByResidence: StateFlow> = _tasksByResidence.asStateFlow() // ==================== DOCUMENTS ==================== private val _documents = MutableStateFlow>(emptyList()) - val documents: StateFlow> = _documents.asStateFlow() + override val documents: StateFlow> = _documents.asStateFlow() private val _documentsByResidence = MutableStateFlow>>(emptyMap()) - val documentsByResidence: StateFlow>> = _documentsByResidence.asStateFlow() + override val documentsByResidence: StateFlow>> = _documentsByResidence.asStateFlow() // ==================== CONTRACTORS ==================== // Stores ContractorSummary for list views (lighter weight than full Contractor) private val _contractors = MutableStateFlow>(emptyList()) - val contractors: StateFlow> = _contractors.asStateFlow() + override val contractors: StateFlow> = _contractors.asStateFlow() // ==================== SUBSCRIPTION ==================== @@ -161,39 +161,39 @@ object DataManager : IDataManager { override val subscription: StateFlow = _subscription.asStateFlow() private val _upgradeTriggers = MutableStateFlow>(emptyMap()) - val upgradeTriggers: StateFlow> = _upgradeTriggers.asStateFlow() + override val upgradeTriggers: StateFlow> = _upgradeTriggers.asStateFlow() private val _featureBenefits = MutableStateFlow>(emptyList()) override val featureBenefits: StateFlow> = _featureBenefits.asStateFlow() private val _promotions = MutableStateFlow>(emptyList()) - val promotions: StateFlow> = _promotions.asStateFlow() + override val promotions: StateFlow> = _promotions.asStateFlow() // ==================== LOOKUPS (Reference Data) ==================== // List-based for dropdowns/pickers private val _residenceTypes = MutableStateFlow>(emptyList()) - val residenceTypes: StateFlow> = _residenceTypes.asStateFlow() + override val residenceTypes: StateFlow> = _residenceTypes.asStateFlow() private val _taskFrequencies = MutableStateFlow>(emptyList()) - val taskFrequencies: StateFlow> = _taskFrequencies.asStateFlow() + override val taskFrequencies: StateFlow> = _taskFrequencies.asStateFlow() private val _taskPriorities = MutableStateFlow>(emptyList()) - val taskPriorities: StateFlow> = _taskPriorities.asStateFlow() + override val taskPriorities: StateFlow> = _taskPriorities.asStateFlow() private val _taskCategories = MutableStateFlow>(emptyList()) - val taskCategories: StateFlow> = _taskCategories.asStateFlow() + override val taskCategories: StateFlow> = _taskCategories.asStateFlow() private val _contractorSpecialties = MutableStateFlow>(emptyList()) - val contractorSpecialties: StateFlow> = _contractorSpecialties.asStateFlow() + override val contractorSpecialties: StateFlow> = _contractorSpecialties.asStateFlow() // ==================== TASK TEMPLATES ==================== private val _taskTemplates = MutableStateFlow>(emptyList()) - val taskTemplates: StateFlow> = _taskTemplates.asStateFlow() + override val taskTemplates: StateFlow> = _taskTemplates.asStateFlow() private val _taskTemplatesGrouped = MutableStateFlow(null) - val taskTemplatesGrouped: StateFlow = _taskTemplatesGrouped.asStateFlow() + override val taskTemplatesGrouped: StateFlow = _taskTemplatesGrouped.asStateFlow() // Map-based for O(1) ID resolution private val _residenceTypesMap = MutableStateFlow>(emptyMap()) @@ -261,11 +261,11 @@ object DataManager : IDataManager { // ==================== O(1) LOOKUP HELPERS ==================== - fun getResidenceType(id: Int?): ResidenceType? = id?.let { _residenceTypesMap.value[it] } - fun getTaskFrequency(id: Int?): TaskFrequency? = id?.let { _taskFrequenciesMap.value[it] } - fun getTaskPriority(id: Int?): TaskPriority? = id?.let { _taskPrioritiesMap.value[it] } - fun getTaskCategory(id: Int?): TaskCategory? = id?.let { _taskCategoriesMap.value[it] } - fun getContractorSpecialty(id: Int?): ContractorSpecialty? = id?.let { _contractorSpecialtiesMap.value[it] } + override fun getResidenceType(id: Int?): ResidenceType? = id?.let { _residenceTypesMap.value[it] } + override fun getTaskFrequency(id: Int?): TaskFrequency? = id?.let { _taskFrequenciesMap.value[it] } + override fun getTaskPriority(id: Int?): TaskPriority? = id?.let { _taskPrioritiesMap.value[it] } + override fun getTaskCategory(id: Int?): TaskCategory? = id?.let { _taskCategoriesMap.value[it] } + override fun getContractorSpecialty(id: Int?): ContractorSpecialty? = id?.let { _contractorSpecialtiesMap.value[it] } // ==================== AUTH UPDATE METHODS ==================== diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/IDataManager.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/IDataManager.kt index 5594297..e924085 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/IDataManager.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/IDataManager.kt @@ -1,37 +1,110 @@ package com.tt.honeyDue.data +import com.tt.honeyDue.models.ContractorSpecialty +import com.tt.honeyDue.models.ContractorSummary +import com.tt.honeyDue.models.Document import com.tt.honeyDue.models.FeatureBenefit +import com.tt.honeyDue.models.MyResidencesResponse +import com.tt.honeyDue.models.Promotion import com.tt.honeyDue.models.Residence +import com.tt.honeyDue.models.ResidenceSummaryResponse +import com.tt.honeyDue.models.ResidenceType import com.tt.honeyDue.models.SubscriptionStatus +import com.tt.honeyDue.models.TaskCategory +import com.tt.honeyDue.models.TaskColumnsResponse +import com.tt.honeyDue.models.TaskFrequency +import com.tt.honeyDue.models.TaskPriority +import com.tt.honeyDue.models.TaskTemplate +import com.tt.honeyDue.models.TaskTemplatesGroupedResponse import com.tt.honeyDue.models.TotalSummary +import com.tt.honeyDue.models.UpgradeTriggerData import com.tt.honeyDue.models.User import kotlinx.coroutines.flow.StateFlow /** - * Minimal contract covering the [DataManager] surface consumed by Compose screens. + * Contract covering the [DataManager] surface consumed by Compose screens + * and the parity-gallery fixture factories. * - * This interface exists solely so screens can depend on an abstraction that tests, - * previews, and the parity-gallery can substitute via [LocalDataManager]. It is - * deliberately narrow — only members referenced from the ui/screens package tree - * are included. + * This interface exists so screens can depend on an abstraction that tests, + * previews, and the parity-gallery can substitute via [LocalDataManager]. + * The member set intentionally mirrors every StateFlow and lookup helper a + * screen may read — [com.tt.honeyDue.testing.FixtureDataManager] produces + * fully-populated fakes so snapshot renders have the data every surface + * expects without reaching into the production singleton. * - * ViewModels, [com.tt.honeyDue.network.APILayer], and [PersistenceManager] continue - * to use the concrete [DataManager] singleton directly; widening this interface to - * cover their surface is explicitly out of scope for the ambient refactor. + * ViewModels, [com.tt.honeyDue.network.APILayer], and [PersistenceManager] + * continue to use the concrete [DataManager] singleton directly; they are + * not covered by this interface. */ interface IDataManager { + + // ==================== AUTH ==================== + /** Observed by [com.tt.honeyDue.ui.screens.ProfileScreen], [com.tt.honeyDue.ui.screens.ResidenceDetailScreen], [com.tt.honeyDue.ui.screens.ResidenceFormScreen]. */ val currentUser: StateFlow + // ==================== RESIDENCES ==================== + /** Observed by [com.tt.honeyDue.ui.screens.ContractorDetailScreen] and the onboarding first-task screen. */ val residences: StateFlow> + /** Full my-residences API response (may include metadata beyond the list itself). */ + val myResidences: StateFlow + /** Observed by [com.tt.honeyDue.ui.screens.HomeScreen] and [com.tt.honeyDue.ui.screens.ResidencesScreen]. */ val totalSummary: StateFlow + /** Per-residence summary cache (keyed by residence id). */ + val residenceSummaries: StateFlow> + + // ==================== TASKS ==================== + + /** Kanban board covering all tasks across all residences. */ + val allTasks: StateFlow + + /** Kanban board cache keyed by residence id. */ + val tasksByResidence: StateFlow> + + // ==================== DOCUMENTS ==================== + + val documents: StateFlow> + + val documentsByResidence: StateFlow>> + + // ==================== CONTRACTORS ==================== + + val contractors: StateFlow> + + // ==================== SUBSCRIPTION ==================== + + /** Observed by [com.tt.honeyDue.ui.screens.ProfileScreen]. */ + val subscription: StateFlow + + val upgradeTriggers: StateFlow> + /** Observed by [com.tt.honeyDue.ui.screens.subscription.FeatureComparisonScreen]. */ val featureBenefits: StateFlow> - /** Observed by [com.tt.honeyDue.ui.screens.ProfileScreen]. */ - val subscription: StateFlow + val promotions: StateFlow> + + // ==================== LOOKUPS ==================== + + val residenceTypes: StateFlow> + val taskFrequencies: StateFlow> + val taskPriorities: StateFlow> + val taskCategories: StateFlow> + val contractorSpecialties: StateFlow> + + // ==================== TASK TEMPLATES ==================== + + val taskTemplates: StateFlow> + val taskTemplatesGrouped: StateFlow + + // ==================== O(1) LOOKUP HELPERS ==================== + + fun getResidenceType(id: Int?): ResidenceType? + fun getTaskFrequency(id: Int?): TaskFrequency? + fun getTaskPriority(id: Int?): TaskPriority? + fun getTaskCategory(id: Int?): TaskCategory? + fun getContractorSpecialty(id: Int?): ContractorSpecialty? } diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/testing/FixtureDataManager.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/testing/FixtureDataManager.kt new file mode 100644 index 0000000..31b5ca8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/testing/FixtureDataManager.kt @@ -0,0 +1,98 @@ +package com.tt.honeyDue.testing + +import com.tt.honeyDue.data.IDataManager + +/** + * Factories that produce deterministic [IDataManager] instances for the + * parity-gallery (Android Roborazzi + iOS swift-snapshot-testing) and any + * other snapshot/preview harness. Both platforms consume the same fixture + * graph (via SKIE bridging on iOS), so any layout divergence between iOS + * and Android renders of the same screen is a real parity bug — not a test + * data mismatch. + * + * Use: + * ```kotlin + * // Android Compose preview or Roborazzi test + * CompositionLocalProvider(LocalDataManager provides FixtureDataManager.empty()) { + * MyScreen() + * } + * ``` + * + * ```swift + * // iOS SwiftUI preview or snapshot test + * MyView().environment(\.dataManager, + * DataManagerObservable(kotlin: FixtureDataManager.shared.populated())) + * ``` + */ +object FixtureDataManager { + + /** + * Data-free fixture — represents a freshly-signed-in user with no + * residences, no tasks, no contractors, no documents. Lookups + * (priorities, categories, frequencies) are still populated because + * empty-state form pickers render them even before the user has any + * entities of their own. + */ + fun empty(): IDataManager = InMemoryDataManager( + currentUser = null, + residences = emptyList(), + myResidencesResponse = null, + totalSummary = null, + residenceSummaries = emptyMap(), + allTasks = null, + tasksByResidence = emptyMap(), + documents = emptyList(), + documentsByResidence = emptyMap(), + contractors = emptyList(), + subscription = Fixtures.freeSubscription, + upgradeTriggers = emptyMap(), + featureBenefits = Fixtures.featureBenefits, + promotions = emptyList(), + residenceTypes = Fixtures.residenceTypes, + taskFrequencies = Fixtures.taskFrequencies, + taskPriorities = Fixtures.taskPriorities, + taskCategories = Fixtures.taskCategories, + contractorSpecialties = Fixtures.contractorSpecialties, + taskTemplates = Fixtures.taskTemplates, + taskTemplatesGrouped = Fixtures.taskTemplatesGrouped, + ) + + /** + * Fully-populated fixture with realistic content for every screen: + * 2 residences · 8 tasks (mix of overdue/due-soon/upcoming/completed) + * · 3 contractors · 5 documents (2 warranties — one expired — + * + 3 manuals). The user is premium-tier so gated surfaces render + * their "pro" appearance. + */ + fun populated(): IDataManager = InMemoryDataManager( + currentUser = Fixtures.user, + residences = Fixtures.residences, + myResidencesResponse = Fixtures.myResidencesResponse, + totalSummary = Fixtures.totalSummary, + residenceSummaries = Fixtures.residenceSummaries, + allTasks = Fixtures.taskColumnsResponse, + tasksByResidence = Fixtures.residences.associate { residence -> + residence.id to Fixtures.taskColumnsResponse.copy( + columns = Fixtures.taskColumnsResponse.columns.map { column -> + val filtered = column.tasks.filter { it.residenceId == residence.id } + column.copy(tasks = filtered, count = filtered.size) + }, + residenceId = residence.id.toString(), + ) + }, + documents = Fixtures.documents, + documentsByResidence = Fixtures.documentsByResidence, + contractors = Fixtures.contractorSummaries, + subscription = Fixtures.premiumSubscription, + upgradeTriggers = emptyMap(), + featureBenefits = Fixtures.featureBenefits, + promotions = emptyList(), + residenceTypes = Fixtures.residenceTypes, + taskFrequencies = Fixtures.taskFrequencies, + taskPriorities = Fixtures.taskPriorities, + taskCategories = Fixtures.taskCategories, + contractorSpecialties = Fixtures.contractorSpecialties, + taskTemplates = Fixtures.taskTemplates, + taskTemplatesGrouped = Fixtures.taskTemplatesGrouped, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/testing/Fixtures.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/testing/Fixtures.kt new file mode 100644 index 0000000..46f5ece --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/testing/Fixtures.kt @@ -0,0 +1,734 @@ +package com.tt.honeyDue.testing + +import com.tt.honeyDue.models.Contractor +import com.tt.honeyDue.models.ContractorSpecialty +import com.tt.honeyDue.models.ContractorSummary +import com.tt.honeyDue.models.Document +import com.tt.honeyDue.models.FeatureBenefit +import com.tt.honeyDue.models.MyResidencesResponse +import com.tt.honeyDue.models.Residence +import com.tt.honeyDue.models.ResidenceSummaryResponse +import com.tt.honeyDue.models.ResidenceType +import com.tt.honeyDue.models.ResidenceUserResponse +import com.tt.honeyDue.models.SubscriptionStatus +import com.tt.honeyDue.models.TaskCategory +import com.tt.honeyDue.models.TaskColumn +import com.tt.honeyDue.models.TaskColumnsResponse +import com.tt.honeyDue.models.TaskFrequency +import com.tt.honeyDue.models.TaskPriority +import com.tt.honeyDue.models.TaskResponse +import com.tt.honeyDue.models.TaskTemplate +import com.tt.honeyDue.models.TaskTemplateCategoryGroup +import com.tt.honeyDue.models.TaskTemplatesGroupedResponse +import com.tt.honeyDue.models.TaskUserResponse +import com.tt.honeyDue.models.TierLimits +import com.tt.honeyDue.models.TotalSummary +import com.tt.honeyDue.models.UsageStats +import com.tt.honeyDue.models.User +import com.tt.honeyDue.models.toSummary +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.minus +import kotlinx.datetime.plus + +/** + * Deterministic fixture graph for the parity-gallery and snapshot tests. + * + * Every date is derived from [FIXED_DATE] (2026-04-15) — no `Clock.System.now()` + * calls — so identical fixtures render identically every run on both + * platforms. These fixtures feed [FixtureDataManager] which in turn feeds + * `LocalDataManager` (Android) and `@Environment(\.dataManager)` (iOS). + * + * Populated content target (matches `rc-parity-gallery.md` P1): + * - 2 residences (1 primary home + 1 lake cabin) + * - 8 tasks (2 overdue, 3 due this week, 2 due later, 1 completed) + * - 3 contractors (plumber, electrician, HVAC) + * - 5 documents (2 warranties — one expired — + 3 manuals) + */ +object Fixtures { + + // ==================== FIXED CLOCK ==================== + + val FIXED_DATE: LocalDate = LocalDate(2026, 4, 15) + + private val FIXED_ISO_TIMESTAMP: String = "2026-04-15T12:00:00Z" + + private fun isoDate(d: LocalDate): String = d.toString() // yyyy-MM-dd + private fun daysFromFixed(offset: Int): String = + if (offset == 0) isoDate(FIXED_DATE) + else if (offset > 0) isoDate(FIXED_DATE.plus(offset, DateTimeUnit.DAY)) + else isoDate(FIXED_DATE.minus(-offset, DateTimeUnit.DAY)) + + // ==================== RESIDENCE TYPES ==================== + + val residenceTypes: List = listOf( + ResidenceType(id = 1, name = "House"), + ResidenceType(id = 2, name = "Condo"), + ResidenceType(id = 3, name = "Apartment"), + ResidenceType(id = 4, name = "Cabin"), + ResidenceType(id = 5, name = "Townhouse"), + ) + + // ==================== TASK CATEGORIES ==================== + + val taskCategories: List = listOf( + TaskCategory(id = 1, name = "Plumbing", description = "Pipes, faucets, drains", icon = "water", color = "#2196F3", displayOrder = 1), + TaskCategory(id = 2, name = "Electrical", description = "Wiring, outlets, fixtures", icon = "bolt", color = "#FFB300", displayOrder = 2), + TaskCategory(id = 3, name = "HVAC", description = "Heating, cooling, ventilation", icon = "thermostat", color = "#00897B", displayOrder = 3), + TaskCategory(id = 4, name = "Exterior", description = "Roof, siding, landscaping", icon = "house", color = "#6D4C41", displayOrder = 4), + TaskCategory(id = 5, name = "Interior", description = "Walls, floors, fixtures", icon = "weekend", color = "#8E24AA", displayOrder = 5), + TaskCategory(id = 6, name = "Appliance", description = "Kitchen & laundry appliances", icon = "kitchen", color = "#E53935", displayOrder = 6), + ) + + // ==================== TASK PRIORITIES ==================== + + val taskPriorities: List = listOf( + TaskPriority(id = 1, name = "Urgent", level = 4, color = "#EF4444", displayOrder = 1), + TaskPriority(id = 2, name = "High", level = 3, color = "#F59E0B", displayOrder = 2), + TaskPriority(id = 3, name = "Medium", level = 2, color = "#3B82F6", displayOrder = 3), + TaskPriority(id = 4, name = "Low", level = 1, color = "#10B981", displayOrder = 4), + ) + + // ==================== TASK FREQUENCIES ==================== + + val taskFrequencies: List = listOf( + TaskFrequency(id = 1, name = "One-time", days = null, displayOrder = 1), + TaskFrequency(id = 2, name = "Monthly", days = 30, displayOrder = 2), + TaskFrequency(id = 3, name = "Quarterly", days = 90, displayOrder = 3), + TaskFrequency(id = 4, name = "Annual", days = 365, displayOrder = 4), + TaskFrequency(id = 5, name = "Custom", days = null, displayOrder = 5), + ) + + // ==================== CONTRACTOR SPECIALTIES ==================== + + val contractorSpecialties: List = listOf( + ContractorSpecialty(id = 1, name = "Plumbing", description = "Pipes, fixtures, drains", icon = "water", displayOrder = 1), + ContractorSpecialty(id = 2, name = "Electrical", description = "Wiring, panels, fixtures", icon = "bolt", displayOrder = 2), + ContractorSpecialty(id = 3, name = "HVAC", description = "Heating & cooling", icon = "thermostat", displayOrder = 3), + ContractorSpecialty(id = 4, name = "Roofing", description = "Roof repair & replacement", icon = "roof", displayOrder = 4), + ContractorSpecialty(id = 5, name = "General", description = "General handyman", icon = "wrench", displayOrder = 5), + ) + + // ==================== USER ==================== + + val user: User = User( + id = 42, + username = "jane.demo", + email = "jane.demo@honeydue.example", + firstName = "Jane", + lastName = "Demo", + isActive = true, + dateJoined = FIXED_ISO_TIMESTAMP, + lastLogin = FIXED_ISO_TIMESTAMP, + ) + + private val residenceUser: ResidenceUserResponse = ResidenceUserResponse( + id = 42, + username = "jane.demo", + email = "jane.demo@honeydue.example", + firstName = "Jane", + lastName = "Demo", + ) + + private val taskUser: TaskUserResponse = TaskUserResponse( + id = 42, + username = "jane.demo", + email = "jane.demo@honeydue.example", + firstName = "Jane", + lastName = "Demo", + ) + + // ==================== RESIDENCES ==================== + + val primaryHome: Residence = Residence( + id = 1, + ownerId = 42, + owner = residenceUser, + users = listOf(residenceUser), + name = "Primary Home", + propertyTypeId = 1, + propertyType = residenceTypes[0], + streetAddress = "1234 Maple Street", + city = "Madison", + stateProvince = "WI", + postalCode = "53703", + country = "USA", + bedrooms = 3, + bathrooms = 2.5, + squareFootage = 2200, + lotSize = 0.25, + yearBuilt = 2005, + description = "Two-story colonial, attached two-car garage.", + purchaseDate = daysFromFixed(-365 * 4), + purchasePrice = 385_000.0, + isPrimary = true, + isActive = true, + overdueCount = 2, + heatingType = "Gas furnace", + coolingType = "Central AC", + waterHeaterType = "Tankless gas", + roofType = "Asphalt shingle", + hasPool = false, + hasSprinklerSystem = true, + hasSeptic = false, + hasFireplace = true, + hasGarage = true, + hasBasement = true, + hasAttic = true, + exteriorType = "Vinyl siding", + flooringPrimary = "Hardwood", + landscapingType = "Traditional lawn", + createdAt = FIXED_ISO_TIMESTAMP, + updatedAt = FIXED_ISO_TIMESTAMP, + ) + + val lakeCabin: Residence = Residence( + id = 2, + ownerId = 42, + owner = residenceUser, + users = listOf(residenceUser), + name = "Lake Cabin", + propertyTypeId = 4, + propertyType = residenceTypes[3], + streetAddress = "56 Lakeshore Drive", + city = "Minocqua", + stateProvince = "WI", + postalCode = "54548", + country = "USA", + bedrooms = 2, + bathrooms = 1.0, + squareFootage = 1200, + lotSize = 0.5, + yearBuilt = 1975, + description = "Rustic cabin on the lake — detached shed, no basement.", + purchaseDate = daysFromFixed(-365 * 2), + purchasePrice = 215_000.0, + isPrimary = false, + isActive = true, + overdueCount = 0, + heatingType = "Baseboard electric", + coolingType = "Window units", + waterHeaterType = "Electric tank", + roofType = "Metal", + hasPool = false, + hasSprinklerSystem = false, + hasSeptic = true, + hasFireplace = true, + hasGarage = false, + hasBasement = false, + hasAttic = true, + exteriorType = "Cedar siding", + flooringPrimary = "Pine plank", + landscapingType = "Woodland natural", + createdAt = FIXED_ISO_TIMESTAMP, + updatedAt = FIXED_ISO_TIMESTAMP, + ) + + val residences: List = listOf(primaryHome, lakeCabin) + + val myResidencesResponse: MyResidencesResponse = MyResidencesResponse(residences = residences) + + val residenceSummaries: Map = mapOf( + primaryHome.id to ResidenceSummaryResponse( + id = primaryHome.id, + name = primaryHome.name, + taskCount = 7, + pendingCount = 6, + overdueCount = 2, + ), + lakeCabin.id to ResidenceSummaryResponse( + id = lakeCabin.id, + name = lakeCabin.name, + taskCount = 2, + pendingCount = 2, + overdueCount = 0, + ), + ) + + // ==================== TASKS ==================== + + private fun task( + id: Int, + title: String, + description: String, + residenceId: Int, + priorityId: Int, + categoryId: Int, + frequencyId: Int, + dueDateOffsetDays: Int, + kanbanColumn: String, + completed: Boolean = false, + estimatedCost: Double? = null, + ): TaskResponse = TaskResponse( + id = id, + residenceId = residenceId, + createdById = 42, + createdBy = taskUser, + assignedToId = 42, + assignedTo = taskUser, + title = title, + description = description, + categoryId = categoryId, + category = taskCategories.first { it.id == categoryId }, + priorityId = priorityId, + priority = taskPriorities.first { it.id == priorityId }, + inProgress = false, + frequencyId = frequencyId, + frequency = taskFrequencies.first { it.id == frequencyId }, + customIntervalDays = null, + dueDate = daysFromFixed(dueDateOffsetDays), + nextDueDate = if (completed) daysFromFixed(dueDateOffsetDays + 90) else null, + estimatedCost = estimatedCost, + actualCost = null, + contractorId = null, + isCancelled = false, + isArchived = false, + parentTaskId = null, + templateId = null, + completionCount = if (completed) 1 else 0, + kanbanColumn = kanbanColumn, + completions = emptyList(), + createdAt = FIXED_ISO_TIMESTAMP, + updatedAt = FIXED_ISO_TIMESTAMP, + ) + + // 2 overdue, 3 due this week, 2 due later, 1 completed + val tasks: List = listOf( + // Overdue (2) + task( + id = 1, + title = "Replace furnace filter", + description = "Swap the pleated filter and log replacement date on the side.", + residenceId = primaryHome.id, + priorityId = 2, + categoryId = 3, + frequencyId = 3, + dueDateOffsetDays = -5, + kanbanColumn = "overdue_tasks", + estimatedCost = 25.0, + ), + task( + id = 2, + title = "Check smoke detector batteries", + description = "All six detectors — replace any older than 12 months.", + residenceId = primaryHome.id, + priorityId = 1, + categoryId = 5, + frequencyId = 4, + dueDateOffsetDays = -2, + kanbanColumn = "overdue_tasks", + estimatedCost = 15.0, + ), + // Due this week (3) + task( + id = 3, + title = "Clean gutters", + description = "Clear leaves and debris from all gutters and downspouts.", + residenceId = primaryHome.id, + priorityId = 3, + categoryId = 4, + frequencyId = 3, + dueDateOffsetDays = 3, + kanbanColumn = "due_soon_tasks", + estimatedCost = 120.0, + ), + task( + id = 4, + title = "Test sump pump", + description = "Pour water into the pit to verify activation and discharge.", + residenceId = primaryHome.id, + priorityId = 2, + categoryId = 1, + frequencyId = 3, + dueDateOffsetDays = 5, + kanbanColumn = "due_soon_tasks", + ), + task( + id = 5, + title = "Reseal deck", + description = "Apply one coat of semi-transparent stain to the main deck.", + residenceId = lakeCabin.id, + priorityId = 4, + categoryId = 4, + frequencyId = 4, + dueDateOffsetDays = 7, + kanbanColumn = "due_soon_tasks", + estimatedCost = 85.0, + ), + // Due later (2) + task( + id = 6, + title = "Service HVAC", + description = "Schedule annual service with North Winds HVAC.", + residenceId = primaryHome.id, + priorityId = 3, + categoryId = 3, + frequencyId = 4, + dueDateOffsetDays = 14, + kanbanColumn = "upcoming_tasks", + estimatedCost = 175.0, + ), + task( + id = 7, + title = "Power-wash siding", + description = "Rent pressure washer; focus on north wall mildew.", + residenceId = lakeCabin.id, + priorityId = 4, + categoryId = 4, + frequencyId = 4, + dueDateOffsetDays = 21, + kanbanColumn = "upcoming_tasks", + estimatedCost = 60.0, + ), + // Completed (1) + task( + id = 8, + title = "Replaced kitchen faucet", + description = "Upgraded to pull-down sprayer model.", + residenceId = primaryHome.id, + priorityId = 3, + categoryId = 1, + frequencyId = 1, + dueDateOffsetDays = -10, + kanbanColumn = "completed_tasks", + completed = true, + estimatedCost = 180.0, + ), + ) + + /** Kanban columns derived from [tasks] — matches API shape. */ + val taskColumnsResponse: TaskColumnsResponse = run { + val grouped = tasks.groupBy { it.kanbanColumn ?: "upcoming_tasks" } + val columnSpec = listOf( + Triple("overdue_tasks", "Overdue", "#EF4444"), + Triple("in_progress_tasks", "In Progress", "#F59E0B"), + Triple("due_soon_tasks", "Due Soon", "#3B82F6"), + Triple("upcoming_tasks", "Upcoming", "#6366F1"), + Triple("completed_tasks", "Completed", "#10B981"), + Triple("cancelled_tasks", "Cancelled", "#6B7280"), + ) + TaskColumnsResponse( + columns = columnSpec.map { (name, displayName, color) -> + val items = grouped[name].orEmpty() + TaskColumn( + name = name, + displayName = displayName, + buttonTypes = emptyList(), + icons = emptyMap(), + color = color, + tasks = items, + count = items.size, + ) + }, + daysThreshold = 30, + residenceId = "", + ) + } + + // ==================== CONTRACTORS ==================== + + val contractors: List = listOf( + Contractor( + id = 1, + residenceId = primaryHome.id, + createdById = 42, + addedBy = 42, + name = "Marisol Rivera", + company = "Madison Plumbing Co", + phone = "608-555-0101", + email = "info@madisonplumbing.example", + website = "https://madisonplumbing.example", + notes = "Fast response on weekends. Preferred for emergency calls.", + streetAddress = "4421 E Washington Ave", + city = "Madison", + stateProvince = "WI", + postalCode = "53704", + specialties = listOf(contractorSpecialties[0]), + rating = 4.8, + isFavorite = true, + isActive = true, + taskCount = 3, + createdAt = FIXED_ISO_TIMESTAMP, + updatedAt = FIXED_ISO_TIMESTAMP, + ), + Contractor( + id = 2, + residenceId = primaryHome.id, + createdById = 42, + addedBy = 42, + name = "Eli Park", + company = "Bright Electric", + phone = "608-555-0202", + email = "contact@brightelectric.example", + website = "https://brightelectric.example", + notes = "Did the panel upgrade in 2024.", + streetAddress = "102 S Park St", + city = "Madison", + stateProvince = "WI", + postalCode = "53715", + specialties = listOf(contractorSpecialties[1]), + rating = 4.5, + isFavorite = false, + isActive = true, + taskCount = 1, + createdAt = FIXED_ISO_TIMESTAMP, + updatedAt = FIXED_ISO_TIMESTAMP, + ), + Contractor( + id = 3, + residenceId = primaryHome.id, + createdById = 42, + addedBy = 42, + name = "Dana Thompson", + company = "North Winds HVAC", + phone = "608-555-0303", + email = "service@northwinds.example", + website = "https://northwinds.example", + notes = "Annual service contract renews in October.", + streetAddress = "88 Commerce Ln", + city = "Madison", + stateProvince = "WI", + postalCode = "53719", + specialties = listOf(contractorSpecialties[2]), + rating = 4.7, + isFavorite = true, + isActive = true, + taskCount = 2, + createdAt = FIXED_ISO_TIMESTAMP, + updatedAt = FIXED_ISO_TIMESTAMP, + ), + ) + + val contractorSummaries: List = contractors.map { it.toSummary() } + + // ==================== DOCUMENTS ==================== + + val documents: List = listOf( + Document( + id = 1, + title = "Furnace Warranty", + documentType = "warranty", + description = "Trane XR80 10-year parts warranty.", + fileName = "trane-warranty.pdf", + fileSize = 245_000, + mimeType = "application/pdf", + modelNumber = "XR80-2019", + serialNumber = "TRN-998112", + vendor = "Trane", + purchaseDate = daysFromFixed(-365 * 7), + purchasePrice = 3_200.0, + expiryDate = daysFromFixed(200), + residenceId = primaryHome.id, + residence = primaryHome.id, + createdById = 42, + isActive = true, + createdAt = FIXED_ISO_TIMESTAMP, + updatedAt = FIXED_ISO_TIMESTAMP, + category = "hvac", + itemName = "Gas Furnace", + provider = "Trane", + daysUntilExpiration = 200, + ), + Document( + id = 2, + title = "Dishwasher Warranty (expired)", + documentType = "warranty", + description = "Bosch SHE3AR76UC — original 1-year warranty, lapsed.", + fileName = "bosch-warranty.pdf", + fileSize = 180_000, + mimeType = "application/pdf", + modelNumber = "SHE3AR76UC", + serialNumber = "BSH-44120", + vendor = "Bosch", + purchaseDate = daysFromFixed(-365 * 2), + purchasePrice = 799.0, + expiryDate = daysFromFixed(-30), + residenceId = primaryHome.id, + residence = primaryHome.id, + createdById = 42, + isActive = true, + createdAt = FIXED_ISO_TIMESTAMP, + updatedAt = FIXED_ISO_TIMESTAMP, + category = "appliance", + itemName = "Dishwasher", + provider = "Bosch", + daysUntilExpiration = -30, + ), + Document( + id = 3, + title = "HVAC Manual", + documentType = "manual", + description = "Owner's manual for the furnace + condenser pair.", + fileName = "trane-manual.pdf", + fileSize = 1_450_000, + mimeType = "application/pdf", + residenceId = primaryHome.id, + residence = primaryHome.id, + createdById = 42, + isActive = true, + createdAt = FIXED_ISO_TIMESTAMP, + updatedAt = FIXED_ISO_TIMESTAMP, + category = "hvac", + itemName = "Gas Furnace", + ), + Document( + id = 4, + title = "Microwave Manual", + documentType = "manual", + description = "Over-range microwave installation & user guide.", + fileName = "microwave-manual.pdf", + fileSize = 520_000, + mimeType = "application/pdf", + residenceId = primaryHome.id, + residence = primaryHome.id, + createdById = 42, + isActive = true, + createdAt = FIXED_ISO_TIMESTAMP, + updatedAt = FIXED_ISO_TIMESTAMP, + category = "appliance", + itemName = "Microwave", + ), + Document( + id = 5, + title = "Well Pump Service Log", + documentType = "manual", + description = "Yearly inspection notes for the lake cabin's well pump.", + fileName = "well-pump-log.pdf", + fileSize = 95_000, + mimeType = "application/pdf", + residenceId = lakeCabin.id, + residence = lakeCabin.id, + createdById = 42, + isActive = true, + createdAt = FIXED_ISO_TIMESTAMP, + updatedAt = FIXED_ISO_TIMESTAMP, + category = "plumbing", + itemName = "Well Pump", + ), + ) + + val documentsByResidence: Map> = documents.groupBy { it.residenceId ?: it.residence } + + // ==================== TOTAL SUMMARY ==================== + + val totalSummary: TotalSummary = TotalSummary( + totalResidences = residences.size, + totalTasks = tasks.size, + totalPending = tasks.count { !(it.kanbanColumn == "completed_tasks" || it.kanbanColumn == "cancelled_tasks") }, + totalOverdue = tasks.count { it.kanbanColumn == "overdue_tasks" }, + tasksDueNextWeek = tasks.count { it.kanbanColumn == "due_soon_tasks" }, + tasksDueNextMonth = tasks.count { it.kanbanColumn == "due_soon_tasks" }, + ) + + // ==================== SUBSCRIPTION ==================== + + private val tierLimits: Map = mapOf( + "free" to TierLimits(properties = 1, tasks = 10, contractors = 3, documents = 5), + "pro" to TierLimits(properties = null, tasks = null, contractors = null, documents = null), + ) + + val freeSubscription: SubscriptionStatus = SubscriptionStatus( + tier = "free", + isActive = true, + usage = UsageStats( + propertiesCount = 0, + tasksCount = 0, + contractorsCount = 0, + documentsCount = 0, + ), + limits = tierLimits, + limitationsEnabled = true, + ) + + val premiumSubscription: SubscriptionStatus = SubscriptionStatus( + tier = "pro", + isActive = true, + subscribedAt = daysFromFixed(-120), + expiresAt = daysFromFixed(245), + autoRenew = true, + usage = UsageStats( + propertiesCount = residences.size, + tasksCount = tasks.size, + contractorsCount = contractors.size, + documentsCount = documents.size, + ), + limits = tierLimits, + limitationsEnabled = false, + trialActive = false, + subscriptionSource = "apple", + ) + + // ==================== FEATURE BENEFITS ==================== + + val featureBenefits: List = listOf( + FeatureBenefit( + featureName = "Residences", + freeTierText = "Up to 1 property", + proTierText = "Unlimited properties", + ), + FeatureBenefit( + featureName = "Tasks", + freeTierText = "10 active tasks", + proTierText = "Unlimited tasks", + ), + FeatureBenefit( + featureName = "Contractors", + freeTierText = "3 contractors", + proTierText = "Unlimited contractors", + ), + FeatureBenefit( + featureName = "Documents", + freeTierText = "5 documents", + proTierText = "Unlimited documents", + ), + ) + + // ==================== TASK TEMPLATES ==================== + + val taskTemplates: List = listOf( + TaskTemplate( + id = 1, + title = "Replace HVAC filter", + description = "Swap out furnace/AC air filter.", + categoryId = 3, + category = taskCategories[2], + frequencyId = 3, + frequency = taskFrequencies[2], + iconIos = "air.purifier", + iconAndroid = "hvac", + tags = listOf("hvac", "seasonal"), + displayOrder = 1, + ), + TaskTemplate( + id = 2, + title = "Clean gutters", + description = "Clear leaves and debris from gutters and downspouts.", + categoryId = 4, + category = taskCategories[3], + frequencyId = 3, + frequency = taskFrequencies[2], + iconIos = "house.fill", + iconAndroid = "home", + tags = listOf("exterior", "seasonal"), + displayOrder = 2, + ), + ) + + val taskTemplatesGrouped: TaskTemplatesGroupedResponse = TaskTemplatesGroupedResponse( + categories = listOf( + TaskTemplateCategoryGroup( + categoryName = "HVAC", + categoryId = 3, + templates = taskTemplates.filter { it.categoryId == 3 }, + count = taskTemplates.count { it.categoryId == 3 }, + ), + TaskTemplateCategoryGroup( + categoryName = "Exterior", + categoryId = 4, + templates = taskTemplates.filter { it.categoryId == 4 }, + count = taskTemplates.count { it.categoryId == 4 }, + ), + ), + totalCount = taskTemplates.size, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/testing/InMemoryDataManager.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/testing/InMemoryDataManager.kt new file mode 100644 index 0000000..bac8038 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/testing/InMemoryDataManager.kt @@ -0,0 +1,123 @@ +package com.tt.honeyDue.testing + +import com.tt.honeyDue.data.IDataManager +import com.tt.honeyDue.models.ContractorSpecialty +import com.tt.honeyDue.models.ContractorSummary +import com.tt.honeyDue.models.Document +import com.tt.honeyDue.models.FeatureBenefit +import com.tt.honeyDue.models.MyResidencesResponse +import com.tt.honeyDue.models.Promotion +import com.tt.honeyDue.models.Residence +import com.tt.honeyDue.models.ResidenceSummaryResponse +import com.tt.honeyDue.models.ResidenceType +import com.tt.honeyDue.models.SubscriptionStatus +import com.tt.honeyDue.models.TaskCategory +import com.tt.honeyDue.models.TaskColumnsResponse +import com.tt.honeyDue.models.TaskFrequency +import com.tt.honeyDue.models.TaskPriority +import com.tt.honeyDue.models.TaskTemplate +import com.tt.honeyDue.models.TaskTemplatesGroupedResponse +import com.tt.honeyDue.models.TotalSummary +import com.tt.honeyDue.models.UpgradeTriggerData +import com.tt.honeyDue.models.User +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Test-only [IDataManager] backed by pre-populated [MutableStateFlow]s. + * + * Not intended for direct instantiation — use the factories on + * [FixtureDataManager] (`empty()` / `populated()`) which wire up consistent + * graphs from [Fixtures]. Because every field is constructor-injected, the + * class supports arbitrary test scenarios without touching the real + * [com.tt.honeyDue.data.DataManager] singleton. + * + * All lookup helpers resolve from the provided lists. The instance has no + * side effects: there are no API calls, no disk persistence, no clocks. + */ +class InMemoryDataManager( + currentUser: User? = null, + residences: List = emptyList(), + myResidencesResponse: MyResidencesResponse? = null, + totalSummary: TotalSummary? = null, + residenceSummaries: Map = emptyMap(), + allTasks: TaskColumnsResponse? = null, + tasksByResidence: Map = emptyMap(), + documents: List = emptyList(), + documentsByResidence: Map> = emptyMap(), + contractors: List = emptyList(), + subscription: SubscriptionStatus? = null, + upgradeTriggers: Map = emptyMap(), + featureBenefits: List = emptyList(), + promotions: List = emptyList(), + residenceTypes: List = emptyList(), + taskFrequencies: List = emptyList(), + taskPriorities: List = emptyList(), + taskCategories: List = emptyList(), + contractorSpecialties: List = emptyList(), + taskTemplates: List = emptyList(), + taskTemplatesGrouped: TaskTemplatesGroupedResponse? = null, +) : IDataManager { + + // ==================== AUTH ==================== + + override val currentUser: StateFlow = MutableStateFlow(currentUser) + + // ==================== RESIDENCES ==================== + + override val residences: StateFlow> = MutableStateFlow(residences) + override val myResidences: StateFlow = MutableStateFlow(myResidencesResponse) + override val totalSummary: StateFlow = MutableStateFlow(totalSummary) + override val residenceSummaries: StateFlow> = MutableStateFlow(residenceSummaries) + + // ==================== TASKS ==================== + + override val allTasks: StateFlow = MutableStateFlow(allTasks) + override val tasksByResidence: StateFlow> = MutableStateFlow(tasksByResidence) + + // ==================== DOCUMENTS ==================== + + override val documents: StateFlow> = MutableStateFlow(documents) + override val documentsByResidence: StateFlow>> = MutableStateFlow(documentsByResidence) + + // ==================== CONTRACTORS ==================== + + override val contractors: StateFlow> = MutableStateFlow(contractors) + + // ==================== SUBSCRIPTION ==================== + + override val subscription: StateFlow = MutableStateFlow(subscription) + override val upgradeTriggers: StateFlow> = MutableStateFlow(upgradeTriggers) + override val featureBenefits: StateFlow> = MutableStateFlow(featureBenefits) + override val promotions: StateFlow> = MutableStateFlow(promotions) + + // ==================== LOOKUPS ==================== + + override val residenceTypes: StateFlow> = MutableStateFlow(residenceTypes) + override val taskFrequencies: StateFlow> = MutableStateFlow(taskFrequencies) + override val taskPriorities: StateFlow> = MutableStateFlow(taskPriorities) + override val taskCategories: StateFlow> = MutableStateFlow(taskCategories) + override val contractorSpecialties: StateFlow> = MutableStateFlow(contractorSpecialties) + + // ==================== TASK TEMPLATES ==================== + + override val taskTemplates: StateFlow> = MutableStateFlow(taskTemplates) + override val taskTemplatesGrouped: StateFlow = MutableStateFlow(taskTemplatesGrouped) + + // ==================== LOOKUP HELPERS ==================== + + override fun getResidenceType(id: Int?): ResidenceType? = + id?.let { wanted -> residenceTypes.value.firstOrNull { it.id == wanted } } + + override fun getTaskFrequency(id: Int?): TaskFrequency? = + id?.let { wanted -> taskFrequencies.value.firstOrNull { it.id == wanted } } + + override fun getTaskPriority(id: Int?): TaskPriority? = + id?.let { wanted -> taskPriorities.value.firstOrNull { it.id == wanted } } + + override fun getTaskCategory(id: Int?): TaskCategory? = + id?.let { wanted -> taskCategories.value.firstOrNull { it.id == wanted } } + + override fun getContractorSpecialty(id: Int?): ContractorSpecialty? = + id?.let { wanted -> contractorSpecialties.value.firstOrNull { it.id == wanted } } +} diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/testing/FixtureDataManagerTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/testing/FixtureDataManagerTest.kt new file mode 100644 index 0000000..3b472df --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/testing/FixtureDataManagerTest.kt @@ -0,0 +1,204 @@ +package com.tt.honeyDue.testing + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Guarantees the fixtures consumed by the parity-gallery render deterministic, + * self-consistent data on every run. These tests do not invoke Compose — they + * assert structural invariants the snapshot tests rely on (unique ids, + * non-empty lookups in empty state, reachable references between tasks and + * residences, etc.). + * + * Failures here surface BEFORE the snapshot suite tries to record goldens + * against inconsistent data — keeps the parity gallery honest. + */ +class FixtureDataManagerTest { + + // ==================== EMPTY STATE ==================== + + @Test + fun empty_hasNoResidences() { + val dm = FixtureDataManager.empty() + assertTrue(dm.residences.value.isEmpty(), "empty() must have no residences") + assertNull(dm.myResidences.value, "empty() must have null myResidencesResponse") + } + + @Test + fun empty_hasNoTasksContractorsDocuments() { + val dm = FixtureDataManager.empty() + assertNull(dm.allTasks.value, "empty() must have null task kanban") + assertTrue(dm.contractors.value.isEmpty(), "empty() must have no contractors") + assertTrue(dm.documents.value.isEmpty(), "empty() must have no documents") + } + + @Test + fun empty_retainsLookupsForPickers() { + // Even in empty state, form pickers need lookup data — otherwise the + // "add first task" / "add first contractor" flows can't render dropdowns. + val dm = FixtureDataManager.empty() + assertTrue(dm.taskCategories.value.isNotEmpty(), "taskCategories must be populated in empty()") + assertTrue(dm.taskPriorities.value.isNotEmpty(), "taskPriorities must be populated in empty()") + assertTrue(dm.taskFrequencies.value.isNotEmpty(), "taskFrequencies must be populated in empty()") + assertTrue(dm.residenceTypes.value.isNotEmpty(), "residenceTypes must be populated in empty()") + assertTrue(dm.contractorSpecialties.value.isNotEmpty(), "contractorSpecialties must be populated in empty()") + } + + @Test + fun empty_providesFreeTierSubscription() { + val dm = FixtureDataManager.empty() + val sub = dm.subscription.value + assertNotNull(sub, "empty() should still have a free-tier SubscriptionStatus") + assertEquals("free", sub.tier) + } + + // ==================== POPULATED STATE ==================== + + @Test + fun populated_hasExpectedResidenceCounts() { + val dm = FixtureDataManager.populated() + assertEquals(2, dm.residences.value.size, "populated() must have 2 residences") + val myRes = dm.myResidences.value + assertNotNull(myRes) + assertEquals(2, myRes.residences.size) + } + + @Test + fun populated_hasKanbanBoardAndEightTasks() { + val dm = FixtureDataManager.populated() + val kanban = dm.allTasks.value + assertNotNull(kanban, "populated() must have a kanban board") + val allTasks = kanban.columns.flatMap { it.tasks } + assertEquals(8, allTasks.size, "populated() must surface exactly 8 tasks across columns") + } + + @Test + fun populated_tasksAreDistributedAcrossExpectedColumns() { + val dm = FixtureDataManager.populated() + val kanban = dm.allTasks.value + assertNotNull(kanban) + val byColumn = kanban.columns.associate { it.name to it.count } + // 2 overdue + 3 due-soon + 2 upcoming + 1 completed = 8 + assertEquals(2, byColumn["overdue_tasks"], "expected 2 overdue tasks") + assertEquals(3, byColumn["due_soon_tasks"], "expected 3 due-soon tasks") + assertEquals(2, byColumn["upcoming_tasks"], "expected 2 upcoming tasks") + assertEquals(1, byColumn["completed_tasks"], "expected 1 completed task") + } + + @Test + fun populated_hasExpectedContractorsAndDocuments() { + val dm = FixtureDataManager.populated() + assertEquals(3, dm.contractors.value.size, "populated() must have 3 contractors") + assertEquals(5, dm.documents.value.size, "populated() must have 5 documents") + } + + @Test + fun populated_providesPremiumSubscriptionAndUser() { + val dm = FixtureDataManager.populated() + val sub = dm.subscription.value + assertNotNull(sub) + assertEquals("pro", sub.tier, "populated() should render on the premium tier") + val user = dm.currentUser.value + assertNotNull(user, "populated() must have a current user for profile screens") + } + + // ==================== STRUCTURAL INVARIANTS ==================== + + @Test + fun populatedIdsAreUniqueAcrossCollections() { + val dm = FixtureDataManager.populated() + val residenceIds = dm.residences.value.map { it.id } + assertEquals(residenceIds.size, residenceIds.toSet().size, "residence ids must be unique") + + val allTasks = dm.allTasks.value?.columns?.flatMap { it.tasks }.orEmpty() + val taskIds = allTasks.map { it.id } + assertEquals(taskIds.size, taskIds.toSet().size, "task ids must be unique") + + val contractorIds = dm.contractors.value.map { it.id } + assertEquals(contractorIds.size, contractorIds.toSet().size, "contractor ids must be unique") + + val documentIds = dm.documents.value.mapNotNull { it.id } + assertEquals(documentIds.size, documentIds.toSet().size, "document ids must be unique") + } + + @Test + fun populatedTasksReferenceExistingResidences() { + val dm = FixtureDataManager.populated() + val residenceIds = dm.residences.value.map { it.id }.toSet() + val allTasks = dm.allTasks.value?.columns?.flatMap { it.tasks }.orEmpty() + allTasks.forEach { task -> + assertTrue( + residenceIds.contains(task.residenceId), + "task id=${task.id} references residence id=${task.residenceId} which isn't in the fixture residence list", + ) + } + } + + @Test + fun populatedDocumentsReferenceExistingResidences() { + val dm = FixtureDataManager.populated() + val residenceIds = dm.residences.value.map { it.id }.toSet() + dm.documents.value.forEach { doc -> + val rid = doc.residenceId ?: doc.residence + assertTrue( + residenceIds.contains(rid), + "document id=${doc.id} references residence id=$rid which isn't in the fixture residence list", + ) + } + } + + @Test + fun populatedLookupHelpersResolveIds() { + // InMemoryDataManager.getTaskPriority/Category/Frequency must resolve + // ids that appear on populated() tasks — otherwise task cards render + // with null categories/priorities, which breaks the parity gallery. + val dm = FixtureDataManager.populated() + val allTasks = dm.allTasks.value?.columns?.flatMap { it.tasks }.orEmpty() + allTasks.forEach { task -> + task.categoryId?.let { + assertNotNull(dm.getTaskCategory(it), "category id=$it must resolve on populated()") + } + task.priorityId?.let { + assertNotNull(dm.getTaskPriority(it), "priority id=$it must resolve on populated()") + } + task.frequencyId?.let { + assertNotNull(dm.getTaskFrequency(it), "frequency id=$it must resolve on populated()") + } + } + } + + @Test + fun populatedTotalSummaryMatchesTaskDistribution() { + val dm = FixtureDataManager.populated() + val summary = dm.totalSummary.value + assertNotNull(summary) + assertEquals(2, summary.totalResidences) + assertEquals(8, summary.totalTasks) + // 8 total - 1 completed = 7 pending (overdue + due_soon + upcoming) + assertEquals(7, summary.totalPending) + assertEquals(2, summary.totalOverdue) + } + + @Test + fun fixturesAreDeterministic() { + // Two calls to populated() should produce identical data. + val a = FixtureDataManager.populated() + val b = FixtureDataManager.populated() + assertEquals(a.residences.value.map { it.id }, b.residences.value.map { it.id }) + assertEquals( + a.allTasks.value?.columns?.flatMap { it.tasks }?.map { it.id }, + b.allTasks.value?.columns?.flatMap { it.tasks }?.map { it.id }, + ) + } + + @Test + fun fixedDateIsStable() { + // The parity gallery's determinism depends on a fixed clock. If a + // refactor accidentally swaps Fixtures.FIXED_DATE for Clock.System.now(), + // snapshot tests will go red every day — catch it here first. + assertEquals("2026-04-15", Fixtures.FIXED_DATE.toString()) + } +}