P2 Stream G: TaskTemplatesBrowserScreen (replaces dialog/sheet)
Full browse-and-select experience matching iOS TaskTemplatesBrowserView. Category filter, multi-select, bulk-create with templateId backlink. Analytics events wired. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+344
@@ -0,0 +1,344 @@
|
||||
package com.tt.honeyDue.ui.screens.task
|
||||
|
||||
import com.tt.honeyDue.analytics.AnalyticsEvents
|
||||
import com.tt.honeyDue.models.BulkCreateTasksRequest
|
||||
import com.tt.honeyDue.models.BulkCreateTasksResponse
|
||||
import com.tt.honeyDue.models.TaskCategory
|
||||
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.TotalSummary
|
||||
import com.tt.honeyDue.network.ApiResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Unit tests for TaskTemplatesBrowserViewModel covering:
|
||||
* 1. Load grouped templates on load()
|
||||
* 2. Category filter narrows templates
|
||||
* 3. Multi-select toggle add/remove
|
||||
* 4. canApply reflects empty selection
|
||||
* 5. apply() calls bulkCreateTasks with templateId backlink
|
||||
* 6. apply() fires analytics events on success (onboarding vs non-onboarding)
|
||||
* 7. apply() surfaces API error and does NOT clear selection
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class TaskTemplatesBrowserViewModelTest {
|
||||
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(dispatcher)
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
// ---------- Fixtures ----------
|
||||
|
||||
private val plumbingCat = TaskCategory(id = 1, name = "Plumbing")
|
||||
private val hvacCat = TaskCategory(id = 2, name = "HVAC")
|
||||
|
||||
private val template1 = TaskTemplate(
|
||||
id = 100,
|
||||
title = "Change Water Filter",
|
||||
description = "Replace every 6 months.",
|
||||
categoryId = 1,
|
||||
category = plumbingCat
|
||||
)
|
||||
private val template2 = TaskTemplate(
|
||||
id = 101,
|
||||
title = "Flush Water Heater",
|
||||
description = "Annual flush.",
|
||||
categoryId = 1,
|
||||
category = plumbingCat
|
||||
)
|
||||
private val template3 = TaskTemplate(
|
||||
id = 200,
|
||||
title = "Replace HVAC Filter",
|
||||
description = "Every 3 months.",
|
||||
categoryId = 2,
|
||||
category = hvacCat
|
||||
)
|
||||
|
||||
private val grouped = TaskTemplatesGroupedResponse(
|
||||
categories = listOf(
|
||||
TaskTemplateCategoryGroup(
|
||||
categoryName = "Plumbing",
|
||||
categoryId = 1,
|
||||
templates = listOf(template1, template2),
|
||||
count = 2
|
||||
),
|
||||
TaskTemplateCategoryGroup(
|
||||
categoryName = "HVAC",
|
||||
categoryId = 2,
|
||||
templates = listOf(template3),
|
||||
count = 1
|
||||
)
|
||||
),
|
||||
totalCount = 3
|
||||
)
|
||||
|
||||
private fun fakeBulkResponse(count: Int) = BulkCreateTasksResponse(
|
||||
tasks = emptyList<TaskResponse>(),
|
||||
summary = TotalSummary(
|
||||
totalResidences = 1,
|
||||
totalTasks = count,
|
||||
totalPending = count,
|
||||
totalOverdue = 0,
|
||||
tasksDueNextWeek = 0,
|
||||
tasksDueNextMonth = count
|
||||
),
|
||||
createdCount = count
|
||||
)
|
||||
|
||||
private fun makeViewModel(
|
||||
loadResult: ApiResult<TaskTemplatesGroupedResponse> = ApiResult.Success(grouped),
|
||||
bulkResult: ApiResult<BulkCreateTasksResponse> = ApiResult.Success(fakeBulkResponse(2)),
|
||||
onBulkCall: (BulkCreateTasksRequest) -> Unit = {},
|
||||
onAnalytics: (String, Map<String, Any>) -> Unit = { _, _ -> },
|
||||
fromOnboarding: Boolean = false
|
||||
) = TaskTemplatesBrowserViewModel(
|
||||
residenceId = 42,
|
||||
fromOnboarding = fromOnboarding,
|
||||
loadTemplates = { loadResult },
|
||||
bulkCreate = { request ->
|
||||
onBulkCall(request)
|
||||
bulkResult
|
||||
},
|
||||
analytics = onAnalytics
|
||||
)
|
||||
|
||||
// ---------- Tests ----------
|
||||
|
||||
@Test
|
||||
fun initialStateIsIdleAndEmpty() {
|
||||
val vm = makeViewModel()
|
||||
assertIs<ApiResult.Idle>(vm.templatesState.value)
|
||||
assertIs<ApiResult.Idle>(vm.applyState.value)
|
||||
assertTrue(vm.selectedTemplateIds.value.isEmpty())
|
||||
assertNull(vm.selectedCategory.value)
|
||||
assertFalse(vm.canApply)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadPopulatesTemplatesStateOnSuccess() = runTest(dispatcher) {
|
||||
val vm = makeViewModel()
|
||||
vm.load()
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
val state = vm.templatesState.value
|
||||
assertIs<ApiResult.Success<TaskTemplatesGroupedResponse>>(state)
|
||||
assertEquals(3, state.data.totalCount)
|
||||
assertEquals(listOf("Plumbing", "HVAC"), vm.categoryNames)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun filteringByCategoryNarrowsTemplates() = runTest(dispatcher) {
|
||||
val vm = makeViewModel()
|
||||
vm.load()
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
// No filter: all three visible
|
||||
assertEquals(3, vm.filteredTemplates().size)
|
||||
|
||||
vm.selectCategory("Plumbing")
|
||||
assertEquals(2, vm.filteredTemplates().size)
|
||||
assertTrue(vm.filteredTemplates().all { it.categoryId == 1 })
|
||||
|
||||
vm.selectCategory("HVAC")
|
||||
assertEquals(1, vm.filteredTemplates().size)
|
||||
assertEquals(200, vm.filteredTemplates().first().id)
|
||||
|
||||
vm.selectCategory(null)
|
||||
assertEquals(3, vm.filteredTemplates().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toggleSelectionAddsAndRemovesIds() = runTest(dispatcher) {
|
||||
val vm = makeViewModel()
|
||||
|
||||
vm.toggleSelection(100)
|
||||
assertEquals(setOf(100), vm.selectedTemplateIds.value)
|
||||
assertTrue(vm.canApply)
|
||||
|
||||
vm.toggleSelection(101)
|
||||
assertEquals(setOf(100, 101), vm.selectedTemplateIds.value)
|
||||
|
||||
vm.toggleSelection(100)
|
||||
assertEquals(setOf(101), vm.selectedTemplateIds.value)
|
||||
|
||||
vm.toggleSelection(101)
|
||||
assertTrue(vm.selectedTemplateIds.value.isEmpty())
|
||||
assertFalse(vm.canApply)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun applyIsNoopWhenSelectionEmpty() = runTest(dispatcher) {
|
||||
var bulkCalled = false
|
||||
val vm = makeViewModel(onBulkCall = { bulkCalled = true })
|
||||
|
||||
vm.apply()
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
assertFalse(bulkCalled, "bulkCreate should not be called when selection empty")
|
||||
assertIs<ApiResult.Idle>(vm.applyState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun applyBuildsRequestsWithTemplateIdBacklink() = runTest(dispatcher) {
|
||||
var captured: BulkCreateTasksRequest? = null
|
||||
val vm = makeViewModel(onBulkCall = { captured = it })
|
||||
|
||||
vm.load()
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
vm.toggleSelection(100)
|
||||
vm.toggleSelection(200)
|
||||
|
||||
vm.apply()
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
val req = captured ?: error("bulkCreate was not called")
|
||||
assertEquals(42, req.residenceId)
|
||||
assertEquals(2, req.tasks.size)
|
||||
val templateIds = req.tasks.map { it.templateId }.toSet()
|
||||
assertEquals(setOf(100, 200), templateIds)
|
||||
|
||||
// Every entry should inherit the residenceId and carry the template title.
|
||||
assertTrue(req.tasks.all { it.residenceId == 42 })
|
||||
val titles = req.tasks.map { it.title }.toSet()
|
||||
assertEquals(setOf("Change Water Filter", "Replace HVAC Filter"), titles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun applySuccessSetsApplyStateWithCreatedCount() = runTest(dispatcher) {
|
||||
val vm = makeViewModel(
|
||||
bulkResult = ApiResult.Success(fakeBulkResponse(2))
|
||||
)
|
||||
|
||||
vm.load()
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
vm.toggleSelection(100)
|
||||
vm.toggleSelection(101)
|
||||
|
||||
vm.apply()
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
val state = vm.applyState.value
|
||||
assertIs<ApiResult.Success<Int>>(state)
|
||||
assertEquals(2, state.data)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun applyFiresNonOnboardingAnalyticsWhenNotFromOnboarding() = runTest(dispatcher) {
|
||||
val events = mutableListOf<Pair<String, Map<String, Any>>>()
|
||||
val vm = makeViewModel(
|
||||
bulkResult = ApiResult.Success(fakeBulkResponse(2)),
|
||||
onAnalytics = { name, props -> events += name to props },
|
||||
fromOnboarding = false
|
||||
)
|
||||
|
||||
vm.load()
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
vm.toggleSelection(100)
|
||||
vm.toggleSelection(200)
|
||||
|
||||
vm.apply()
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
// Per-template accepted events + one bulk_created summary event
|
||||
val perTemplate = events.filter {
|
||||
it.first == TaskTemplatesBrowserViewModel.EVENT_TASK_TEMPLATE_ACCEPTED
|
||||
}
|
||||
assertEquals(2, perTemplate.size)
|
||||
assertTrue(perTemplate.all { it.second["template_id"] is Int })
|
||||
|
||||
val bulk = events.firstOrNull {
|
||||
it.first == TaskTemplatesBrowserViewModel.EVENT_TASK_TEMPLATES_BULK_CREATED
|
||||
}
|
||||
assertTrue(bulk != null, "expected bulk_created event")
|
||||
assertEquals(2, bulk.second["count"])
|
||||
|
||||
// Onboarding events should NOT fire in the non-onboarding path.
|
||||
assertTrue(events.none { it.first == AnalyticsEvents.ONBOARDING_TASKS_CREATED })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun applyFiresOnboardingAnalyticsWhenFromOnboarding() = runTest(dispatcher) {
|
||||
val events = mutableListOf<Pair<String, Map<String, Any>>>()
|
||||
val vm = makeViewModel(
|
||||
bulkResult = ApiResult.Success(fakeBulkResponse(1)),
|
||||
onAnalytics = { name, props -> events += name to props },
|
||||
fromOnboarding = true
|
||||
)
|
||||
|
||||
vm.load()
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
vm.toggleSelection(100)
|
||||
|
||||
vm.apply()
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
val accepted = events.firstOrNull {
|
||||
it.first == AnalyticsEvents.ONBOARDING_BROWSE_TEMPLATE_ACCEPTED
|
||||
}
|
||||
assertTrue(accepted != null)
|
||||
assertEquals(100, accepted.second["template_id"])
|
||||
|
||||
val created = events.firstOrNull {
|
||||
it.first == AnalyticsEvents.ONBOARDING_TASKS_CREATED
|
||||
}
|
||||
assertTrue(created != null)
|
||||
assertEquals(1, created.second["count"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun applyErrorSurfacesErrorAndKeepsSelection() = runTest(dispatcher) {
|
||||
val vm = makeViewModel(
|
||||
bulkResult = ApiResult.Error("Network down", 500),
|
||||
fromOnboarding = false
|
||||
)
|
||||
vm.load()
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
vm.toggleSelection(100)
|
||||
val selectedBefore = vm.selectedTemplateIds.value
|
||||
|
||||
vm.apply()
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
val state = vm.applyState.value
|
||||
assertIs<ApiResult.Error>(state)
|
||||
assertEquals("Network down", state.message)
|
||||
// Selection retained so the user can retry without re-picking.
|
||||
assertEquals(selectedBefore, vm.selectedTemplateIds.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadErrorLeavesTemplatesStateError() = runTest(dispatcher) {
|
||||
val vm = makeViewModel(
|
||||
loadResult = ApiResult.Error("Boom", 500)
|
||||
)
|
||||
vm.load()
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
assertIs<ApiResult.Error>(vm.templatesState.value)
|
||||
assertTrue(vm.filteredTemplates().isEmpty())
|
||||
assertTrue(vm.categoryNames.isEmpty())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user