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