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:
@@ -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 ====================
|
||||
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user