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

View File

@@ -0,0 +1,160 @@
package com.tt.honeyDue.ui.screens.residence
import com.tt.honeyDue.models.ResidenceCreateRequest
import com.tt.honeyDue.models.ResidenceUpdateRequest
/**
* Immutable snapshot of the create/edit-residence form field values.
*
* Mirrors the iOS `ResidenceFormView` state
* (`iosApp/iosApp/ResidenceFormView.swift`) and its companion
* `ResidenceFormState` (`iosApp/iosApp/Core/FormStates/ResidenceFormState.swift`).
*
* All fields except [name] are optional; string numeric fields stay as
* strings here so the Compose layer can bind them directly to text inputs.
* The `toCreateRequest` / `toUpdateRequest` builders perform the final
* parse after validation.
*/
data class ResidenceFormFields(
val name: String = "",
val propertyTypeId: Int? = null,
val streetAddress: String = "",
val apartmentUnit: String = "",
val city: String = "",
val stateProvince: String = "",
val postalCode: String = "",
val country: String = "USA",
val bedrooms: String = "",
val bathrooms: String = "",
val squareFootage: String = "",
val lotSize: String = "",
val yearBuilt: String = "",
val description: String = "",
val isPrimary: Boolean = false,
)
/**
* Per-field error snapshot — one nullable string per validated field.
* A `null` entry means "no error". Non-validated fields (address parts,
* description, etc.) are not listed here because iOS has no rules for them.
*/
data class ResidenceFormErrors(
val name: String? = null,
val bedrooms: String? = null,
val bathrooms: String? = null,
val squareFootage: String? = null,
val lotSize: String? = null,
val yearBuilt: String? = null,
) {
/** True when every entry is null — i.e. the form passes validation. */
val isEmpty: Boolean
get() = name == null &&
bedrooms == null &&
bathrooms == null &&
squareFootage == null &&
lotSize == null &&
yearBuilt == null
}
/**
* Mutable container that glues [ResidenceFormFields] to the pure
* validators in [ResidenceFormValidation] and exposes typed request
* builders. Mirrors the Stream W `TaskFormState` pattern.
*
* 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 `remember { mutableStateOf(...) }` as needed.
*/
class ResidenceFormState(initial: ResidenceFormFields = ResidenceFormFields()) {
var fields: ResidenceFormFields = initial
private set
/** Derived: recomputed from the current [fields] on every access. */
val errors: ResidenceFormErrors
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: ResidenceFormFields) {
fields = f
}
/** Alias for `errors` — returned as an explicit snapshot for callers that prefer imperative style. */
fun validate(): ResidenceFormErrors = errors
/**
* Builds a [ResidenceCreateRequest] from the current fields, or `null`
* if the form does not validate. Blank optional strings are mapped to
* `null` to match the iOS `submitForm()` mapping.
*/
fun toCreateRequest(): ResidenceCreateRequest? {
if (!isValid) return null
val f = fields
return ResidenceCreateRequest(
name = f.name,
propertyTypeId = f.propertyTypeId,
streetAddress = f.streetAddress.blankToNull(),
apartmentUnit = f.apartmentUnit.blankToNull(),
city = f.city.blankToNull(),
stateProvince = f.stateProvince.blankToNull(),
postalCode = f.postalCode.blankToNull(),
country = f.country.blankToNull(),
bedrooms = f.bedrooms.blankToNull()?.toIntOrNull(),
bathrooms = f.bathrooms.blankToNull()?.toDoubleOrNull(),
squareFootage = f.squareFootage.blankToNull()?.toIntOrNull(),
lotSize = f.lotSize.blankToNull()?.toDoubleOrNull(),
yearBuilt = f.yearBuilt.blankToNull()?.toIntOrNull(),
description = f.description.blankToNull(),
isPrimary = f.isPrimary,
)
}
/**
* Builds a [ResidenceUpdateRequest] from the current fields, or `null`
* if the form does not validate. Uses the same blank-to-null mapping
* as create.
*/
fun toUpdateRequest(residenceId: Int): ResidenceUpdateRequest? {
if (!isValid) return null
val f = fields
return ResidenceUpdateRequest(
name = f.name,
propertyTypeId = f.propertyTypeId,
streetAddress = f.streetAddress.blankToNull(),
apartmentUnit = f.apartmentUnit.blankToNull(),
city = f.city.blankToNull(),
stateProvince = f.stateProvince.blankToNull(),
postalCode = f.postalCode.blankToNull(),
country = f.country.blankToNull(),
bedrooms = f.bedrooms.blankToNull()?.toIntOrNull(),
bathrooms = f.bathrooms.blankToNull()?.toDoubleOrNull(),
squareFootage = f.squareFootage.blankToNull()?.toIntOrNull(),
lotSize = f.lotSize.blankToNull()?.toDoubleOrNull(),
yearBuilt = f.yearBuilt.blankToNull()?.toIntOrNull(),
description = f.description.blankToNull(),
isPrimary = f.isPrimary,
)
}
/**
* Overload for nav args typed as [Long]. Narrows to [Int] because the
* wire model uses Int.
*/
fun toUpdateRequest(residenceId: Long): ResidenceUpdateRequest? =
toUpdateRequest(residenceId.toInt())
private fun computeErrors(f: ResidenceFormFields): ResidenceFormErrors = ResidenceFormErrors(
name = ResidenceFormValidation.validateName(f.name),
bedrooms = ResidenceFormValidation.validateBedrooms(f.bedrooms),
bathrooms = ResidenceFormValidation.validateBathrooms(f.bathrooms),
squareFootage = ResidenceFormValidation.validateSquareFootage(f.squareFootage),
lotSize = ResidenceFormValidation.validateLotSize(f.lotSize),
yearBuilt = ResidenceFormValidation.validateYearBuilt(f.yearBuilt),
)
private fun String.blankToNull(): String? = ifBlank { null }
}

