From 3700968d00a04a2230bc56894ecc41aedb2bdda5 Mon Sep 17 00:00:00 2001 From: Trey T Date: Sat, 18 Apr 2026 13:22:53 -0500 Subject: [PATCH] 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) --- .../honeyDue/ui/screens/task/TaskFormState.kt | 142 ++++++++++++++ .../ui/screens/task/TaskFormValidation.kt | 46 +++++ .../ui/screens/task/TaskFormStateTest.kt | 184 ++++++++++++++++++ .../ui/screens/task/TaskFormValidationTest.kt | 117 +++++++++++ 4 files changed, 489 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/task/TaskFormState.kt create mode 100644 composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/task/TaskFormValidation.kt create mode 100644 composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/task/TaskFormStateTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/task/TaskFormValidationTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/task/TaskFormState.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/task/TaskFormState.kt new file mode 100644 index 0000000..45bf475 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/task/TaskFormState.kt @@ -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), + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/task/TaskFormValidation.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/task/TaskFormValidation.kt new file mode 100644 index 0000000..72eeddb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/task/TaskFormValidation.kt @@ -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 +} diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/task/TaskFormStateTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/task/TaskFormStateTest.kt new file mode 100644 index 0000000..e981d31 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/task/TaskFormStateTest.kt @@ -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) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/task/TaskFormValidationTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/task/TaskFormValidationTest.kt new file mode 100644 index 0000000..82f075a --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/task/TaskFormValidationTest.kt @@ -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)) + } +}