From cf2aca583b4e8b4a9e332ca3e81ae6e707302851 Mon Sep 17 00:00:00 2001 From: Trey T Date: Sat, 18 Apr 2026 13:34:49 -0500 Subject: [PATCH] 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) --- .../screens/residence/ResidenceFormState.kt | 160 ++++++++++++ .../residence/ResidenceFormValidation.kt | 109 ++++++++ .../residence/ResidenceFormStateTest.kt | 214 ++++++++++++++++ .../residence/ResidenceFormValidationTest.kt | 242 ++++++++++++++++++ 4 files changed, 725 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/residence/ResidenceFormState.kt create mode 100644 composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/residence/ResidenceFormValidation.kt create mode 100644 composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/residence/ResidenceFormStateTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/residence/ResidenceFormValidationTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/residence/ResidenceFormState.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/residence/ResidenceFormState.kt new file mode 100644 index 0000000..25b4e6e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/residence/ResidenceFormState.kt @@ -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 } +} diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/residence/ResidenceFormValidation.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/residence/ResidenceFormValidation.kt new file mode 100644 index 0000000..5e43888 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/residence/ResidenceFormValidation.kt @@ -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 + } +} diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/residence/ResidenceFormStateTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/residence/ResidenceFormStateTest.kt new file mode 100644 index 0000000..a0dd664 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/residence/ResidenceFormStateTest.kt @@ -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) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/residence/ResidenceFormValidationTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/residence/ResidenceFormValidationTest.kt new file mode 100644 index 0000000..775ff6e --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/residence/ResidenceFormValidationTest.kt @@ -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"), + ) + } +}