P7 Stream X: ResidenceFormState + validation (iOS parity)

Pure-function field validators matching iOS ResidenceFormView rules.
Mirrors Stream W's TaskFormState pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 13:34:49 -05:00
parent 1cbeeafa2d
commit cf2aca583b
4 changed files with 725 additions and 0 deletions
@@ -0,0 +1,214 @@
package com.tt.honeyDue.ui.screens.residence
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 [ResidenceFormState] — container that wires
* [ResidenceFormFields] to the pure validators and to typed request
* builders. Mirrors Stream W's `TaskFormStateTest` structure.
*/
class ResidenceFormStateTest {
private fun validFields() = ResidenceFormFields(
name = "Main House",
propertyTypeId = 2,
streetAddress = "123 Main St",
apartmentUnit = "Apt 4",
city = "Springfield",
stateProvince = "IL",
postalCode = "62701",
country = "USA",
bedrooms = "3",
bathrooms = "2.5",
squareFootage = "1800",
lotSize = "0.25",
yearBuilt = "1998",
description = "Three-bedroom single-family home",
isPrimary = true,
)
// --- defaults / empty --------------------------------------------------
@Test
fun default_fields_have_usa_country_and_blank_name() {
val fields = ResidenceFormFields()
assertEquals("USA", fields.country)
assertEquals("", fields.name)
assertFalse(fields.isPrimary)
}
@Test
fun empty_state_isValid_false_and_name_error_present() {
val state = ResidenceFormState()
assertFalse(state.isValid)
assertEquals("Name is required", state.errors.name)
}
@Test
fun empty_state_toCreateRequest_null() {
assertNull(ResidenceFormState().toCreateRequest())
}
// --- valid path --------------------------------------------------------
@Test
fun valid_fields_isValid_true() {
val state = ResidenceFormState(validFields())
assertTrue(state.isValid)
val e = state.errors
assertNull(e.name)
assertNull(e.bedrooms)
assertNull(e.bathrooms)
assertNull(e.squareFootage)
assertNull(e.lotSize)
assertNull(e.yearBuilt)
}
@Test
fun only_name_required_for_validity() {
// iOS canSave: !name.isEmpty — no other required fields.
val state = ResidenceFormState(ResidenceFormFields(name = "Cabin"))
assertTrue(state.isValid)
}
// --- toCreateRequest ---------------------------------------------------
@Test
fun toCreateRequest_maps_fields_correctly_when_valid() {
val state = ResidenceFormState(validFields())
val req = state.toCreateRequest()
assertNotNull(req)
assertEquals("Main House", req.name)
assertEquals(2, req.propertyTypeId)
assertEquals("123 Main St", req.streetAddress)
assertEquals("Apt 4", req.apartmentUnit)
assertEquals("Springfield", req.city)
assertEquals("IL", req.stateProvince)
assertEquals("62701", req.postalCode)
assertEquals("USA", req.country)
assertEquals(3, req.bedrooms)
assertEquals(2.5, req.bathrooms)
assertEquals(1800, req.squareFootage)
assertEquals(0.25, req.lotSize)
assertEquals(1998, req.yearBuilt)
assertEquals("Three-bedroom single-family home", req.description)
assertEquals(true, req.isPrimary)
}
@Test
fun toCreateRequest_blank_optional_strings_map_to_null() {
val state = ResidenceFormState(
validFields().copy(
streetAddress = "",
apartmentUnit = " ",
city = "",
stateProvince = "",
postalCode = "",
country = "",
description = "",
)
)
val req = state.toCreateRequest()
assertNotNull(req)
assertNull(req.streetAddress)
assertNull(req.apartmentUnit)
assertNull(req.city)
assertNull(req.stateProvince)
assertNull(req.postalCode)
assertNull(req.country)
assertNull(req.description)
}
@Test
fun toCreateRequest_empty_numeric_fields_map_to_null() {
val state = ResidenceFormState(
validFields().copy(
bedrooms = "",
bathrooms = "",
squareFootage = "",
lotSize = "",
yearBuilt = "",
)
)
val req = state.toCreateRequest()
assertNotNull(req)
assertNull(req.bedrooms)
assertNull(req.bathrooms)
assertNull(req.squareFootage)
assertNull(req.lotSize)
assertNull(req.yearBuilt)
}
@Test
fun toCreateRequest_invalid_numeric_returns_null() {
val state = ResidenceFormState(validFields().copy(bedrooms = "-1"))
assertFalse(state.isValid)
assertNull(state.toCreateRequest())
assertNotNull(state.errors.bedrooms)
}
@Test
fun toCreateRequest_invalid_yearBuilt_returns_null() {
val state = ResidenceFormState(validFields().copy(yearBuilt = "42"))
assertFalse(state.isValid)
assertNull(state.toCreateRequest())
}
// --- toUpdateRequest ---------------------------------------------------
@Test
fun toUpdateRequest_maps_fields_correctly_when_valid() {
val state = ResidenceFormState(validFields())
val req = state.toUpdateRequest(residenceId = 42)
assertNotNull(req)
assertEquals("Main House", req.name)
assertEquals(2, req.propertyTypeId)
assertEquals(3, req.bedrooms)
assertEquals(2.5, req.bathrooms)
assertEquals(1800, req.squareFootage)
assertEquals(1998, req.yearBuilt)
assertEquals(true, req.isPrimary)
}
@Test
fun toUpdateRequest_returns_null_when_invalid() {
assertNull(ResidenceFormState().toUpdateRequest(residenceId = 1))
}
// --- update / validate -------------------------------------------------
@Test
fun update_recomputes_errors_and_isValid() {
val state = ResidenceFormState()
assertFalse(state.isValid)
assertEquals("Name is required", state.errors.name)
state.update(validFields())
assertTrue(state.isValid)
assertNull(state.errors.name)
state.update(state.fields.copy(name = ""))
assertFalse(state.isValid)
assertEquals("Name is required", state.errors.name)
}
@Test
fun validate_returns_current_errors_snapshot() {
val state = ResidenceFormState()
val snapshot = state.validate()
assertEquals("Name is required", snapshot.name)
}
@Test
fun toUpdateRequest_long_overload_narrows_to_int() {
val state = ResidenceFormState(validFields())
val req = state.toUpdateRequest(residenceId = 99L)
assertNotNull(req)
assertEquals("Main House", req.name)
}
}
@@ -0,0 +1,242 @@
package com.tt.honeyDue.ui.screens.residence
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* Pure-function validator tests for [ResidenceFormValidation].
*
* Rules are derived from iOS:
* - `iosApp/iosApp/ResidenceFormView.swift::validateForm()` — only `name`
* is required; `canSave: !name.isEmpty`.
* - `iosApp/iosApp/Core/FormStates/ResidenceFormState.swift` — numeric
* fields (bedrooms, bathrooms, squareFootage, lotSize, yearBuilt) use
* `asOptionalInt`/`asOptionalDouble` — empty is valid, non-numeric
* silently returns nil. We surface that as a user-facing error here so
* the KMM client can tell the user their input was dropped rather than
* failing silently.
*
* Error copy uses the same phrasing style as Stream W's
* `TaskFormValidation` ("... is required", "... must be a valid number").
*/
class ResidenceFormValidationTest {
// --- validateName ------------------------------------------------------
@Test
fun validateName_emptyString_returnsNameRequired() {
assertEquals("Name is required", ResidenceFormValidation.validateName(""))
}
@Test
fun validateName_blankString_returnsNameRequired() {
assertEquals("Name is required", ResidenceFormValidation.validateName(" "))
}
@Test
fun validateName_nonEmpty_returnsNull() {
assertNull(ResidenceFormValidation.validateName("Home"))
}
@Test
fun validateName_atMaxLength_returnsNull() {
assertNull(ResidenceFormValidation.validateName("a".repeat(100)))
}
@Test
fun validateName_overMaxLength_returnsError() {
assertEquals(
"Name must be 100 characters or fewer",
ResidenceFormValidation.validateName("a".repeat(101)),
)
}
// --- validateBedrooms --------------------------------------------------
@Test
fun validateBedrooms_empty_returnsNull() {
assertNull(ResidenceFormValidation.validateBedrooms(""))
}
@Test
fun validateBedrooms_validInteger_returnsNull() {
assertNull(ResidenceFormValidation.validateBedrooms("3"))
}
@Test
fun validateBedrooms_zero_returnsNull() {
// iOS `asOptionalInt` accepts 0. Match that behaviour.
assertNull(ResidenceFormValidation.validateBedrooms("0"))
}
@Test
fun validateBedrooms_negative_returnsError() {
assertEquals(
"Bedrooms must be a non-negative whole number",
ResidenceFormValidation.validateBedrooms("-1"),
)
}
@Test
fun validateBedrooms_decimal_returnsError() {
assertEquals(
"Bedrooms must be a non-negative whole number",
ResidenceFormValidation.validateBedrooms("2.5"),
)
}
@Test
fun validateBedrooms_nonNumeric_returnsError() {
assertEquals(
"Bedrooms must be a non-negative whole number",
ResidenceFormValidation.validateBedrooms("abc"),
)
}
// --- validateBathrooms -------------------------------------------------
@Test
fun validateBathrooms_empty_returnsNull() {
assertNull(ResidenceFormValidation.validateBathrooms(""))
}
@Test
fun validateBathrooms_validDecimal_returnsNull() {
assertNull(ResidenceFormValidation.validateBathrooms("2.5"))
}
@Test
fun validateBathrooms_validInteger_returnsNull() {
assertNull(ResidenceFormValidation.validateBathrooms("3"))
}
@Test
fun validateBathrooms_negative_returnsError() {
assertEquals(
"Bathrooms must be a non-negative number",
ResidenceFormValidation.validateBathrooms("-0.5"),
)
}
@Test
fun validateBathrooms_nonNumeric_returnsError() {
assertEquals(
"Bathrooms must be a non-negative number",
ResidenceFormValidation.validateBathrooms("abc"),
)
}
// --- validateSquareFootage --------------------------------------------
@Test
fun validateSquareFootage_empty_returnsNull() {
assertNull(ResidenceFormValidation.validateSquareFootage(""))
}
@Test
fun validateSquareFootage_validInteger_returnsNull() {
assertNull(ResidenceFormValidation.validateSquareFootage("1500"))
}
@Test
fun validateSquareFootage_zero_returnsError() {
assertEquals(
"Square footage must be a positive whole number",
ResidenceFormValidation.validateSquareFootage("0"),
)
}
@Test
fun validateSquareFootage_negative_returnsError() {
assertEquals(
"Square footage must be a positive whole number",
ResidenceFormValidation.validateSquareFootage("-100"),
)
}
@Test
fun validateSquareFootage_nonNumeric_returnsError() {
assertEquals(
"Square footage must be a positive whole number",
ResidenceFormValidation.validateSquareFootage("big"),
)
}
// --- validateLotSize ---------------------------------------------------
@Test
fun validateLotSize_empty_returnsNull() {
assertNull(ResidenceFormValidation.validateLotSize(""))
}
@Test
fun validateLotSize_validDecimal_returnsNull() {
assertNull(ResidenceFormValidation.validateLotSize("0.25"))
}
@Test
fun validateLotSize_negative_returnsError() {
assertEquals(
"Lot size must be a positive number",
ResidenceFormValidation.validateLotSize("-1"),
)
}
@Test
fun validateLotSize_zero_returnsError() {
assertEquals(
"Lot size must be a positive number",
ResidenceFormValidation.validateLotSize("0"),
)
}
@Test
fun validateLotSize_nonNumeric_returnsError() {
assertEquals(
"Lot size must be a positive number",
ResidenceFormValidation.validateLotSize("huge"),
)
}
// --- validateYearBuilt -------------------------------------------------
@Test
fun validateYearBuilt_empty_returnsNull() {
assertNull(ResidenceFormValidation.validateYearBuilt(""))
}
@Test
fun validateYearBuilt_validYear_returnsNull() {
assertNull(ResidenceFormValidation.validateYearBuilt("1998"))
}
@Test
fun validateYearBuilt_earliestValid_returnsNull() {
assertNull(ResidenceFormValidation.validateYearBuilt("1800"))
}
@Test
fun validateYearBuilt_beforeMinimum_returnsError() {
assertEquals(
"Year built must be between 1800 and the current year",
ResidenceFormValidation.validateYearBuilt("1799"),
)
}
@Test
fun validateYearBuilt_wrongDigitCount_returnsError() {
assertEquals(
"Year built must be a 4-digit year",
ResidenceFormValidation.validateYearBuilt("98"),
)
}
@Test
fun validateYearBuilt_nonNumeric_returnsError() {
assertEquals(
"Year built must be a 4-digit year",
ResidenceFormValidation.validateYearBuilt("abcd"),
)
}
}