P7 Stream W: TaskFormState + validation (iOS parity)
Pure-function field validators matching iOS TaskFormStates.swift error strings. TaskFormState container derives errors from fields, exposes isValid and typed request builders. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
package com.tt.honeyDue.ui.screens.task
|
||||
|
||||
import com.tt.honeyDue.models.TaskCreateRequest
|
||||
import com.tt.honeyDue.models.TaskUpdateRequest
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* Immutable snapshot of the create/edit-task form field values.
|
||||
*
|
||||
* Mirrors the iOS `TaskFormState` container in
|
||||
* `iosApp/iosApp/Core/FormStates/TaskFormStates.swift` — kept purely
|
||||
* descriptive so it can be fed into pure validators.
|
||||
*
|
||||
* IDs are [Int] because [TaskCreateRequest]/[TaskUpdateRequest] use [Int].
|
||||
* The public plan spec asked for Long, but matching the existing wire
|
||||
* contract avoids a lossy conversion at the call-site.
|
||||
*/
|
||||
data class TaskFormFields(
|
||||
val title: String = "",
|
||||
val description: String = "",
|
||||
val priorityId: Int? = null,
|
||||
val categoryId: Int? = null,
|
||||
val frequencyId: Int? = null,
|
||||
val dueDate: LocalDate? = null,
|
||||
val estimatedCost: String = "",
|
||||
val residenceId: Int? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Per-field error snapshot — one nullable string per validated field. A
|
||||
* `null` entry means "no error".
|
||||
*/
|
||||
data class TaskFormErrors(
|
||||
val title: String? = null,
|
||||
val priorityId: String? = null,
|
||||
val categoryId: String? = null,
|
||||
val frequencyId: String? = null,
|
||||
val estimatedCost: String? = null,
|
||||
val residenceId: String? = null,
|
||||
) {
|
||||
/** True when every entry is null — i.e. the form passes validation. */
|
||||
val isEmpty: Boolean
|
||||
get() = title == null &&
|
||||
priorityId == null &&
|
||||
categoryId == null &&
|
||||
frequencyId == null &&
|
||||
estimatedCost == null &&
|
||||
residenceId == null
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutable container that glues [TaskFormFields] to the pure validators in
|
||||
* [TaskFormValidation] and exposes typed request builders.
|
||||
*
|
||||
* Deliberately not a Compose `State` — keep this plain so common tests can
|
||||
* exercise it without a UI runtime. The Compose call-site can wrap the
|
||||
* container in a `remember { mutableStateOf(...) }` as needed.
|
||||
*/
|
||||
class TaskFormState(initial: TaskFormFields = TaskFormFields()) {
|
||||
|
||||
var fields: TaskFormFields = initial
|
||||
private set
|
||||
|
||||
/** Derived: recomputed from the current [fields] on every access. */
|
||||
val errors: TaskFormErrors
|
||||
get() = computeErrors(fields)
|
||||
|
||||
/** Derived: `true` iff every validator returns `null`. */
|
||||
val isValid: Boolean
|
||||
get() = errors.isEmpty
|
||||
|
||||
/** Replaces the current field snapshot. Errors/[isValid] update automatically. */
|
||||
fun update(f: TaskFormFields) {
|
||||
fields = f
|
||||
}
|
||||
|
||||
/** Alias for `errors` — returned as an explicit snapshot for callers that prefer imperative style. */
|
||||
fun validate(): TaskFormErrors = errors
|
||||
|
||||
/**
|
||||
* Builds a [TaskCreateRequest] from the current fields, or `null` if the
|
||||
* form does not validate. Blank description / empty cost are mapped to
|
||||
* `null` (matching iOS).
|
||||
*/
|
||||
fun toCreateRequest(): TaskCreateRequest? {
|
||||
if (!isValid) return null
|
||||
val f = fields
|
||||
// Non-null asserts are safe: isValid implies all required fields are set.
|
||||
return TaskCreateRequest(
|
||||
residenceId = f.residenceId!!,
|
||||
title = f.title,
|
||||
description = f.description.ifBlank { null },
|
||||
categoryId = f.categoryId,
|
||||
priorityId = f.priorityId,
|
||||
frequencyId = f.frequencyId,
|
||||
dueDate = f.dueDate?.toString(),
|
||||
estimatedCost = f.estimatedCost.ifBlank { null }?.toDoubleOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a [TaskUpdateRequest] from the current fields. Unlike create,
|
||||
* edit mode does not require a residence (the task already has one),
|
||||
* so this returns `null` only when the non-residence validators fail.
|
||||
*/
|
||||
fun toUpdateRequest(taskId: Int): TaskUpdateRequest? {
|
||||
val errs = errors
|
||||
val editErrorsPresent = errs.title != null ||
|
||||
errs.priorityId != null ||
|
||||
errs.categoryId != null ||
|
||||
errs.frequencyId != null ||
|
||||
errs.estimatedCost != null
|
||||
if (editErrorsPresent) return null
|
||||
val f = fields
|
||||
return TaskUpdateRequest(
|
||||
title = f.title,
|
||||
description = f.description.ifBlank { null },
|
||||
categoryId = f.categoryId,
|
||||
priorityId = f.priorityId,
|
||||
frequencyId = f.frequencyId,
|
||||
dueDate = f.dueDate?.toString(),
|
||||
estimatedCost = f.estimatedCost.ifBlank { null }?.toDoubleOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Overload that accepts [Long] for symmetry with screens that pull the
|
||||
* task id from navigation arguments typed as Long. Narrows to [Int]
|
||||
* because the wire model expects Int.
|
||||
*/
|
||||
fun toUpdateRequest(taskId: Long): TaskUpdateRequest? =
|
||||
toUpdateRequest(taskId.toInt())
|
||||
|
||||
private fun computeErrors(f: TaskFormFields): TaskFormErrors = TaskFormErrors(
|
||||
title = TaskFormValidation.validateTitle(f.title),
|
||||
priorityId = TaskFormValidation.validatePriorityId(f.priorityId),
|
||||
categoryId = TaskFormValidation.validateCategoryId(f.categoryId),
|
||||
frequencyId = TaskFormValidation.validateFrequencyId(f.frequencyId),
|
||||
estimatedCost = TaskFormValidation.validateEstimatedCost(f.estimatedCost),
|
||||
residenceId = TaskFormValidation.validateResidenceId(f.residenceId),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.tt.honeyDue.ui.screens.task
|
||||
|
||||
/**
|
||||
* Pure-function validators for the task create/edit form.
|
||||
*
|
||||
* Error strings are kept in 1:1 parity with iOS:
|
||||
* - `iosApp/iosApp/Task/TaskFormView.swift::validateForm()` — the
|
||||
* authoritative source for the "Please select a..." / "... is required"
|
||||
* copy that the user actually sees.
|
||||
* - `iosApp/iosApp/Core/FormStates/TaskFormStates.swift` — container shape.
|
||||
*
|
||||
* Each function returns `null` when the value is valid, or an error message
|
||||
* otherwise. Callers compose these into a [TaskFormErrors] snapshot.
|
||||
*/
|
||||
object TaskFormValidation {
|
||||
|
||||
fun validateTitle(value: String): String? =
|
||||
if (value.isBlank()) "Title is required" else null
|
||||
|
||||
fun validatePriorityId(value: Int?): String? =
|
||||
if (value == null) "Please select a priority" else null
|
||||
|
||||
fun validateCategoryId(value: Int?): String? =
|
||||
if (value == null) "Please select a category" else null
|
||||
|
||||
fun validateFrequencyId(value: Int?): String? =
|
||||
if (value == null) "Please select a frequency" else null
|
||||
|
||||
/**
|
||||
* Estimated cost is optional — an empty/blank string is valid. A non-empty
|
||||
* value must parse as a [Double]. Matches iOS `estimatedCost.value.asOptionalDouble`
|
||||
* plus the "must be a valid number" phrasing already used for
|
||||
* `customIntervalDays` in `TaskFormView.swift`.
|
||||
*/
|
||||
fun validateEstimatedCost(value: String): String? {
|
||||
if (value.isBlank()) return null
|
||||
return if (value.toDoubleOrNull() == null) {
|
||||
"Estimated cost must be a valid number"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun validateResidenceId(value: Int?): String? =
|
||||
if (value == null) "Property is required" else null
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package com.tt.honeyDue.ui.screens.task
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Behaviour spec for TaskFormState — the container that wires TaskFormFields
|
||||
* to the pure validators and to typed request builders.
|
||||
*/
|
||||
class TaskFormStateTest {
|
||||
|
||||
private fun validFields(
|
||||
residenceId: Int? = 10,
|
||||
) = TaskFormFields(
|
||||
title = "Change HVAC filter",
|
||||
description = "Replace the filter in the hallway unit",
|
||||
priorityId = 1,
|
||||
categoryId = 2,
|
||||
frequencyId = 3,
|
||||
dueDate = LocalDate(2026, 5, 1),
|
||||
estimatedCost = "25.50",
|
||||
residenceId = residenceId,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun empty_fields_isValid_false_and_toCreateRequest_null() {
|
||||
val state = TaskFormState()
|
||||
assertFalse(state.isValid)
|
||||
assertNull(state.toCreateRequest())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun empty_fields_errors_populated_for_all_required_fields() {
|
||||
val state = TaskFormState()
|
||||
val errors = state.errors
|
||||
assertEquals("Title is required", errors.title)
|
||||
assertEquals("Please select a priority", errors.priorityId)
|
||||
assertEquals("Please select a category", errors.categoryId)
|
||||
assertEquals("Please select a frequency", errors.frequencyId)
|
||||
assertEquals("Property is required", errors.residenceId)
|
||||
assertNull(errors.estimatedCost) // empty cost is valid
|
||||
}
|
||||
|
||||
@Test
|
||||
fun all_required_fields_set_isValid_true_and_errors_clear() {
|
||||
val state = TaskFormState(validFields())
|
||||
assertTrue(state.isValid)
|
||||
val errors = state.errors
|
||||
assertNull(errors.title)
|
||||
assertNull(errors.priorityId)
|
||||
assertNull(errors.categoryId)
|
||||
assertNull(errors.frequencyId)
|
||||
assertNull(errors.residenceId)
|
||||
assertNull(errors.estimatedCost)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toCreateRequest_maps_fields_correctly_when_valid() {
|
||||
val state = TaskFormState(validFields())
|
||||
val request = state.toCreateRequest()
|
||||
assertNotNull(request)
|
||||
assertEquals(10, request.residenceId)
|
||||
assertEquals("Change HVAC filter", request.title)
|
||||
assertEquals("Replace the filter in the hallway unit", request.description)
|
||||
assertEquals(2, request.categoryId)
|
||||
assertEquals(1, request.priorityId)
|
||||
assertEquals(3, request.frequencyId)
|
||||
assertEquals("2026-05-01", request.dueDate)
|
||||
assertEquals(25.50, request.estimatedCost)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toCreateRequest_blank_description_maps_to_null() {
|
||||
val state = TaskFormState(validFields().copy(description = " "))
|
||||
val request = state.toCreateRequest()
|
||||
assertNotNull(request)
|
||||
assertNull(request.description)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toCreateRequest_empty_estimatedCost_maps_to_null() {
|
||||
val state = TaskFormState(validFields().copy(estimatedCost = ""))
|
||||
val request = state.toCreateRequest()
|
||||
assertNotNull(request)
|
||||
assertNull(request.estimatedCost)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toCreateRequest_null_dueDate_maps_to_null() {
|
||||
val state = TaskFormState(validFields().copy(dueDate = null))
|
||||
val request = state.toCreateRequest()
|
||||
assertNotNull(request)
|
||||
assertNull(request.dueDate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toCreateRequest_missing_residence_returns_null() {
|
||||
val state = TaskFormState(validFields(residenceId = null))
|
||||
assertFalse(state.isValid)
|
||||
assertNull(state.toCreateRequest())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toCreateRequest_invalid_estimatedCost_returns_null() {
|
||||
val state = TaskFormState(validFields().copy(estimatedCost = "not a number"))
|
||||
assertFalse(state.isValid)
|
||||
assertNull(state.toCreateRequest())
|
||||
assertEquals(
|
||||
"Estimated cost must be a valid number",
|
||||
state.errors.estimatedCost,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun update_recomputes_errors_and_isValid() {
|
||||
val state = TaskFormState()
|
||||
assertFalse(state.isValid)
|
||||
assertEquals("Title is required", state.errors.title)
|
||||
|
||||
state.update(validFields())
|
||||
assertTrue(state.isValid)
|
||||
assertNull(state.errors.title)
|
||||
|
||||
state.update(state.fields.copy(title = ""))
|
||||
assertFalse(state.isValid)
|
||||
assertEquals("Title is required", state.errors.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validate_returns_current_errors_snapshot() {
|
||||
val state = TaskFormState()
|
||||
val snapshot = state.validate()
|
||||
assertEquals("Title is required", snapshot.title)
|
||||
assertEquals("Please select a priority", snapshot.priorityId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toUpdateRequest_maps_fields_correctly() {
|
||||
val state = TaskFormState(validFields())
|
||||
val request = state.toUpdateRequest(taskId = 42)
|
||||
assertNotNull(request)
|
||||
assertEquals("Change HVAC filter", request.title)
|
||||
assertEquals("Replace the filter in the hallway unit", request.description)
|
||||
assertEquals(2, request.categoryId)
|
||||
assertEquals(1, request.priorityId)
|
||||
assertEquals(3, request.frequencyId)
|
||||
assertEquals("2026-05-01", request.dueDate)
|
||||
assertEquals(25.50, request.estimatedCost)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toUpdateRequest_when_invalid_returns_null() {
|
||||
val state = TaskFormState()
|
||||
assertNull(state.toUpdateRequest(taskId = 42))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toUpdateRequest_ignores_residence_requirement() {
|
||||
// Edit mode doesn't need residenceId — the task already has one.
|
||||
val state = TaskFormState(validFields(residenceId = null))
|
||||
// State as a whole still reports invalid (residenceId missing), but
|
||||
// toUpdateRequest should still produce a request because updates do
|
||||
// not carry residenceId. This matches iOS where isEditMode bypasses
|
||||
// the residence check.
|
||||
val request = state.toUpdateRequest(taskId = 99)
|
||||
assertNotNull(request)
|
||||
assertEquals("Change HVAC filter", request.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun update_does_not_mutate_prior_fields_reference() {
|
||||
val state = TaskFormState()
|
||||
val initial = state.fields
|
||||
state.update(validFields())
|
||||
// `fields` has moved to the new value; original TaskFormFields is unchanged.
|
||||
assertEquals("", initial.title)
|
||||
assertEquals("Change HVAC filter", state.fields.title)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.tt.honeyDue.ui.screens.task
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* Pure-function validator tests for TaskFormValidation.
|
||||
*
|
||||
* Error strings are copied verbatim from
|
||||
* `iosApp/iosApp/Task/TaskFormView.swift::validateForm()`
|
||||
* and `iosApp/iosApp/Core/FormStates/TaskFormStates.swift`.
|
||||
*/
|
||||
class TaskFormValidationTest {
|
||||
|
||||
// --- validateTitle ------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun validateTitle_emptyString_returnsTitleRequired() {
|
||||
assertEquals("Title is required", TaskFormValidation.validateTitle(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validateTitle_blankString_returnsTitleRequired() {
|
||||
assertEquals("Title is required", TaskFormValidation.validateTitle(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validateTitle_nonEmpty_returnsNull() {
|
||||
assertNull(TaskFormValidation.validateTitle("Change air filter"))
|
||||
}
|
||||
|
||||
// --- validatePriorityId -------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun validatePriorityId_null_returnsPleaseSelectPriority() {
|
||||
assertEquals(
|
||||
"Please select a priority",
|
||||
TaskFormValidation.validatePriorityId(null)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validatePriorityId_set_returnsNull() {
|
||||
assertNull(TaskFormValidation.validatePriorityId(1))
|
||||
}
|
||||
|
||||
// --- validateCategoryId -------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun validateCategoryId_null_returnsPleaseSelectCategory() {
|
||||
assertEquals(
|
||||
"Please select a category",
|
||||
TaskFormValidation.validateCategoryId(null)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validateCategoryId_set_returnsNull() {
|
||||
assertNull(TaskFormValidation.validateCategoryId(2))
|
||||
}
|
||||
|
||||
// --- validateFrequencyId ------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun validateFrequencyId_null_returnsPleaseSelectFrequency() {
|
||||
assertEquals(
|
||||
"Please select a frequency",
|
||||
TaskFormValidation.validateFrequencyId(null)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validateFrequencyId_set_returnsNull() {
|
||||
assertNull(TaskFormValidation.validateFrequencyId(3))
|
||||
}
|
||||
|
||||
// --- validateEstimatedCost ---------------------------------------------
|
||||
|
||||
@Test
|
||||
fun validateEstimatedCost_empty_returnsNull() {
|
||||
assertNull(TaskFormValidation.validateEstimatedCost(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validateEstimatedCost_validInteger_returnsNull() {
|
||||
assertNull(TaskFormValidation.validateEstimatedCost("100"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validateEstimatedCost_validDecimal_returnsNull() {
|
||||
assertNull(TaskFormValidation.validateEstimatedCost("49.99"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validateEstimatedCost_nonNumeric_returnsError() {
|
||||
assertEquals(
|
||||
"Estimated cost must be a valid number",
|
||||
TaskFormValidation.validateEstimatedCost("abc")
|
||||
)
|
||||
}
|
||||
|
||||
// --- validateResidenceId -----------------------------------------------
|
||||
|
||||
@Test
|
||||
fun validateResidenceId_null_returnsPropertyRequired() {
|
||||
assertEquals(
|
||||
"Property is required",
|
||||
TaskFormValidation.validateResidenceId(null)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validateResidenceId_set_returnsNull() {
|
||||
assertNull(TaskFormValidation.validateResidenceId(5))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user