import Clocks import ComposableArchitecture2 import Dependencies import DependenciesTestSupport import Foundation import Testing @testable import ComposableForms @MainActor @Suite(.serialized) struct FormTests { @Test func valueChangeDebouncesValidationForFieldsWithoutErrors() async { let clock = TestClock() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() let validationProbe = AsyncValidationProbe() let clock = TestClock() 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 { _ async -> ValidationResult in .valid } #expect(validation.policy == .all) } @Test func observedValueChangeDebouncesAsyncValidation() async { let probe = AsyncValidationProbe() let clock = TestClock() 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() 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() 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() let validationProbe = AsyncValidationProbe() let clock = TestClock() 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() let validationProbe = CancellationAwareAsyncValidationProbe() let value = AsyncTestValue(username: "fresh") let store = TestStore( initialState: AsyncTestForm.State(value: value) ) { Form { Form.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() 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() let validationProbe = AsyncValidationProbe() let clock = TestClock() 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 private typealias NestedTestForm = Form private typealias AsyncTestForm = Form private typealias SubmitOnlyTestForm = Form private final class SubmitProbe: @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] = [] private var values: [String] = [] private var waiters: [ ( count: Int, continuation: CheckedContinuation ) ] = [] 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 ) ] = [] 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 ) ] = [] private var cancellations = 0 private var continuations: [CheckedContinuation] = [] private var validationWaiters: [ ( count: Int, continuation: CheckedContinuation ) ] = [] 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 ) ] = [] 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 ) ] = [] 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 { 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 { 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 { Form.Field(\.username) { Validation(error: .usernameRequired, policy: .formSubmitted) { !$0.isEmpty } } } } } private extension NestedTestForm { init() { self = Form { 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") }