Something went wrong. Try again.
A library for helping build high quality forms in SwiftUI and TCA
Something went wrong. Try again.
40 kB · 1290 lines
Swift
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291import Clocksimport ComposableArchitecture2import Dependenciesimport DependenciesTestSupportimport Foundationimport Testing@testable import ComposableForms
@MainActor@Suite(.serialized)struct FormTests { @Test func valueChangeDebouncesValidationForFieldsWithoutErrors() async { let clock = TestClock<Duration>() let store = withDependencies { $0.continuousClock = clock $0.formObservedChangeValidationDelay = .seconds(1) } operation: { TestStore(initialState: TestForm.State(value: TestValue())) { TestForm() } }
let task = store.modify { $0.value.name = "Blob" } #expect(store.state.validation[FieldID.name] == nil)
await clock.advance(by: Duration.seconds(1)) await task?.value let expectation = store.expect { $0.validation[FieldID.name] = .valid } await expectation?.value }
@Test func fieldSubmittedValidatesFieldAndAdvancesFocus() async { let store = TestStore(initialState: TestForm.State(value: TestValue())) { TestForm() }
let task = store.send(.fieldSubmitted(FieldID.name)) { $0.focusedField = FieldID.status $0.validation[FieldID.name] = .invalid(.nameRequired) } await task?.value }
@Test func fieldSubmittedValidatesFocusedFieldViaFocusChange() async { let store = TestStore( initialState: TestForm.State( value: TestValue(), focusedField: FieldID.name ) ) { TestForm() }
let task = store.send(.fieldSubmitted(FieldID.name)) { $0.focusedField = FieldID.status $0.validation[FieldID.name] = .invalid(.nameRequired) } await task?.value }
@Test func fieldSubmittedStoresValidState() async { let store = TestStore( initialState: TestForm.State( value: TestValue(name: "Blob") ) ) { TestForm() }
let task = store.send(.fieldSubmitted(FieldID.name)) { $0.focusedField = FieldID.status $0.validation[FieldID.name] = .valid } await task?.value }
@Test func valueChangeRevalidatesFieldsWithErrors() async { let store = TestStore( initialState: TestForm.State( value: TestValue(), validation: [FieldID.name: .invalid(.nameRequired)] ) ) { TestForm() }
let task = store.modify { $0.value.name = "Blob" } changes: { $0.validation[FieldID.name] = .valid } await task?.value }
@Test func valueChangeDebouncesValidationForValidFields() async { let clock = TestClock<Duration>() let store = withDependencies { $0.continuousClock = clock $0.formObservedChangeValidationDelay = .seconds(1) } operation: { TestStore( initialState: TestForm.State( value: TestValue(name: "Blob"), validation: [FieldID.name: .valid] ) ) { TestForm() } }
let task = store.modify { $0.value.name = "" } changes: { $0.validation.removeValue(forKey: FieldID.name) }
await clock.advance(by: Duration.seconds(1)) await task?.value let expectation = store.expect { $0.validation[FieldID.name] = .invalid(.nameRequired) } await expectation?.value }
@Test func valueChangeDoesNotRevalidateUnchangedFieldsWithErrors() async { let clock = TestClock<Duration>() let store = withDependencies { $0.continuousClock = clock $0.formObservedChangeValidationDelay = .seconds(1) } operation: { TestStore( initialState: TestForm.State( value: TestValue(), validation: [FieldID.name: .invalid(.nameRequired)] ) ) { TestForm() } }
let task = store.modify { $0.value.status = "draft" } #expect(store.state.validation[FieldID.name] == .invalid(.nameRequired))
await clock.advance(by: Duration.seconds(1)) await task?.value let expectation = store.expect { $0.validation[FieldID.status] = .valid } await expectation?.value }
@Test func unobservedValueChangeDoesNotRevalidateFieldsWithErrors() { let store = TestStore( initialState: TestForm.State( value: TestValue(), validation: [FieldID.name: .invalid(.nameRequired)] ) ) { TestForm() }
store.modify { $0.value.notes = "Still local" } }
@Test func focusedFieldChangeValidatesPreviouslyFocusedField() async { let store = TestStore( initialState: TestForm.State( value: TestValue(), focusedField: FieldID.name ) ) { TestForm() }
let task = store.modify { $0.focusedField = FieldID.status } changes: { $0.validation[FieldID.name] = .invalid(.nameRequired) } await task?.value }
@Test func focusedFieldChangeToNilValidatesPreviouslyFocusedField() async { let store = TestStore( initialState: TestForm.State( value: TestValue(), focusedField: FieldID.name ) ) { TestForm() }
let task = store.modify { $0.focusedField = nil } changes: { $0.validation[FieldID.name] = .invalid(.nameRequired) } await task?.value }
@Test func submittedFormValidatesAllFields() async { let store = TestStore(initialState: TestForm.State(value: TestValue())) { TestForm() }
let task = store.send(.submittedForm) { $0.validation[FieldID.name] = .invalid(.nameRequired) $0.validation[FieldID.status] = .invalid(.statusRequired) } await task?.value }
@Test func submittedFormMarksFormSubmittedWhenValid() async { let store = TestStore( initialState: TestForm.State( value: TestValue(name: "Blob", status: "draft") ) ) { TestForm() }
let task = store.send(.submittedForm) { $0.submissionStatus = .submitted $0.validation[FieldID.name] = .valid $0.validation[FieldID.status] = .valid } await task?.value }
@Test func submittedFormCallsOnSubmitWhenValid() async { let value = TestValue(name: "Blob", status: "draft") let submitProbe = SubmitProbe<TestValue>() let store = TestStore(initialState: TestForm.State(value: value)) { TestForm() .onSubmit { submitProbe.submit($0) } }
let task = store.send(.submittedForm) { $0.submissionStatus = .submitted $0.validation[FieldID.name] = .valid $0.validation[FieldID.status] = .valid } await task?.value
#expect(submitProbe.values() == [value]) }
@Test func submittedFormDoesNotCallOnSubmitWhenInvalid() async { let submitProbe = SubmitProbe<TestValue>() let store = TestStore(initialState: TestForm.State(value: TestValue())) { TestForm() .onSubmit { submitProbe.submit($0) } }
let task = store.send(.submittedForm) { $0.validation[FieldID.name] = .invalid(.nameRequired) $0.validation[FieldID.status] = .invalid(.statusRequired) } await task?.value
#expect(submitProbe.values().isEmpty) }
@Test func valueChangeClearsSubmittedStatus() async { let clock = TestClock<Duration>() let store = withDependencies { $0.continuousClock = clock $0.formObservedChangeValidationDelay = .seconds(1) } operation: { TestStore( initialState: TestForm.State( value: TestValue(name: "Blob", status: "draft"), submissionStatus: .submitted, validation: [ FieldID.name: .valid, FieldID.status: .valid, ] ) ) { TestForm() } }
let task = store.modify { $0.value.name = "Blob Jr" } changes: { $0.submissionStatus = .idle $0.validation.removeValue(forKey: FieldID.name) } task?.cancel() await task?.value }
@Test func fieldSubmittedSupersedesPendingObservedValidation() async { let clock = TestClock<Duration>() let store = withDependencies { $0.continuousClock = clock $0.formObservedChangeValidationDelay = .seconds(1) } operation: { TestStore(initialState: TestForm.State(value: TestValue())) { TestForm() } }
let debounceTask = store.modify { $0.value.name = "Blob" } #expect(store.state.validation[FieldID.name] == nil)
let fieldTask = store.send(.fieldSubmitted(FieldID.name)) { $0.focusedField = FieldID.status $0.validation[FieldID.name] = .valid } await fieldTask?.value
await clock.advance(by: Duration.seconds(1)) await debounceTask?.value #expect(store.state.focusedField == FieldID.status) #expect(store.state.validation[FieldID.name] == .valid) }
@Test func focusLossSupersedesPendingObservedValidation() async { let clock = TestClock<Duration>() let store = withDependencies { $0.continuousClock = clock $0.formObservedChangeValidationDelay = .seconds(1) } operation: { TestStore( initialState: TestForm.State( value: TestValue(), focusedField: FieldID.name ) ) { TestForm() } }
let debounceTask = store.modify { $0.value.name = "Blob" } #expect(store.state.validation[FieldID.name] == nil)
let focusTask = store.modify { $0.focusedField = FieldID.status } changes: { $0.validation[FieldID.name] = .valid } await focusTask?.value
await clock.advance(by: Duration.seconds(1)) await debounceTask?.value #expect(store.state.focusedField == FieldID.status) #expect(store.state.validation[FieldID.name] == .valid) }
@Test func multipleFieldsChangedInOneDebounceWindowValidateTogether() async { let clock = TestClock<Duration>() let store = withDependencies { $0.continuousClock = clock $0.formObservedChangeValidationDelay = .seconds(1) } operation: { TestStore(initialState: TestForm.State(value: TestValue())) { TestForm() } }
let nameTask = store.modify { $0.value.name = "Blob" } let statusTask = store.modify { $0.value.status = "draft" } #expect(store.state.validation.isEmpty)
await clock.advance(by: Duration.seconds(1)) await nameTask?.value await statusTask?.value let expectation = store.expect { $0.validation[FieldID.name] = .valid $0.validation[FieldID.status] = .valid } await expectation?.value }
@Test func submissionDerivedStateReflectsValidationState() { let state = TestForm.State( value: TestValue(), submissionStatus: .submitting, validation: [ FieldID.name: .invalid(.nameRequired), FieldID.status: .validating(), ] )
#expect(!state.canSubmit) #expect(state.hasErrors) #expect(state.isSubmitting) #expect(!state.isSubmitted) #expect(state.isValidating) }
@Test func stateIsEquatableWhenValueIsEquatable() { let lhs = TestForm.State( value: TestValue(name: "Blob"), focusedField: FieldID.name, submissionStatus: .submitting, validation: [FieldID.name: .valid] ) var rhs = lhs
#expect(lhs == rhs)
rhs.value.name = "Blob Jr." #expect(lhs != rhs)
rhs = lhs rhs.validation[FieldID.name] = .invalid(.nameRequired) #expect(lhs != rhs)
rhs = lhs rhs.focusedField = FieldID.status #expect(lhs != rhs)
rhs = lhs rhs.submissionStatus = .submitted #expect(lhs != rhs) }
@Test func scopedFieldsSubmitWithScopedIDs() async { let store = TestStore(initialState: NestedTestForm.State(value: NestedValue())) { NestedTestForm() }
let task = store.send(.submittedForm) { $0.validation[NestedFieldID.name] = .invalid(.nameRequired) $0.validation[NestedFieldID.profileDisplayName] = .invalid(.displayNameRequired) $0.validation[NestedFieldID.profileHandle] = .invalid(.handleRequired) } await task?.value }
@Test func fieldSubmittedAdvancesThroughScopedFields() async { let store = TestStore(initialState: NestedTestForm.State(value: NestedValue())) { NestedTestForm() }
let nameTask = store.send(.fieldSubmitted(NestedFieldID.name)) { $0.focusedField = NestedFieldID.profileDisplayName $0.validation[NestedFieldID.name] = .invalid(.nameRequired) } await nameTask?.value
let displayNameTask = store.send(.fieldSubmitted(NestedFieldID.profileDisplayName)) { $0.focusedField = NestedFieldID.profileHandle $0.validation[NestedFieldID.profileDisplayName] = .invalid(.displayNameRequired) } await displayNameTask?.value }
@Test func scopedValueChangeRevalidatesChangedChildFieldWithErrors() async { let store = TestStore( initialState: NestedTestForm.State( value: NestedValue(), validation: [ NestedFieldID.profileDisplayName: .invalid(.displayNameRequired), NestedFieldID.profileHandle: .invalid(.handleRequired), ] ) ) { NestedTestForm() }
let task = store.modify { $0.value.profile.displayName = "Blob" } changes: { $0.validation[NestedFieldID.profileDisplayName] = .valid } await task?.value }
@Test func submittedFormRunsAsyncValidation() async { let probe = AsyncValidationProbe() let store = TestStore( initialState: AsyncTestForm.State( value: AsyncTestValue(username: "blob") ) ) { AsyncTestForm(probe: probe) }
let task = store.send(.submittedForm) { $0.submissionStatus = .submitting $0.validation[AsyncFieldID.username] = .validating() } await probe.waitForValidation() await probe.finish(.invalid(.usernameUnavailable)) await task?.value let expectation = store.expect { $0.submissionStatus = .idle $0.validation[AsyncFieldID.username] = .invalid(.usernameUnavailable) } await expectation?.value }
@Test func submittedFormCallsOnSubmitAfterAsyncValidationSucceeds() async { let submitProbe = SubmitProbe<AsyncTestValue>() let validationProbe = AsyncValidationProbe() let value = AsyncTestValue(username: "blob") let store = TestStore(initialState: AsyncTestForm.State(value: value)) { AsyncTestForm(probe: validationProbe) .onSubmit { submitProbe.submit($0) } }
let task = store.send(.submittedForm) { $0.submissionStatus = .submitting $0.validation[AsyncFieldID.username] = .validating() } #expect(submitProbe.values().isEmpty)
await validationProbe.waitForValidation() await validationProbe.finish(.valid) await task?.value let expectation = store.expect { $0.submissionStatus = .submitted $0.validation[AsyncFieldID.username] = .valid } await expectation?.value
#expect(submitProbe.values() == [value]) }
@Test func submittedFormSupersedesPendingObservedValidation() async { let submitProbe = SubmitProbe<AsyncTestValue>() let validationProbe = AsyncValidationProbe() let clock = TestClock<Duration>() let value = AsyncTestValue(username: "fresh") let store = withDependencies { $0.continuousClock = clock $0.formObservedChangeValidationDelay = .seconds(1) } operation: { TestStore( initialState: AsyncTestForm.State( value: AsyncTestValue(username: "blob") ) ) { AsyncTestForm(probe: validationProbe) .onSubmit { submitProbe.submit($0) } } }
let debounceTask = store.modify { $0.value.username = value.username } #expect(store.state.validation[AsyncFieldID.username] == nil)
let submitTask = store.send(.submittedForm) { $0.submissionStatus = .submitting $0.validation[AsyncFieldID.username] = .validating() } await validationProbe.waitForValidation(count: 1) #expect(await validationProbe.validatedValues() == [value.username])
await validationProbe.finish(.valid) await submitTask?.value let expectation = store.expect { $0.submissionStatus = .submitted $0.validation[AsyncFieldID.username] = .valid } await expectation?.value #expect(submitProbe.values() == [value])
await clock.advance(by: Duration.seconds(1)) await debounceTask?.value #expect(await validationProbe.callCount() == 1) #expect(store.state.submissionStatus == .submitted) #expect(store.state.validation[AsyncFieldID.username] == .valid) #expect(submitProbe.values() == [value]) }
@Test func bareAsyncValidationDefaultsToAllTriggers() { let validation = Validation<String> { _ async -> ValidationResult in .valid }
#expect(validation.policy == .all) }
@Test func observedValueChangeDebouncesAsyncValidation() async { let probe = AsyncValidationProbe() let clock = TestClock<Duration>() let store = withDependencies { $0.continuousClock = clock $0.formObservedChangeValidationDelay = .seconds(1) } operation: { TestStore( initialState: AsyncTestForm.State( value: AsyncTestValue(username: "blob") ) ) { AsyncTestForm(probe: probe) } }
let task = store.modify { $0.value.username = "fresh" } #expect(store.state.validation[AsyncFieldID.username] == nil)
await clock.advance(by: Duration.seconds(1)) await probe.waitForValidation() let validating = store.expect { $0.validation[AsyncFieldID.username] = .validating() } await validating?.value
await probe.finish(.valid) await task?.value let expectation = store.expect { $0.validation[AsyncFieldID.username] = .valid } await expectation?.value }
@Test func explicitFormSubmittedAsyncDoesNotRunObservedValidation() async { let probe = AsyncValidationProbe() let clock = TestClock<Duration>() let store = withDependencies { $0.continuousClock = clock $0.formObservedChangeValidationDelay = .seconds(1) } operation: { TestStore( initialState: AsyncTestForm.State( value: AsyncTestValue(username: "blob") ) ) { AsyncTestForm(probe: probe, asyncPolicy: .formSubmitted) } }
let task = store.modify { $0.value.username = "fresh" } #expect(store.state.validation[AsyncFieldID.username] == nil)
await clock.advance(by: Duration.seconds(1)) await task?.value #expect(await probe.callCount() == 0) #expect(store.state.validation[AsyncFieldID.username] == nil) }
@Test func observedAsyncValidationIgnoresStaleResultAfterSecondEdit() async { let probe = AsyncValidationProbe() let clock = TestClock<Duration>() let store = withDependencies { $0.continuousClock = clock $0.formObservedChangeValidationDelay = .seconds(1) } operation: { TestStore( initialState: AsyncTestForm.State( value: AsyncTestValue(username: "blob") ) ) { AsyncTestForm(probe: probe) } }
let firstTask = store.modify { $0.value.username = "first" } await clock.advance(by: Duration.seconds(1)) await probe.waitForValidation(count: 1) let validating = store.expect { $0.validation[AsyncFieldID.username] = .validating() } await validating?.value #expect(await probe.validatedValues() == ["first"])
let secondTask = store.modify { $0.value.username = "second" } changes: { $0.validation.removeValue(forKey: AsyncFieldID.username) }
await probe.finish(.invalid(.usernameUnavailable)) await firstTask?.value #expect(store.state.validation[AsyncFieldID.username] == nil)
secondTask?.cancel() await secondTask?.value }
@Test func submittedFormWaitsForSubmitAsyncWhenObservedAsyncIsAlreadyInFlight() async { let submitProbe = SubmitProbe<AsyncTestValue>() let validationProbe = AsyncValidationProbe() let clock = TestClock<Duration>() let value = AsyncTestValue(username: "fresh") let store = withDependencies { $0.continuousClock = clock $0.formObservedChangeValidationDelay = .seconds(1) } operation: { TestStore( initialState: AsyncTestForm.State( value: AsyncTestValue(username: "blob") ) ) { AsyncTestForm(probe: validationProbe) .onSubmit { submitProbe.submit($0) } } }
let observedTask = store.modify { $0.value.username = value.username } await clock.advance(by: Duration.seconds(1)) await validationProbe.waitForValidation(count: 1) let validating = store.expect { $0.validation[AsyncFieldID.username] = .validating() } await validating?.value
let submitTask = store.send(.submittedForm) { $0.submissionStatus = .submitting } await validationProbe.waitForValidation(count: 2) #expect(await validationProbe.validatedValues() == [value.username, value.username])
await validationProbe.finish(.valid) await observedTask?.value #expect(store.state.submissionStatus == .submitting) #expect(submitProbe.values().isEmpty)
await validationProbe.finish(.valid) await submitTask?.value let expectation = store.expect { $0.submissionStatus = .submitted $0.validation[AsyncFieldID.username] = .valid } await expectation?.value #expect(submitProbe.values() == [value]) }
@Test func submittedFormCancelsInFlightSubmitValidationForSameField() async { let submitProbe = SubmitProbe<AsyncTestValue>() let validationProbe = CancellationAwareAsyncValidationProbe() let value = AsyncTestValue(username: "fresh") let store = TestStore( initialState: AsyncTestForm.State(value: value) ) { Form<AsyncTestValue> { Form<AsyncTestValue>.Field(\.username) { Validation(error: .usernameRequired) { !$0.isEmpty } Validation { value async -> ValidationResult in await validationProbe.validate(value) } } } .onSubmit { submitProbe.submit($0) } }
let firstSubmitTask = store.send(.submittedForm) { $0.submissionStatus = .submitting $0.validation[AsyncFieldID.username] = .validating() } await validationProbe.waitForValidation(count: 1)
let secondSubmitTask = store.send(.submittedForm) await validationProbe.waitForCancellation(count: 1) await firstSubmitTask?.value await validationProbe.waitForValidation(count: 2)
await validationProbe.finish(.valid) await secondSubmitTask?.value let expectation = store.expect { $0.submissionStatus = .submitted $0.validation[AsyncFieldID.username] = .valid } await expectation?.value
#expect(await validationProbe.validatedValues() == [value.username, value.username]) #expect(await validationProbe.cancellationCount() == 1) #expect(submitProbe.values() == [value]) }
@Test func asyncValidationIgnoresStaleResults() async { let probe = AsyncValidationProbe() let clock = TestClock<Duration>() let store = withDependencies { $0.continuousClock = clock $0.formObservedChangeValidationDelay = .seconds(1) } operation: { TestStore( initialState: AsyncTestForm.State( value: AsyncTestValue(username: "blob") ) ) { AsyncTestForm(probe: probe) } }
let task = store.send(.submittedForm) { $0.submissionStatus = .submitting $0.validation[AsyncFieldID.username] = .validating() } await probe.waitForValidation()
let staleTask = store.modify { $0.value.username = "fresh" } changes: { $0.submissionStatus = .idle $0.validation.removeValue(forKey: AsyncFieldID.username) } staleTask?.cancel() await staleTask?.value
await probe.finish(.invalid(.usernameUnavailable)) await task?.value #expect(store.state.validation[AsyncFieldID.username] == nil) }
@Test func staleAsyncValidationDoesNotCallOnSubmit() async { let submitProbe = SubmitProbe<AsyncTestValue>() let validationProbe = AsyncValidationProbe() let clock = TestClock<Duration>() let store = withDependencies { $0.continuousClock = clock $0.formObservedChangeValidationDelay = .seconds(1) } operation: { TestStore( initialState: AsyncTestForm.State( value: AsyncTestValue(username: "blob") ) ) { AsyncTestForm(probe: validationProbe) .onSubmit { submitProbe.submit($0) } } }
let task = store.send(.submittedForm) { $0.submissionStatus = .submitting $0.validation[AsyncFieldID.username] = .validating() } await validationProbe.waitForValidation()
let staleTask = store.modify { $0.value.username = "fresh" } changes: { $0.submissionStatus = .idle $0.validation.removeValue(forKey: AsyncFieldID.username) } staleTask?.cancel() await staleTask?.value
await validationProbe.finish(.valid) await task?.value #expect(submitProbe.values().isEmpty) }
@Test func observedValueChangeRunsAsyncValidationForFieldsWithErrors() async { let probe = AsyncValidationProbe() let store = TestStore( initialState: AsyncTestForm.State( value: AsyncTestValue(username: "blob"), validation: [AsyncFieldID.username: .invalid(.usernameUnavailable)] ) ) { AsyncTestForm(probe: probe) }
let task = store.modify { $0.value.username = "fresh" } changes: { $0.validation[AsyncFieldID.username] = .validating() }
await probe.waitForValidation() await probe.finish(.valid) await task?.value let expectation = store.expect { $0.validation[AsyncFieldID.username] = .valid } await expectation?.value }
@Test func submittedFormDoesNotRunAsyncValidationWhenSyncValidationFails() async { let probe = AsyncValidationProbe() let store = TestStore(initialState: AsyncTestForm.State(value: AsyncTestValue())) { AsyncTestForm(probe: probe) }
let task = store.send(.submittedForm) { $0.validation[AsyncFieldID.username] = .invalid(.usernameRequired) } await task?.value
#expect(await probe.callCount() == 0) }
@Test func asyncValidationPolicyCanRunOnlyOnFormSubmit() async { let probe = AsyncValidationProbe() let store = TestStore( initialState: AsyncTestForm.State( value: AsyncTestValue(username: "blob") ) ) { AsyncTestForm(probe: probe, asyncPolicy: .formSubmitted) }
let fieldTask = store.send(.fieldSubmitted(AsyncFieldID.username)) await fieldTask?.value #expect(await probe.callCount() == 0) #expect(store.state.validation[AsyncFieldID.username] == nil)
let task = store.send(.submittedForm) { $0.submissionStatus = .submitting $0.validation[AsyncFieldID.username] = .validating() } await probe.waitForValidation() await probe.finish(.valid) await task?.value let expectation = store.expect { $0.submissionStatus = .submitted $0.validation[AsyncFieldID.username] = .valid } await expectation?.value }
@Test func syncValidationPolicyCanRunOnlyOnFormSubmit() async { let store = TestStore(initialState: SubmitOnlyTestForm.State(value: AsyncTestValue())) { SubmitOnlyTestForm() }
let fieldTask = store.send(.fieldSubmitted(AsyncFieldID.username)) await fieldTask?.value #expect(store.state.validation[AsyncFieldID.username] == nil)
let submitTask = store.send(.submittedForm) { $0.validation[AsyncFieldID.username] = .invalid(.usernameRequired) } await submitTask?.value }}
private typealias TestForm = Form<TestValue>private typealias NestedTestForm = Form<NestedValue>private typealias AsyncTestForm = Form<AsyncTestValue>private typealias SubmitOnlyTestForm = Form<AsyncTestValue>
private final class SubmitProbe<Value>: @unchecked Sendable { private let lock = NSLock() private var submittedValues: [Value] = []
func submit(_ value: Value) { self.lock.lock() defer { self.lock.unlock() } self.submittedValues.append(value) }
func values() -> [Value] { self.lock.lock() defer { self.lock.unlock() } return self.submittedValues }}
private actor AsyncValidationProbe { private var continuations: [CheckedContinuation<ValidationResult, Never>] = [] private var values: [String] = [] private var waiters: [ ( count: Int, continuation: CheckedContinuation<Void, Never> ) ] = []
func callCount() -> Int { self.values.count }
func finish(_ result: ValidationResult) { self.continuations.removeFirst().resume(returning: result) }
func validatedValues() -> [String] { self.values }
func validate(_ value: String) async -> ValidationResult { self.values.append(value) self.resumeReadyWaiters() return await withCheckedContinuation { continuation in self.continuations.append(continuation) self.resumeReadyWaiters() } }
func waitForValidation(count: Int = 1) async { guard self.values.count < count else { return } await withCheckedContinuation { continuation in self.waiters.append((count, continuation)) } }
private func resumeReadyWaiters() { let valuesCount = self.values.count var waiting: [ ( count: Int, continuation: CheckedContinuation<Void, Never> ) ] = [] for waiter in self.waiters { if valuesCount >= waiter.count { waiter.continuation.resume() } else { waiting.append(waiter) } } self.waiters = waiting }}
private actor CancellationAwareAsyncValidationProbe { private var cancellationWaiters: [ ( count: Int, continuation: CheckedContinuation<Void, Never> ) ] = [] private var cancellations = 0 private var continuations: [CheckedContinuation<ValidationResult, Never>] = [] private var validationWaiters: [ ( count: Int, continuation: CheckedContinuation<Void, Never> ) ] = [] private var values: [String] = []
func cancellationCount() -> Int { self.cancellations }
func finish(_ result: ValidationResult) { self.continuations.removeFirst().resume(returning: result) }
func validate(_ value: String) async -> ValidationResult { self.values.append(value) self.resumeReadyValidationWaiters() return await withTaskCancellationHandler { await withCheckedContinuation { continuation in self.continuations.append(continuation) self.resumeReadyValidationWaiters() } } onCancel: { Task { await self.cancel() } } }
func validatedValues() -> [String] { self.values }
func waitForCancellation(count: Int = 1) async { guard self.cancellations < count else { return } await withCheckedContinuation { continuation in self.cancellationWaiters.append((count, continuation)) } }
func waitForValidation(count: Int = 1) async { guard self.values.count < count else { return } await withCheckedContinuation { continuation in self.validationWaiters.append((count, continuation)) } }
private func cancel() { self.cancellations += 1 if !self.continuations.isEmpty { self.continuations.removeFirst().resume(returning: .valid) } self.resumeReadyCancellationWaiters() }
private func resumeReadyCancellationWaiters() { let cancellationCount = self.cancellations var waiting: [ ( count: Int, continuation: CheckedContinuation<Void, Never> ) ] = [] for waiter in self.cancellationWaiters { if cancellationCount >= waiter.count { waiter.continuation.resume() } else { waiting.append(waiter) } } self.cancellationWaiters = waiting }
private func resumeReadyValidationWaiters() { let valuesCount = self.values.count var waiting: [ ( count: Int, continuation: CheckedContinuation<Void, Never> ) ] = [] for waiter in self.validationWaiters { if valuesCount >= waiter.count { waiter.continuation.resume() } else { waiting.append(waiter) } } self.validationWaiters = waiting }}
private enum FieldID { static var name: AnyHashable { AnyHashable(\TestValue.name) } static var status: AnyHashable { AnyHashable(\TestValue.status) }}
private enum AsyncFieldID { static var username: AnyHashable { AnyHashable(\AsyncTestValue.username) }}
private enum NestedFieldID { static var name: AnyHashable { AnyHashable(\NestedValue.name) } static var profile: AnyHashable { AnyHashable(\NestedValue.profile) } static var profileDisplayName: AnyHashable { AnyHashable( ScopedFormFieldID( scope: Self.profile, field: AnyHashable(\ProfileValue.displayName) ) ) } static var profileHandle: AnyHashable { AnyHashable( ScopedFormFieldID( scope: Self.profile, field: AnyHashable(\ProfileValue.handle) ) ) }}
private struct NestedValue: Equatable { var name = "" var profile = ProfileValue()}
private struct ProfileValue: Equatable { var displayName = "" var handle = "" var notes = ""}
private struct TestValue: Equatable { var name = "" var notes = "" var status = ""}
private struct AsyncTestValue: Equatable { var username = ""}
private extension TestForm { init() { self = Form<TestValue> { Form.Field(\.name) { Validation(error: .nameRequired) { !$0.isEmpty } } Form.Field(\.status) { Validation(error: "Status is required") { !$0.isEmpty } } } }}
private extension AsyncTestForm { init( probe: AsyncValidationProbe, asyncPolicy: ValidationPolicy? = nil ) { self = Form<AsyncTestValue> { Form.Field(\.username) { Validation(error: .usernameRequired) { !$0.isEmpty } Validation(policy: asyncPolicy) { value async -> ValidationResult in await probe.validate(value) } } } }}
private extension SubmitOnlyTestForm { init() { self = Form<AsyncTestValue> { Form.Field(\.username) { Validation(error: .usernameRequired, policy: .formSubmitted) { !$0.isEmpty } } } }}
private extension NestedTestForm { init() { self = Form<NestedValue> { Form.Field(\.name) { Validation(error: .nameRequired) { !$0.isEmpty } } Form.Scope(\.profile) { Form.Field(\.displayName) { Validation(error: .displayNameRequired) { !$0.isEmpty } } Form.Field(\.handle) { Validation(error: .handleRequired) { !$0.isEmpty } } } } }}
private extension ErrorMessage { static let displayNameRequired = Self(message: "Display name is required") static let handleRequired = Self(message: "Handle is required") static let nameRequired = Self(message: "Name is required") static let statusRequired = Self(message: "Status is required") static let usernameRequired = Self(message: "Username is required") static let usernameUnavailable = Self(message: "Username is unavailable")}