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:
+265
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user