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