P1: Shared FixtureDataManager (empty + populated) for cross-platform snapshots

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) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 19:22:41 -05:00
parent c57743dca0
commit 47eaf5a0c0
6 changed files with 1263 additions and 31 deletions

View File

@@ -125,35 +125,35 @@ object DataManager : IDataManager {
override val residences: StateFlow<List<Residence>> = _residences.asStateFlow()
private val _myResidences = MutableStateFlow<MyResidencesResponse?>(null)
val myResidences: StateFlow<MyResidencesResponse?> = _myResidences.asStateFlow()
override val myResidences: StateFlow<MyResidencesResponse?> = _myResidences.asStateFlow()
private val _totalSummary = MutableStateFlow<TotalSummary?>(null)
override val totalSummary: StateFlow<TotalSummary?> = _totalSummary.asStateFlow()
private val _residenceSummaries = MutableStateFlow<Map<Int, ResidenceSummaryResponse>>(emptyMap())
val residenceSummaries: StateFlow<Map<Int, ResidenceSummaryResponse>> = _residenceSummaries.asStateFlow()
override val residenceSummaries: StateFlow<Map<Int, ResidenceSummaryResponse>> = _residenceSummaries.asStateFlow()
// ==================== TASKS ====================
private val _allTasks = MutableStateFlow<TaskColumnsResponse?>(null)
val allTasks: StateFlow<TaskColumnsResponse?> = _allTasks.asStateFlow()
override val allTasks: StateFlow<TaskColumnsResponse?> = _allTasks.asStateFlow()
private val _tasksByResidence = MutableStateFlow<Map<Int, TaskColumnsResponse>>(emptyMap())
val tasksByResidence: StateFlow<Map<Int, TaskColumnsResponse>> = _tasksByResidence.asStateFlow()
override val tasksByResidence: StateFlow<Map<Int, TaskColumnsResponse>> = _tasksByResidence.asStateFlow()
// ==================== DOCUMENTS ====================
private val _documents = MutableStateFlow<List<Document>>(emptyList())
val documents: StateFlow<List<Document>> = _documents.asStateFlow()
override val documents: StateFlow<List<Document>> = _documents.asStateFlow()
private val _documentsByResidence = MutableStateFlow<Map<Int, List<Document>>>(emptyMap())
val documentsByResidence: StateFlow<Map<Int, List<Document>>> = _documentsByResidence.asStateFlow()
override val documentsByResidence: StateFlow<Map<Int, List<Document>>> = _documentsByResidence.asStateFlow()
// ==================== CONTRACTORS ====================
// Stores ContractorSummary for list views (lighter weight than full Contractor)
private val _contractors = MutableStateFlow<List<ContractorSummary>>(emptyList())
val contractors: StateFlow<List<ContractorSummary>> = _contractors.asStateFlow()
override val contractors: StateFlow<List<ContractorSummary>> = _contractors.asStateFlow()
// ==================== SUBSCRIPTION ====================
@@ -161,39 +161,39 @@ object DataManager : IDataManager {
override val subscription: StateFlow<SubscriptionStatus?> = _subscription.asStateFlow()
private val _upgradeTriggers = MutableStateFlow<Map<String, UpgradeTriggerData>>(emptyMap())
val upgradeTriggers: StateFlow<Map<String, UpgradeTriggerData>> = _upgradeTriggers.asStateFlow()
override val upgradeTriggers: StateFlow<Map<String, UpgradeTriggerData>> = _upgradeTriggers.asStateFlow()
private val _featureBenefits = MutableStateFlow<List<FeatureBenefit>>(emptyList())
override val featureBenefits: StateFlow<List<FeatureBenefit>> = _featureBenefits.asStateFlow()
private val _promotions = MutableStateFlow<List<Promotion>>(emptyList())
val promotions: StateFlow<List<Promotion>> = _promotions.asStateFlow()
override val promotions: StateFlow<List<Promotion>> = _promotions.asStateFlow()
// ==================== LOOKUPS (Reference Data) ====================
// List-based for dropdowns/pickers
private val _residenceTypes = MutableStateFlow<List<ResidenceType>>(emptyList())
val residenceTypes: StateFlow<List<ResidenceType>> = _residenceTypes.asStateFlow()
override val residenceTypes: StateFlow<List<ResidenceType>> = _residenceTypes.asStateFlow()
private val _taskFrequencies = MutableStateFlow<List<TaskFrequency>>(emptyList())
val taskFrequencies: StateFlow<List<TaskFrequency>> = _taskFrequencies.asStateFlow()
override val taskFrequencies: StateFlow<List<TaskFrequency>> = _taskFrequencies.asStateFlow()
private val _taskPriorities = MutableStateFlow<List<TaskPriority>>(emptyList())
val taskPriorities: StateFlow<List<TaskPriority>> = _taskPriorities.asStateFlow()
override val taskPriorities: StateFlow<List<TaskPriority>> = _taskPriorities.asStateFlow()
private val _taskCategories = MutableStateFlow<List<TaskCategory>>(emptyList())
val taskCategories: StateFlow<List<TaskCategory>> = _taskCategories.asStateFlow()
override val taskCategories: StateFlow<List<TaskCategory>> = _taskCategories.asStateFlow()
private val _contractorSpecialties = MutableStateFlow<List<ContractorSpecialty>>(emptyList())
val contractorSpecialties: StateFlow<List<ContractorSpecialty>> = _contractorSpecialties.asStateFlow()
override val contractorSpecialties: StateFlow<List<ContractorSpecialty>> = _contractorSpecialties.asStateFlow()
// ==================== TASK TEMPLATES ====================
private val _taskTemplates = MutableStateFlow<List<TaskTemplate>>(emptyList())
val taskTemplates: StateFlow<List<TaskTemplate>> = _taskTemplates.asStateFlow()
override val taskTemplates: StateFlow<List<TaskTemplate>> = _taskTemplates.asStateFlow()
private val _taskTemplatesGrouped = MutableStateFlow<TaskTemplatesGroupedResponse?>(null)
val taskTemplatesGrouped: StateFlow<TaskTemplatesGroupedResponse?> = _taskTemplatesGrouped.asStateFlow()
override val taskTemplatesGrouped: StateFlow<TaskTemplatesGroupedResponse?> = _taskTemplatesGrouped.asStateFlow()
// Map-based for O(1) ID resolution
private val _residenceTypesMap = MutableStateFlow<Map<Int, ResidenceType>>(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 ====================

View File

@@ -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<User?>
// ==================== RESIDENCES ====================
/** Observed by [com.tt.honeyDue.ui.screens.ContractorDetailScreen] and the onboarding first-task screen. */
val residences: StateFlow<List<Residence>>
/** Full my-residences API response (may include metadata beyond the list itself). */
val myResidences: StateFlow<MyResidencesResponse?>
/** Observed by [com.tt.honeyDue.ui.screens.HomeScreen] and [com.tt.honeyDue.ui.screens.ResidencesScreen]. */
val totalSummary: StateFlow<TotalSummary?>
/** Per-residence summary cache (keyed by residence id). */
val residenceSummaries: StateFlow<Map<Int, ResidenceSummaryResponse>>
// ==================== TASKS ====================
/** Kanban board covering all tasks across all residences. */
val allTasks: StateFlow<TaskColumnsResponse?>
/** Kanban board cache keyed by residence id. */
val tasksByResidence: StateFlow<Map<Int, TaskColumnsResponse>>
// ==================== DOCUMENTS ====================
val documents: StateFlow<List<Document>>
val documentsByResidence: StateFlow<Map<Int, List<Document>>>
// ==================== CONTRACTORS ====================
val contractors: StateFlow<List<ContractorSummary>>
// ==================== SUBSCRIPTION ====================
/** Observed by [com.tt.honeyDue.ui.screens.ProfileScreen]. */
val subscription: StateFlow<SubscriptionStatus?>
val upgradeTriggers: StateFlow<Map<String, UpgradeTriggerData>>
/** Observed by [com.tt.honeyDue.ui.screens.subscription.FeatureComparisonScreen]. */
val featureBenefits: StateFlow<List<FeatureBenefit>>
/** Observed by [com.tt.honeyDue.ui.screens.ProfileScreen]. */
val subscription: StateFlow<SubscriptionStatus?>
val promotions: StateFlow<List<Promotion>>
// ==================== LOOKUPS ====================
val residenceTypes: StateFlow<List<ResidenceType>>
val taskFrequencies: StateFlow<List<TaskFrequency>>
val taskPriorities: StateFlow<List<TaskPriority>>
val taskCategories: StateFlow<List<TaskCategory>>
val contractorSpecialties: StateFlow<List<ContractorSpecialty>>
// ==================== TASK TEMPLATES ====================
val taskTemplates: StateFlow<List<TaskTemplate>>
val taskTemplatesGrouped: StateFlow<TaskTemplatesGroupedResponse?>
// ==================== 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?
}

View File

@@ -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,
)
}

