P2 Stream I: AddTaskWithResidenceScreen

Port iOS AddTaskWithResidenceView. Residence pre-selected via constructor
param, form validation, submit -> APILayer.createTask(residenceId attached).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 13:22:13 -05:00
parent 46db133458
commit edc22c0d2b
4 changed files with 587 additions and 0 deletions
@@ -0,0 +1,176 @@
package com.tt.honeyDue.ui.screens.task
import com.tt.honeyDue.models.TaskCreateRequest
import com.tt.honeyDue.models.TaskResponse
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.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Unit tests for [AddTaskWithResidenceViewModel] — the state-logic layer
* behind AddTaskWithResidenceScreen (P2 Stream I). Covers validation,
* submit -> APILayer.createTask wiring, residenceId pre-selection, and
* success/error outcomes.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class AddTaskWithResidenceViewModelTest {
private val dispatcher = StandardTestDispatcher()
@BeforeTest
fun setUp() { Dispatchers.setMain(dispatcher) }
@AfterTest
fun tearDown() { Dispatchers.resetMain() }
private fun fakeCreatedTask(): TaskResponse = TaskResponse(
id = 1,
residenceId = 42,
createdById = 1,
title = "Created",
description = "",
createdAt = "2024-01-01T00:00:00Z",
updatedAt = "2024-01-01T00:00:00Z"
)
private fun makeViewModel(
residenceId: Int = 42,
createResult: ApiResult<TaskResponse> = ApiResult.Success(fakeCreatedTask()),
onCreateCall: (TaskCreateRequest) -> Unit = {}
) = AddTaskWithResidenceViewModel(
residenceId = residenceId,
createTask = { request ->
onCreateCall(request)
createResult
}
)
@Test
fun titleEmpty_submitDisabled() {
val vm = makeViewModel()
assertTrue(vm.title.value.isEmpty())
assertFalse(vm.canSubmit.value, "submit should be disabled with empty title")
}
@Test
fun titleValid_submitEnabled() {
val vm = makeViewModel()
vm.onTitleChange("Change water filter")
assertTrue(vm.canSubmit.value, "submit should be enabled when title is non-empty")
}
@Test
fun titleWhitespaceOnly_submitDisabled() {
val vm = makeViewModel()
vm.onTitleChange(" ")
assertFalse(vm.canSubmit.value, "whitespace-only title should not enable submit")
}
@Test
fun submit_buildsTaskCreateRequestWithResidenceId() = runTest(dispatcher) {
var captured: TaskCreateRequest? = null
val vm = makeViewModel(
residenceId = 42,
onCreateCall = { captured = it }
)
vm.onTitleChange("Flush water heater")
vm.onDescriptionChange("Annual flush")
vm.onCategoryIdChange(3)
vm.onFrequencyIdChange(7)
vm.onPriorityIdChange(2)
vm.onDueDateChange("2024-06-15")
vm.onEstimatedCostChange("150.50")
vm.submit(onSuccess = {})
dispatcher.scheduler.advanceUntilIdle()
val req = assertNotNull(captured, "createTask was not called")
assertEquals(42, req.residenceId)
assertEquals("Flush water heater", req.title)
assertEquals("Annual flush", req.description)
assertEquals(3, req.categoryId)
assertEquals(7, req.frequencyId)
assertEquals(2, req.priorityId)
assertEquals("2024-06-15", req.dueDate)
assertEquals(150.50, req.estimatedCost)
}
@Test
fun submit_success_invokesOnCreated() = runTest(dispatcher) {
var createdCalled = 0
val vm = makeViewModel(createResult = ApiResult.Success(fakeCreatedTask()))
vm.onTitleChange("Clean gutters")
vm.submit(onSuccess = { createdCalled++ })
dispatcher.scheduler.advanceUntilIdle()
assertEquals(1, createdCalled, "onCreated should fire exactly once on success")
assertIs<ApiResult.Success<TaskResponse>>(vm.submitState.value)
}
@Test
fun submit_failure_surfacesError() = runTest(dispatcher) {
var createdCalled = 0
val vm = makeViewModel(createResult = ApiResult.Error("Server exploded", 500))
vm.onTitleChange("Mow lawn")
vm.submit(onSuccess = { createdCalled++ })
dispatcher.scheduler.advanceUntilIdle()
val state = vm.submitState.value
assertIs<ApiResult.Error>(state)
assertEquals("Server exploded", state.message)
assertEquals(0, createdCalled, "onCreated must NOT fire on API error")
}
@Test
fun residenceId_passedIntoRequest() = runTest(dispatcher) {
var captured: TaskCreateRequest? = null
val vm = makeViewModel(
residenceId = 999,
onCreateCall = { captured = it }
)
vm.onTitleChange("Replace batteries")
vm.submit(onSuccess = {})
dispatcher.scheduler.advanceUntilIdle()
assertEquals(999, captured?.residenceId)
}
@Test
fun submit_emptyTitle_doesNotCallCreateTask() = runTest(dispatcher) {
var callCount = 0
val vm = makeViewModel(onCreateCall = { callCount++ })
// Title intentionally left blank.
vm.submit(onSuccess = {})
dispatcher.scheduler.advanceUntilIdle()
assertEquals(0, callCount, "createTask must not be called when title is blank")
assertEquals("Title is required", vm.titleError.value)
}
@Test
fun submit_omitsOptionalFieldsWhenBlank() = runTest(dispatcher) {
var captured: TaskCreateRequest? = null
val vm = makeViewModel(onCreateCall = { captured = it })
vm.onTitleChange("Just a title")
// Leave description, dueDate, estimatedCost blank.
vm.submit(onSuccess = {})
dispatcher.scheduler.advanceUntilIdle()
val req = assertNotNull(captured)
assertNull(req.description, "blank description should serialize as null")
assertNull(req.dueDate, "blank dueDate should serialize as null")
assertNull(req.estimatedCost, "blank estimatedCost should serialize as null")
}
}