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:
Trey T
2026-04-18 12:56:01 -05:00
parent 1fcb456ef1
commit ee135c4673
6 changed files with 1122 additions and 433 deletions
@@ -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())
}
}