View File

@@ -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<ResidenceType> = 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<TaskCategory> = 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<TaskPriority> = 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<TaskFrequency> = 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<ContractorSpecialty> = 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<Residence> = listOf(primaryHome, lakeCabin)
val myResidencesResponse: MyResidencesResponse = MyResidencesResponse(residences = residences)
val residenceSummaries: Map<Int, ResidenceSummaryResponse> = 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<TaskResponse> = 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<Contractor> = 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<ContractorSummary> = contractors.map { it.toSummary() }
// ==================== DOCUMENTS ====================
val documents: List<Document> = 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<Int, List<Document>> = 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<String, TierLimits> = 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<FeatureBenefit> = 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<TaskTemplate> = 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,
)
}

View File

@@ -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<Residence> = emptyList(),
myResidencesResponse: MyResidencesResponse? = null,
totalSummary: TotalSummary? = null,
residenceSummaries: Map<Int, ResidenceSummaryResponse> = emptyMap(),
allTasks: TaskColumnsResponse? = null,
tasksByResidence: Map<Int, TaskColumnsResponse> = emptyMap(),
documents: List<Document> = emptyList(),
documentsByResidence: Map<Int, List<Document>> = emptyMap(),
contractors: List<ContractorSummary> = emptyList(),
subscription: SubscriptionStatus? = null,
upgradeTriggers: Map<String, UpgradeTriggerData> = emptyMap(),
featureBenefits: List<FeatureBenefit> = emptyList(),
promotions: List<Promotion> = emptyList(),
residenceTypes: List<ResidenceType> = emptyList(),
taskFrequencies: List<TaskFrequency> = emptyList(),
taskPriorities: List<TaskPriority> = emptyList(),
taskCategories: List<TaskCategory> = emptyList(),
contractorSpecialties: List<ContractorSpecialty> = emptyList(),
taskTemplates: List<TaskTemplate> = emptyList(),
taskTemplatesGrouped: TaskTemplatesGroupedResponse? = null,
) : IDataManager {
// ==================== AUTH ====================
override val currentUser: StateFlow<User?> = MutableStateFlow(currentUser)
// ==================== RESIDENCES ====================
override val residences: StateFlow<List<Residence>> = MutableStateFlow(residences)
override val myResidences: StateFlow<MyResidencesResponse?> = MutableStateFlow(myResidencesResponse)
override val totalSummary: StateFlow<TotalSummary?> = MutableStateFlow(totalSummary)
override val residenceSummaries: StateFlow<Map<Int, ResidenceSummaryResponse>> = MutableStateFlow(residenceSummaries)
// ==================== TASKS ====================
override val allTasks: StateFlow<TaskColumnsResponse?> = MutableStateFlow(allTasks)
override val tasksByResidence: StateFlow<Map<Int, TaskColumnsResponse>> = MutableStateFlow(tasksByResidence)
// ==================== DOCUMENTS ====================
override val documents: StateFlow<List<Document>> = MutableStateFlow(documents)
override val documentsByResidence: StateFlow<Map<Int, List<Document>>> = MutableStateFlow(documentsByResidence)
// ==================== CONTRACTORS ====================
override val contractors: StateFlow<List<ContractorSummary>> = MutableStateFlow(contractors)
// ==================== SUBSCRIPTION ====================
override val subscription: StateFlow<SubscriptionStatus?> = MutableStateFlow(subscription)
override val upgradeTriggers: StateFlow<Map<String, UpgradeTriggerData>> = MutableStateFlow(upgradeTriggers)
override val featureBenefits: StateFlow<List<FeatureBenefit>> = MutableStateFlow(featureBenefits)
override val promotions: StateFlow<List<Promotion>> = MutableStateFlow(promotions)
// ==================== LOOKUPS ====================
override val residenceTypes: StateFlow<List<ResidenceType>> = MutableStateFlow(residenceTypes)
override val taskFrequencies: StateFlow<List<TaskFrequency>> = MutableStateFlow(taskFrequencies)
override val taskPriorities: StateFlow<List<TaskPriority>> = MutableStateFlow(taskPriorities)
override val taskCategories: StateFlow<List<TaskCategory>> = MutableStateFlow(taskCategories)
override val contractorSpecialties: StateFlow<List<ContractorSpecialty>> = MutableStateFlow(contractorSpecialties)
// ==================== TASK TEMPLATES ====================
override val taskTemplates: StateFlow<List<TaskTemplate>> = MutableStateFlow(taskTemplates)
override val taskTemplatesGrouped: StateFlow<TaskTemplatesGroupedResponse?> = 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 } }
}

View File

@@ -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())
}
}