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