P2 Stream H: standalone TaskSuggestionsScreen

Port iOS TaskSuggestionsView as a standalone route reachable outside
onboarding. Uses shared suggestions API + accept/skip analytics in
non-onboarding variant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 13:10:47 -05:00
parent 7d71408bcc
commit 19471d780d
19 changed files with 2161 additions and 3 deletions
@@ -0,0 +1,265 @@
package com.tt.honeyDue.ui.screens.task
import com.tt.honeyDue.models.TaskCategory
import com.tt.honeyDue.models.TaskCreateRequest
import com.tt.honeyDue.models.TaskResponse
import com.tt.honeyDue.models.TaskSuggestionResponse
import com.tt.honeyDue.models.TaskSuggestionsResponse
import com.tt.honeyDue.models.TaskTemplate
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.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Unit tests for TaskSuggestionsViewModel covering:
* 1. Initial state is Idle.
* 2. load() transitions to Success with suggestions.
* 3. Empty suggestions -> Success with empty list (empty state UI).
* 4. Accept fires createTask with exact template fields + templateId backlink.
* 5. Accept success fires non-onboarding analytics task_suggestion_accepted.
* 6. Load error surfaces ApiResult.Error + retry() reloads endpoint.
* 7. ViewModel is constructible with an explicit residenceId (standalone path).
* 8. Accept error surfaces error and fires no analytics.
* 9. resetAcceptState returns to Idle.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class TaskSuggestionsViewModelTest {
private val dispatcher = StandardTestDispatcher()
@BeforeTest
fun setUp() { Dispatchers.setMain(dispatcher) }
@AfterTest
fun tearDown() { Dispatchers.resetMain() }
private val plumbingCat = TaskCategory(id = 1, name = "Plumbing")
private val template1 = TaskTemplate(
id = 100,
title = "Change Water Filter",
description = "Replace every 6 months.",
categoryId = 1,
category = plumbingCat,
frequencyId = 7
)
private val template2 = TaskTemplate(
id = 200,
title = "Flush Water Heater",
description = "Annual flush.",
categoryId = 1,
category = plumbingCat,
frequencyId = 8
)
private val suggestionsResponse = TaskSuggestionsResponse(
suggestions = listOf(
TaskSuggestionResponse(
template = template1,
relevanceScore = 0.92,
matchReasons = listOf("Has water heater")
),
TaskSuggestionResponse(
template = template2,
relevanceScore = 0.75,
matchReasons = listOf("Annual maintenance")
)
),
totalCount = 2,
profileCompleteness = 0.8
)
private val emptyResponse = TaskSuggestionsResponse(
suggestions = emptyList(),
totalCount = 0,
profileCompleteness = 0.5
)
private fun fakeCreatedTask(templateId: Int?): TaskResponse = TaskResponse(
id = 777,
residenceId = 42,
createdById = 1,
title = "Created",
description = "",
categoryId = 1,
frequencyId = 7,
templateId = templateId,
createdAt = "2024-01-01T00:00:00Z",
updatedAt = "2024-01-01T00:00:00Z"
)
private fun makeViewModel(
loadResult: ApiResult<TaskSuggestionsResponse> = ApiResult.Success(suggestionsResponse),
createResult: ApiResult<TaskResponse> = ApiResult.Success(fakeCreatedTask(100)),
onCreateCall: (TaskCreateRequest) -> Unit = {},
onAnalytics: (String, Map<String, Any>) -> Unit = { _, _ -> },
residenceId: Int = 42
) = TaskSuggestionsViewModel(
residenceId = residenceId,
loadSuggestions = { loadResult },
createTask = { request ->
onCreateCall(request)
createResult
},
analytics = onAnalytics
)
@Test
fun initialStateIsIdle() {
val vm = makeViewModel()
assertIs<ApiResult.Idle>(vm.suggestionsState.value)
assertIs<ApiResult.Idle>(vm.acceptState.value)
}
@Test
fun loadTransitionsToSuccessWithSuggestions() = runTest(dispatcher) {
val vm = makeViewModel()
vm.load()
dispatcher.scheduler.advanceUntilIdle()
val state = vm.suggestionsState.value
assertIs<ApiResult.Success<TaskSuggestionsResponse>>(state)
assertEquals(2, state.data.totalCount)
assertEquals(100, state.data.suggestions.first().template.id)
}
@Test
fun emptySuggestionsResolvesToSuccessWithEmptyList() = runTest(dispatcher) {
val vm = makeViewModel(loadResult = ApiResult.Success(emptyResponse))
vm.load()
dispatcher.scheduler.advanceUntilIdle()
val state = vm.suggestionsState.value
assertIs<ApiResult.Success<TaskSuggestionsResponse>>(state)
assertTrue(state.data.suggestions.isEmpty())
assertEquals(0, state.data.totalCount)
}
@Test
fun acceptInvokesCreateTaskWithTemplateIdBacklinkAndFields() = runTest(dispatcher) {
var captured: TaskCreateRequest? = null
val vm = makeViewModel(onCreateCall = { captured = it })
vm.load()
dispatcher.scheduler.advanceUntilIdle()
vm.accept(suggestionsResponse.suggestions.first())
dispatcher.scheduler.advanceUntilIdle()
val req = captured ?: error("createTask was not called")
assertEquals(42, req.residenceId)
assertEquals("Change Water Filter", req.title)
assertEquals("Replace every 6 months.", req.description)
assertEquals(1, req.categoryId)
assertEquals(7, req.frequencyId)
assertEquals(100, req.templateId)
}
@Test
fun acceptSuccessFiresNonOnboardingAnalyticsEvent() = runTest(dispatcher) {
val events = mutableListOf<Pair<String, Map<String, Any>>>()
val vm = makeViewModel(onAnalytics = { name, props -> events += name to props })
vm.load()
dispatcher.scheduler.advanceUntilIdle()
vm.accept(suggestionsResponse.suggestions.first())
dispatcher.scheduler.advanceUntilIdle()
val accepted = events.firstOrNull {
it.first == TaskSuggestionsViewModel.EVENT_TASK_SUGGESTION_ACCEPTED
}
assertTrue(accepted != null, "expected task_suggestion_accepted event")
assertEquals(100, accepted.second["template_id"])
assertEquals(0.92, accepted.second["relevance_score"])
assertTrue(events.none { it.first == "onboarding_suggestion_accepted" })
val state = vm.acceptState.value
assertIs<ApiResult.Success<TaskResponse>>(state)
}
@Test
fun loadErrorSurfacesErrorAndRetryReloads() = runTest(dispatcher) {
var callCount = 0
var nextResult: ApiResult<TaskSuggestionsResponse> = ApiResult.Error("Network down", 500)
val vm = TaskSuggestionsViewModel(
residenceId = 42,
loadSuggestions = {
callCount++
nextResult
},
createTask = { ApiResult.Success(fakeCreatedTask(null)) },
analytics = { _, _ -> }
)
vm.load()
dispatcher.scheduler.advanceUntilIdle()
assertIs<ApiResult.Error>(vm.suggestionsState.value)
assertEquals(1, callCount)
nextResult = ApiResult.Success(suggestionsResponse)
vm.retry()
dispatcher.scheduler.advanceUntilIdle()
assertEquals(2, callCount)
assertIs<ApiResult.Success<TaskSuggestionsResponse>>(vm.suggestionsState.value)
}
@Test
fun viewModelUsesProvidedResidenceIdOnStandalonePath() = runTest(dispatcher) {
var captured: TaskCreateRequest? = null
val vm = makeViewModel(
residenceId = 999,
onCreateCall = { captured = it }
)
vm.load()
dispatcher.scheduler.advanceUntilIdle()
vm.accept(suggestionsResponse.suggestions.first())
dispatcher.scheduler.advanceUntilIdle()
assertEquals(999, captured?.residenceId)
}
@Test
fun acceptErrorSurfacesErrorAndFiresNoAnalytics() = runTest(dispatcher) {
val events = mutableListOf<Pair<String, Map<String, Any>>>()
val vm = makeViewModel(
createResult = ApiResult.Error("Server error", 500),
onAnalytics = { name, props -> events += name to props }
)
vm.load()
dispatcher.scheduler.advanceUntilIdle()
vm.accept(suggestionsResponse.suggestions.first())
dispatcher.scheduler.advanceUntilIdle()
val state = vm.acceptState.value
assertIs<ApiResult.Error>(state)
assertEquals("Server error", state.message)
assertTrue(events.isEmpty(), "analytics should not fire on accept error")
}
@Test
fun resetAcceptStateReturnsToIdle() = runTest(dispatcher) {
val vm = makeViewModel(createResult = ApiResult.Error("boom", 500))
vm.load()
dispatcher.scheduler.advanceUntilIdle()
vm.accept(suggestionsResponse.suggestions.first())
dispatcher.scheduler.advanceUntilIdle()
assertIs<ApiResult.Error>(vm.acceptState.value)
vm.resetAcceptState()
assertIs<ApiResult.Idle>(vm.acceptState.value)
assertNull(vm.lastAcceptedTemplateId)
}
}