View File

@@ -0,0 +1,109 @@
package com.tt.honeyDue.ui.screens.residence
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
/**
* Pure-function validators for the residence create/edit form.
*
* Parity with iOS:
* - `iosApp/iosApp/ResidenceFormView.swift::validateForm()` — only `name`
* is required (the iOS `canSave` and `validateForm` both gate solely on
* `!name.isEmpty`). We keep that contract: all address/feature fields are
* optional.
* - `iosApp/iosApp/Core/FormStates/ResidenceFormState.swift` — numeric
* fields use `asOptionalInt`/`asOptionalDouble` which silently drop
* unparseable values. On KMM we surface a user-facing error instead so
* the client isn't dropping input on the floor, matching the phrasing
* used by Stream W's `TaskFormValidation`.
*
* Each function returns `null` when the value is valid, or an error
* message otherwise. Callers compose these into a [ResidenceFormErrors]
* snapshot.
*/
object ResidenceFormValidation {
/** Maximum length enforced by the backend's `name` column. */
private const val NAME_MAX_LENGTH = 100
/** iOS uses `yearBuilt` as a 4-digit integer with no UI range gate; the
* API accepts any Int. We bound on the client to catch obvious typos. */
private const val YEAR_BUILT_MIN = 1800
fun validateName(value: String): String? {
if (value.isBlank()) return "Name is required"
if (value.length > NAME_MAX_LENGTH) {
return "Name must be $NAME_MAX_LENGTH characters or fewer"
}
return null
}
/**
* Optional integer >= 0. Matches iOS `bedrooms.value.asOptionalInt` —
* iOS allows 0 silently, so do we.
*/
fun validateBedrooms(value: String): String? {
if (value.isBlank()) return null
val n = value.toIntOrNull()
if (n == null || n < 0) {
return "Bedrooms must be a non-negative whole number"
}
return null
}
/** Optional double >= 0. Matches iOS `bathrooms.value.asOptionalDouble`. */
fun validateBathrooms(value: String): String? {
if (value.isBlank()) return null
val n = value.toDoubleOrNull()
if (n == null || n < 0.0) {
return "Bathrooms must be a non-negative number"
}
return null
}
/** Optional integer > 0. A 0-square-foot property is nonsensical. */
fun validateSquareFootage(value: String): String? {
if (value.isBlank()) return null
val n = value.toIntOrNull()
if (n == null || n <= 0) {
return "Square footage must be a positive whole number"
}
return null
}
/** Optional double > 0. Acres typically, but unit-agnostic. */
fun validateLotSize(value: String): String? {
if (value.isBlank()) return null
val n = value.toDoubleOrNull()
if (n == null || n <= 0.0) {
return "Lot size must be a positive number"
}
return null
}
/**
* Optional 4-digit year between [YEAR_BUILT_MIN] and the current year
* inclusive. iOS coerces via `asOptionalInt` with no range gate; we
* tighten on the client.
*/
fun validateYearBuilt(value: String, currentYear: Int = defaultCurrentYear()): String? {
if (value.isBlank()) return null
if (value.length != 4) return "Year built must be a 4-digit year"
val n = value.toIntOrNull() ?: return "Year built must be a 4-digit year"
if (n < YEAR_BUILT_MIN || n > currentYear) {
return "Year built must be between $YEAR_BUILT_MIN and the current year"
}
return null
}
@OptIn(ExperimentalTime::class)
private fun defaultCurrentYear(): Int {
val nowMillis = Clock.System.now().toEpochMilliseconds()
return Instant.fromEpochMilliseconds(nowMillis)
.toLocalDateTime(TimeZone.currentSystemDefault())
.year
}
}

View File

@@ -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)
}
}

View File

@@ -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"),
)
}
}