diff --git a/src/BootstrapBlazor/Attributes/AsyncValidationAttribute.cs b/src/BootstrapBlazor/Attributes/AsyncValidationAttribute.cs index bf63c1d0066..6d69d50424a 100644 --- a/src/BootstrapBlazor/Attributes/AsyncValidationAttribute.cs +++ b/src/BootstrapBlazor/Attributes/AsyncValidationAttribute.cs @@ -69,8 +69,6 @@ protected AsyncValidationAttribute(Func errorMessageAccessor) : base(err /// public async Task GetValidationResultAsync(object? value, ValidationContext validationContext, CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(validationContext); - var result = await IsValidAsync(value, validationContext, cancellationToken).ConfigureAwait(false); if (result != null && string.IsNullOrEmpty(result.ErrorMessage)) { diff --git a/src/BootstrapBlazor/BootstrapBlazor.csproj b/src/BootstrapBlazor/BootstrapBlazor.csproj index 9b40beb7df8..f848a821b24 100644 --- a/src/BootstrapBlazor/BootstrapBlazor.csproj +++ b/src/BootstrapBlazor/BootstrapBlazor.csproj @@ -1,7 +1,7 @@  - 10.10.1-beta03 + 10.10.1 diff --git a/src/BootstrapBlazor/Components/ValidateForm/BootstrapBlazorDataAnnotationsValidator.cs b/src/BootstrapBlazor/Components/ValidateForm/BootstrapBlazorDataAnnotationsValidator.cs index 6f41631fe32..289ec3f6190 100644 --- a/src/BootstrapBlazor/Components/ValidateForm/BootstrapBlazorDataAnnotationsValidator.cs +++ b/src/BootstrapBlazor/Components/ValidateForm/BootstrapBlazorDataAnnotationsValidator.cs @@ -68,12 +68,11 @@ private void RemoveEditContextDataAnnotationsValidation() CurrentEditContext.OnFieldChanged -= OnFieldChanged; } -#if NET11_0_OR_GREATER internal Task ValidateAsync(CancellationToken cancellationToken = default) => CurrentEditContext.ValidateAsync(cancellationToken); private void OnValidationRequested(object? sender, ValidationRequestedEventArgs args) { - args.AddAsyncValidator(cancellationToken => ValidateModelAsync(CurrentEditContext, _message, Provider, cancellationToken)); + args.AddAsyncValidator(ValidateModelAsync); } private void OnFieldChanged(object? sender, FieldChangedEventArgs args) @@ -81,162 +80,25 @@ private void OnFieldChanged(object? sender, FieldChangedEventArgs args) var fieldIdentifier = args.FieldIdentifier; CurrentEditContext.RegisterAsyncFieldValidator(fieldIdentifier, cancellationToken => ValidateFieldAsync(CurrentEditContext, _message, fieldIdentifier, Provider, cancellationToken)); } -#else -#if NET9_0_OR_GREATER - private readonly Lock _fieldValidationLock = new(); -#else - private readonly object _fieldValidationLock = new(); -#endif - - private readonly SemaphoreSlim _validationLock = new(1, 1); - private readonly Dictionary _fieldValidationOperations = []; - private bool _suppressValidationRequested; - internal async Task ValidateAsync(CancellationToken cancellationToken = default) + private async Task ValidateModelAsync(CancellationToken cancellationToken) { - await _validationLock.WaitAsync(cancellationToken); try { - CancelFieldValidations(); - _message.Clear(); - - bool synchronousValid; - _suppressValidationRequested = true; - try - { - // NET10 只有同步方法 - synchronousValid = CurrentEditContext.Validate(); - } - finally - { - _suppressValidationRequested = false; - } - - // 通过 _suppressValidationRequested 控制 OnValidationRequested 事件中的 ValidateModelAsync 不被调用,避免重复验证 - var valid = await ValidateModelAsync(CurrentEditContext, _message, Provider, cancellationToken); - return synchronousValid && valid && !CurrentEditContext.GetValidationMessages().Any(); + await ValidateModelAsync(CurrentEditContext, _message, Provider, cancellationToken); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception exception) { Logger.LogError(exception, "An exception occurred while validating the form."); - return false; - } - finally - { - _validationLock.Release(); - } - } - - private async void OnValidationRequested(object? sender, ValidationRequestedEventArgs args) - { - if (!_suppressValidationRequested) - { - try - { - await ValidateModelAsync(CurrentEditContext, _message, Provider, CancellationToken.None); - } - catch (Exception exception) - { - Logger.LogError(exception, "An exception occurred while validating the form."); - } - } - } - - private void OnFieldChanged(object? sender, FieldChangedEventArgs args) - { - var fieldIdentifier = args.FieldIdentifier; - - FieldValidationOperation? previousOperation; - FieldValidationOperation operation; - lock (_fieldValidationLock) - { - _fieldValidationOperations.Remove(fieldIdentifier, out previousOperation); - operation = new FieldValidationOperation(); - _fieldValidationOperations.Add(fieldIdentifier, operation); - } - previousOperation?.Cancel(); - _ = ValidateFieldAndCleanupAsync(fieldIdentifier, operation); - } - - private async Task ValidateFieldAndCleanupAsync(FieldIdentifier fieldIdentifier, FieldValidationOperation operation) - { - try - { - await ValidateFieldAsync(CurrentEditContext, _message, fieldIdentifier, Provider, operation.Token); - } - catch (OperationCanceledException) when (operation.IsCancellationRequested) - { - } - finally - { - lock (_fieldValidationLock) - { - if (_fieldValidationOperations.TryGetValue(fieldIdentifier, out var currentOperation) - && ReferenceEquals(currentOperation, operation)) - { - _fieldValidationOperations.Remove(fieldIdentifier); - } - } - operation.Complete(); - } - } - - private void CancelFieldValidations() - { - FieldValidationOperation[] operations; - lock (_fieldValidationLock) - { - operations = [.. _fieldValidationOperations.Values]; - _fieldValidationOperations.Clear(); - } - foreach (var operation in operations) - { - operation.Cancel(); - } - } - - private sealed class FieldValidationOperation - { - private CancellationTokenSource? _tokenSource; - - public CancellationToken Token { get; } - - public bool IsCancellationRequested => Token.IsCancellationRequested; - - public FieldValidationOperation() - { - _tokenSource = new(); - Token = _tokenSource.Token; - } - - public void Cancel() - { - var tokenSource = Interlocked.Exchange(ref _tokenSource, null); - if (tokenSource != null) - { - try - { - tokenSource.Cancel(); - } - finally - { - tokenSource.Dispose(); - } - } - } - - public void Complete() - { - Interlocked.Exchange(ref _tokenSource, null)?.Dispose(); + throw; } } -#endif - private async Task ValidateModelAsync(EditContext editContext, ValidationMessageStore messages, IServiceProvider provider, CancellationToken cancellationToken) + private async Task ValidateModelAsync(EditContext editContext, ValidationMessageStore messages, IServiceProvider provider, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); var validationContext = new ValidationContext(editContext.Model, provider, null); @@ -265,7 +127,6 @@ private async Task ValidateModelAsync(EditContext editContext, ValidationM } } editContext.NotifyValidationStateChanged(); - return validationResults.Count == 0; } private async Task ValidateFieldAsync(EditContext editContext, ValidationMessageStore messages, FieldIdentifier field, IServiceProvider provider, CancellationToken cancellationToken) @@ -290,9 +151,13 @@ private async Task ValidateFieldAsync(EditContext editContext, ValidationMessage { return; } + catch (Exception exception) + { + Logger.LogError(exception, "An exception occurred while validating the field."); + throw; + } messages.Add(field, validationResults.Where(v => !string.IsNullOrEmpty(v.ErrorMessage)).Select(result => result.ErrorMessage!)); - editContext.NotifyValidationStateChanged(); } @@ -301,7 +166,7 @@ private void Dispose(bool disposing) if (disposing) { #if !NET11_0_OR_GREATER - CancelFieldValidations(); + CurrentEditContext.CancelAsyncFieldValidations(); #endif RemoveEditContextDataAnnotationsValidation(); } diff --git a/src/BootstrapBlazor/Extensions/EditContextExtensions.cs b/src/BootstrapBlazor/Extensions/EditContextExtensions.cs new file mode 100644 index 00000000000..a00920a3d04 --- /dev/null +++ b/src/BootstrapBlazor/Extensions/EditContextExtensions.cs @@ -0,0 +1,402 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +#if !NET11_0_OR_GREATER +using Microsoft.AspNetCore.Components.Forms; +using System.Linq.Expressions; +using System.Runtime.CompilerServices; + +namespace BootstrapBlazor.Components; + +/// +/// EditContext 异步验证兼容扩展 +/// Async validation compatibility extensions for EditContext +/// +public static class EditContextExtensions +{ + private static readonly AsyncLocal CurrentScope = new(); + private static readonly ConditionalWeakTable States = new(); + + /// + /// 请求验证并等待本次注册的所有异步验证完成 + /// Requests validation and awaits all async validators registered for this pass + /// + /// 编辑上下文The edit context + /// 取消令牌The cancellation token + /// 无验证消息且异步验证未发生异常时返回 trueTrue if there are no validation messages or async validation faults + /// + /// 同一上下文的验证按顺序执行,并在请求验证前取消字段验证、清除字段异常状态。不支持在验证处理程序中重入验证同一上下文。异步任务异常通过 IsValidationFaulted 和验证状态通知报告;事件处理程序同步异常直接传播。 + /// Passes for the same context are serialized. Pending field validations are cancelled and field faults are cleared before requesting validation. Reentrant validation of the same context is unsupported. Async task faults are reported through IsValidationFaulted and validation state notifications; synchronous handler exceptions propagate. + /// + public static async Task ValidateAsync(this EditContext editContext, CancellationToken cancellationToken = default) + { + var previousScope = CurrentScope.Value; + for (var parent = previousScope; parent != null; parent = parent.Parent) + { + if (parent.IsActive && ReferenceEquals(parent.EditContext, editContext)) + { + throw new InvalidOperationException("Reentrant validation of the same EditContext is not supported."); + } + } + + var state = GetState(editContext); + await state.Semaphore.WaitAsync(cancellationToken); + var scope = new ValidationScope(editContext, previousScope); + CurrentScope.Value = scope; + try + { + try + { + CancelAsyncFieldValidations(editContext); + // Older frameworks share ValidationRequestedEventArgs.Empty across all edit contexts. + // Collect in this pass's scope instead of attaching state to the event arguments. + editContext.Validate(); + } + finally + { + scope.IsCollecting = false; + } + + var tasks = new List(); + foreach (var validator in scope.Validators) + { + var task = validator(cancellationToken) + ?? throw new InvalidOperationException("The async validator returned a null task."); + if (!task.IsCompletedSuccessfully) + { + tasks.Add(task); + } + } + + state.IsPending = tasks.Exists(task => !task.IsCompleted); + if (state.IsPending) + { + editContext.NotifyValidationStateChanged(); + } + + var faulted = false; + foreach (var task in tasks) + { + try + { + await task; + } + catch (Exception) when (task.IsFaulted || task.IsCanceled) + { + // Match NET11: task faults invalidate the pass, but all started tasks must settle. + faulted = true; + } + } + + cancellationToken.ThrowIfCancellationRequested(); + state.IsFaulted = faulted; + return !faulted && !editContext.GetValidationMessages().Any(); + } + finally + { + scope.IsActive = false; + CurrentScope.Value = previousScope; + state.IsPending = false; + state.Semaphore.Release(); + editContext.NotifyValidationStateChanged(); + } + } + + /// + /// 获得当前窗体验证是否正在等待异步任务 + /// Gets whether the current form validation is awaiting async tasks + /// + /// 编辑上下文The edit context + public static bool IsValidationPending(this EditContext editContext) + { + return States.TryGetValue(editContext, out var state) && state.IsPending; + } + + /// + /// 获得最近一次窗体异步验证是否发生异常 + /// Gets whether the most recent async form validation faulted + /// + /// 编辑上下文The edit context + public static bool IsValidationFaulted(this EditContext editContext) + { + return States.TryGetValue(editContext, out var state) && state.IsFaulted; + } + + /// + /// 立即执行字段异步验证并取消该字段上一次验证 + /// Immediately starts async field validation and cancels the previous validation for that field + /// + /// 编辑上下文The edit context + /// 字段标识The field identifier + /// 异步验证委托The async validation delegate + /// + /// 取消令牌由编辑上下文管理,验证任务结束后释放。任务异常通过字段 IsValidationFaulted 和验证状态通知报告;委托同步异常直接传播。 + /// The edit context owns the cancellation token source and disposes it after the task settles. Task faults are reported through the field's IsValidationFaulted state and validation state notifications; synchronous delegate exceptions propagate. + /// + public static void RegisterAsyncFieldValidator(this EditContext editContext, in FieldIdentifier fieldIdentifier, Func validator) + { + var state = GetState(editContext); + var operation = new FieldValidationOperation(); + FieldValidationState fieldState; + FieldValidationOperation? previousOperation; + bool previouslyChanged; + lock (state.FieldLock) + { + if (!state.Fields.TryGetValue(fieldIdentifier, out fieldState!)) + { + fieldState = new(); + state.Fields.Add(fieldIdentifier, fieldState); + } + previousOperation = fieldState.Operation; + previouslyChanged = previousOperation != null || fieldState.IsFaulted; + fieldState.Operation = operation; + fieldState.IsFaulted = false; + } + + Task task; + try + { + previousOperation?.Cancel(); + task = validator(operation.Token) + ?? throw new InvalidOperationException("The async validator returned a null task."); + } + catch + { + operation.Complete(); + if (SettleFieldValidation(state, fieldState, operation, false) && previouslyChanged) + { + editContext.NotifyValidationStateChanged(); + } + throw; + } + + _ = ObserveFieldValidationAsync(editContext, state, fieldState, operation, task, previouslyChanged); + } + + /// + /// 获得指定字段是否有尚未完成的异步验证 + /// Gets whether the specified field has an unsettled async validation + /// + /// 编辑上下文The edit context + /// 字段标识The field identifier + public static bool IsValidationPending(this EditContext editContext, in FieldIdentifier fieldIdentifier) + { + if (States.TryGetValue(editContext, out var state)) + { + lock (state.FieldLock) + { + return state.Fields.TryGetValue(fieldIdentifier, out var field) && field.Operation != null; + } + } + return false; + } + + /// + /// 获得表达式指定字段是否有尚未完成的异步验证 + /// Gets whether the field identified by the expression has an unsettled async validation + /// + /// 字段类型The field type + /// 编辑上下文The edit context + /// 字段表达式The field expression + public static bool IsValidationPending(this EditContext editContext, Expression> accessor) + => editContext.IsValidationPending(FieldIdentifier.Create(accessor)); + + /// + /// 获得指定字段最近一次异步验证是否发生异常 + /// Gets whether the specified field's most recent async validation faulted + /// + /// 编辑上下文The edit context + /// 字段标识The field identifier + public static bool IsValidationFaulted(this EditContext editContext, in FieldIdentifier fieldIdentifier) + { + if (States.TryGetValue(editContext, out var state)) + { + lock (state.FieldLock) + { + return state.Fields.TryGetValue(fieldIdentifier, out var field) && field.IsFaulted; + } + } + return false; + } + + /// + /// 获得表达式指定字段最近一次异步验证是否发生异常 + /// Gets whether the field identified by the expression most recently faulted + /// + /// 字段类型The field type + /// 编辑上下文The edit context + /// 字段表达式The field expression + public static bool IsValidationFaulted(this EditContext editContext, Expression> accessor) + => editContext.IsValidationFaulted(FieldIdentifier.Create(accessor)); + + internal static void CancelAsyncFieldValidations(this EditContext editContext) + { + if (States.TryGetValue(editContext, out var state)) + { + var operations = new List(); + var changed = false; + lock (state.FieldLock) + { + foreach (var field in state.Fields.Values) + { + if (field.Operation != null) + { + operations.Add(field.Operation); + field.Operation = null; + changed = true; + } + changed |= field.IsFaulted; + field.IsFaulted = false; + } + } + foreach (var operation in operations) + { + operation.Cancel(); + } + if (changed) + { + editContext.NotifyValidationStateChanged(); + } + } + } + + private static async Task ObserveFieldValidationAsync(EditContext editContext, ValidationState state, FieldValidationState field, FieldValidationOperation operation, Task task, bool previouslyChanged) + { + var completedSynchronously = task.IsCompleted; + try + { + if (!completedSynchronously) + { + editContext.NotifyValidationStateChanged(); + } + + var faulted = false; + try + { + await task; + } + catch (OperationCanceledException) when (operation.Token.IsCancellationRequested) + { + } + catch (Exception) when (task.IsFaulted || task.IsCanceled) + { + faulted = true; + } + + if (SettleFieldValidation(state, field, operation, faulted) + && (!completedSynchronously || previouslyChanged || faulted)) + { + editContext.NotifyValidationStateChanged(); + } + } + finally + { + operation.Complete(); + } + } + + private static bool SettleFieldValidation(ValidationState state, FieldValidationState field, FieldValidationOperation operation, bool faulted) + { + lock (state.FieldLock) + { + // An older task must not clear the pending state or fault of its replacement. + if (!ReferenceEquals(field.Operation, operation)) + { + return false; + } + field.Operation = null; + field.IsFaulted = faulted; + return true; + } + } + + internal static void AddAsyncValidator(Func validator) + { + var scope = CurrentScope.Value; + if (scope == null || !scope.IsCollecting) + { + throw new InvalidOperationException("Asynchronous validation requires an EditContext.ValidateAsync call. Register validators synchronously in OnValidationRequested."); + } + scope.Validators.Add(validator); + } + + private static ValidationState GetState(EditContext editContext) => States.GetValue(editContext, static _ => new()); + + private sealed class ValidationState + { + public SemaphoreSlim Semaphore { get; } = new(1, 1); + +#if NET9_0_OR_GREATER + public Lock FieldLock { get; } = new(); +#else + public object FieldLock { get; } = new(); +#endif + + public Dictionary Fields { get; } = []; + + public bool IsPending { get; set; } + + public bool IsFaulted { get; set; } + } + + private sealed class FieldValidationState + { + public FieldValidationOperation? Operation { get; set; } + + public bool IsFaulted { get; set; } + } + + private sealed class FieldValidationOperation + { + private CancellationTokenSource? _tokenSource = new(); + +#if NET9_0_OR_GREATER + private readonly Lock _lock = new(); +#else + private readonly object _lock = new(); +#endif + + public CancellationToken Token { get; } + + public FieldValidationOperation() => Token = _tokenSource.Token; + + public void Cancel() + { + lock (_lock) + { + if (_tokenSource != null) + { + _tokenSource.Cancel(); + } + } + } + + public void Complete() + { + lock (_lock) + { + if (_tokenSource != null) + { + _tokenSource.Dispose(); + _tokenSource = null; + } + } + } + } + + private sealed class ValidationScope(EditContext editContext, ValidationScope? parent) + { + public EditContext EditContext { get; } = editContext; + + public ValidationScope? Parent { get; } = parent; + + public List> Validators { get; } = []; + + public bool IsCollecting { get; set; } = true; + + public bool IsActive { get; set; } = true; + } +} +#endif diff --git a/src/BootstrapBlazor/Extensions/ValidationRequestedEventArgsExtensions.cs b/src/BootstrapBlazor/Extensions/ValidationRequestedEventArgsExtensions.cs new file mode 100644 index 00000000000..f716668f566 --- /dev/null +++ b/src/BootstrapBlazor/Extensions/ValidationRequestedEventArgsExtensions.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +#if !NET11_0_OR_GREATER +using Microsoft.AspNetCore.Components.Forms; + +namespace BootstrapBlazor.Components; + +/// +/// ValidationRequestedEventArgs 异步验证兼容扩展 +/// Async validation compatibility extensions for ValidationRequestedEventArgs +/// +public static class ValidationRequestedEventArgsExtensions +{ + /// + /// 注册由当前 EditContext.ValidateAsync 调用执行并等待的异步验证 + /// Registers an async validator to be invoked and awaited by the current EditContext.ValidateAsync call + /// + /// 验证请求事件参数The validation request event arguments + /// 异步验证委托The async validation delegate + /// + /// 必须在 OnValidationRequested 处理程序中同步注册。旧框架共享事件参数,不能在处理程序中调用其他上下文的同步 Validate 方法。 + /// Register synchronously inside OnValidationRequested. Older frameworks share event arguments, so do not call another context's synchronous Validate method inside the handler. + /// + /// 当前不处于异步验证注册阶段There is no current async validation registration scope + public static void AddAsyncValidator(this ValidationRequestedEventArgs args, Func validator) + { + EditContextExtensions.AddAsyncValidator(validator); + } +} +#endif diff --git a/test/UnitTest/Attributes/AsyncValidationAttributeTest.cs b/test/UnitTest/Attributes/AsyncValidationAttributeTest.cs index cb830505e4c..77967a06083 100644 --- a/test/UnitTest/Attributes/AsyncValidationAttributeTest.cs +++ b/test/UnitTest/Attributes/AsyncValidationAttributeTest.cs @@ -30,14 +30,6 @@ public async Task GetValidationResultAsync_Ok() Assert.Equal([nameof(MockModel.Name)], result.MemberNames); } - [Fact] - public async Task GetValidationResultAsync_NullContext() - { - var attribute = new MockAsyncValidationAttribute(); - - await Assert.ThrowsAsync(() => attribute.GetValidationResultAsync(null, null!, CancellationToken.None)); - } - [Fact] public async Task GetValidationResultAsync_Success() { diff --git a/test/UnitTest/Components/ValidateFormTest.cs b/test/UnitTest/Components/ValidateFormTest.cs index 3cc72780c6c..8113448486d 100644 --- a/test/UnitTest/Components/ValidateFormTest.cs +++ b/test/UnitTest/Components/ValidateFormTest.cs @@ -72,6 +72,45 @@ public async Task ValidateAsync_Exception() Assert.False(valid); } + [Fact] + public async Task ValidateFieldAsync_Exception() + { + var logger = Assert.IsType( + Context.Services.GetRequiredService>()); + var foo = new Foo() { Name = "Initial" }; + var cut = Context.Render(pb => + { + pb.Add(a => a.Model, foo); + pb.AddChildContent>(pb => + { + pb.Add(a => a.Value, foo.Name); + pb.Add(a => a.ValueChanged, value => foo.Name = value); + pb.Add(a => a.ValueExpression, foo.GenerateValueExpression()); + pb.Add(a => a.ValidateRules, [new ThrowingValidator()]); + }); + }); + var validator = cut.FindComponent().Instance; + var property = typeof(BootstrapBlazorDataAnnotationsValidator).GetProperty( + "CurrentEditContext", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + var editContext = Assert.IsType(property?.GetValue(validator)); + var field = editContext.Field(nameof(foo.Name)); + + await cut.InvokeAsync(() => cut.Find("input").Change("Changed")); + + cut.WaitForAssertion(() => + { + var exception = Assert.IsType(logger.Exception); + Assert.Equal("Validation failed", exception.Message); + Assert.Equal(LogLevel.Error, logger.Level); + Assert.Equal("An exception occurred while validating the field.", logger.Message); + Assert.True(editContext.IsValidationFaulted(field)); + Assert.False(editContext.IsValidationPending(field)); + Assert.False(editContext.IsValidationFaulted()); + Assert.Empty(editContext.GetValidationMessages(field)); + }); + } + [Fact] public async Task FieldValidation_CancelsPreviousOperation() { @@ -139,12 +178,10 @@ public async Task OnValidationRequested_Ok() System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); var editContext = Assert.IsType(property?.GetValue(validator)); -#if NET11_0_OR_GREATER - await cut.InvokeAsync(() => editContext.ValidateAsync()); -#else - await cut.InvokeAsync(() => editContext.Validate()); - cut.WaitForAssertion(() => Assert.NotEmpty(editContext.GetValidationMessages())); -#endif + var valid = await cut.InvokeAsync(() => editContext.ValidateAsync()); + + Assert.False(valid); + Assert.NotEmpty(editContext.GetValidationMessages()); } [Fact] @@ -169,19 +206,17 @@ public async Task OnValidationRequested_Exception() System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); var editContext = Assert.IsType(property?.GetValue(validator)); -#if NET11_0_OR_GREATER - await cut.InvokeAsync(() => editContext.ValidateAsync()); -#else - await cut.InvokeAsync(() => editContext.Validate()); - cut.WaitForAssertion(() => Assert.IsType(logger.Exception)); -#endif + var valid = await cut.InvokeAsync(() => editContext.ValidateAsync()); + + Assert.False(valid); + Assert.True(editContext.IsValidationFaulted()); + Assert.IsType(logger.Exception); } -#if !NET11_0_OR_GREATER [Fact] - public async Task ValidateFieldAndCleanupAsync_OperationCancellation() + public async Task ValidateAsync_ClearsPreviousErrors() { - var foo = new Foo() { Name = "Test" }; + var foo = new Foo(); var cut = Context.Render(pb => { pb.Add(a => a.Model, foo); @@ -191,30 +226,38 @@ public async Task ValidateFieldAndCleanupAsync_OperationCancellation() pb.Add(a => a.ValueExpression, foo.GenerateValueExpression()); }); }); - var validator = cut.FindComponent().Instance; - var validatorType = typeof(BootstrapBlazorDataAnnotationsValidator); - var operationType = validatorType.GetNestedType( - "FieldValidationOperation", - System.Reflection.BindingFlags.NonPublic); - Assert.NotNull(operationType); - var operation = Activator.CreateInstance(operationType, nonPublic: true); - Assert.NotNull(operation); - var method = validatorType.GetMethod( - "ValidateFieldAndCleanupAsync", - System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); - Assert.NotNull(method); - var cancelMethod = operationType.GetMethod(nameof(CancellationTokenSource.Cancel)); - Assert.NotNull(cancelMethod); - cancelMethod.Invoke(operation, null); - await cut.InvokeAsync(async () => + Assert.False(await cut.InvokeAsync(() => cut.Instance.ValidateAsync())); + + foo.Name = "Test"; + Assert.True(await cut.InvokeAsync(() => cut.Instance.ValidateAsync())); + } + + [Fact] + public async Task ValidateAsync_CancelsFieldValidation() + { + var rule = new CancellableFieldValidator(); + var foo = new Foo() { Name = "Initial" }; + var cut = Context.Render(pb => { - var validation = Assert.IsType( - method.Invoke(validator, [new FieldIdentifier(foo, nameof(foo.Name)), operation]), exactMatch: false); - await validation; + pb.Add(a => a.Model, foo); + pb.AddChildContent>(pb => + { + pb.Add(a => a.Value, foo.Name); + pb.Add(a => a.ValueChanged, value => foo.Name = value); + pb.Add(a => a.ValueExpression, foo.GenerateValueExpression()); + pb.Add(a => a.ValidateRules, [rule]); + }); }); + + await cut.InvokeAsync(() => cut.Find("input").Change("First")); + await rule.FirstValidationStarted.Task.WaitAsync(CancellationToken.None); + foo.Name = "Second"; + + Assert.True(await cut.InvokeAsync(() => cut.Instance.ValidateAsync())); + await rule.FirstValidationCancelled.Task.WaitAsync(CancellationToken.None); + Assert.True(rule.SecondValidationCompleted.Task.IsCompletedSuccessfully); } -#endif [Fact] public async Task Validate_Ok() @@ -1316,6 +1359,10 @@ private sealed class ValidateFormTestLogger : ILogger(TState state) where TState : notnull => null; public bool IsEnabled(LogLevel logLevel) => true; @@ -1328,6 +1375,8 @@ public void Log( Func formatter) { Exception = exception; + Level = logLevel; + Message = formatter(state, exception); } } diff --git a/test/UnitTest/Extensions/AsyncFieldValidationTest.cs b/test/UnitTest/Extensions/AsyncFieldValidationTest.cs new file mode 100644 index 00000000000..685167772b1 --- /dev/null +++ b/test/UnitTest/Extensions/AsyncFieldValidationTest.cs @@ -0,0 +1,255 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +using Microsoft.AspNetCore.Components.Forms; + +namespace UnitTest.Extensions; + +public class AsyncFieldValidationTest +{ + [Fact] + public void CompletedValidation() + { + var model = new Foo(); + var context = new EditContext(model); + var field = context.Field(nameof(model.Name)); + var notifications = 0; + context.OnValidationStateChanged += (_, _) => notifications++; + + Assert.False(context.IsValidationPending(field)); + Assert.False(context.IsValidationFaulted(field)); + Assert.False(context.IsValidationPending(() => model.Name)); + Assert.False(context.IsValidationFaulted(() => model.Name)); + + context.RegisterAsyncFieldValidator(field, _ => Task.CompletedTask); + + Assert.False(context.IsValidationPending(field)); + Assert.False(context.IsValidationFaulted(field)); + Assert.Equal(0, notifications); + } + + [Fact] + public void IsValidationFaulted_UnregisteredField() + { + var context = new EditContext(new object()); + var registeredField = context.Field("Name"); + var unregisteredField = context.Field("Age"); + + Assert.False(context.IsValidationFaulted(unregisteredField)); + + context.RegisterAsyncFieldValidator(registeredField, + _ => Task.FromException(new InvalidOperationException("Validation failed"))); + + Assert.True(context.IsValidationFaulted(registeredField)); + Assert.False(context.IsValidationFaulted(unregisteredField)); + } + + [Fact] + public async Task PendingValidation() + { + var model = new Foo(); + var context = new EditContext(model); + var field = context.Field(nameof(model.Name)); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var states = new List(); + context.OnValidationStateChanged += (_, _) => states.Add(context.IsValidationPending(field)); + var settled = ObserveCompletion(context, field); + context.RegisterAsyncFieldValidator(field, _ => completion.Task); + + Assert.True(context.IsValidationPending(field)); + Assert.True(context.IsValidationPending(() => model.Name)); + Assert.False(context.IsValidationPending()); + completion.SetResult(); + await settled.Task.WaitAsync(CancellationToken.None); + + Assert.False(context.IsValidationPending(field)); + Assert.False(context.IsValidationFaulted(field)); + Assert.Equal([true, false], states); + } + + [Fact] + public async Task ReplacementCancelsPreviousValidation() + { + var context = new EditContext(new object()); + var field = context.Field("Name"); + var previousCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var settled = ObserveCompletion(context, field); + CancellationToken previousToken = default; + context.RegisterAsyncFieldValidator(field, token => + { + previousToken = token; + return previousCompletion.Task; + }); + context.RegisterAsyncFieldValidator(field, _ => completion.Task); + + Assert.True(previousToken.IsCancellationRequested); + Assert.True(context.IsValidationPending(field)); + // The old task still owns a usable token source until it settles. + Assert.True(previousToken.WaitHandle.WaitOne(0)); + previousCompletion.SetException(new InvalidOperationException("Stale failure")); + completion.SetResult(); + await settled.Task.WaitAsync(CancellationToken.None); + + Assert.False(context.IsValidationPending(field)); + Assert.False(context.IsValidationFaulted(field)); + } + + [Fact] + public async Task FieldsAndContextsAreIndependent() + { + var first = new EditContext(new object()); + var second = new EditContext(first.Model); + var name = first.Field("Name"); + var age = first.Field("Age"); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var nameSettled = ObserveCompletion(first, name); + var ageSettled = ObserveCompletion(first, age); + var secondSettled = ObserveCompletion(second, name); + var tokens = new List(); + Task Validate(CancellationToken token) + { + tokens.Add(token); + return completion.Task; + } + + first.RegisterAsyncFieldValidator(name, Validate); + first.RegisterAsyncFieldValidator(age, Validate); + second.RegisterAsyncFieldValidator(name, Validate); + + Assert.All(tokens, token => Assert.False(token.IsCancellationRequested)); + Assert.Equal(3, tokens.Distinct().Count()); + completion.SetResult(); + await Task.WhenAll(nameSettled.Task, ageSettled.Task, secondSettled.Task); + Assert.False(first.IsValidationPending(name)); + Assert.False(first.IsValidationPending(age)); + Assert.False(second.IsValidationPending(name)); + } + + [Fact] + public async Task FaultIsObservableAndClearedBySuccess() + { + var model = new Foo(); + var context = new EditContext(model); + var field = context.Field(nameof(model.Name)); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var settled = ObserveCompletion(context, field); + context.RegisterAsyncFieldValidator(field, _ => completion.Task); + completion.SetException(new InvalidOperationException("Validation failed")); + await settled.Task.WaitAsync(CancellationToken.None); + + Assert.True(context.IsValidationFaulted(field)); + Assert.True(context.IsValidationFaulted(() => model.Name)); + Assert.False(context.IsValidationFaulted()); + context.RegisterAsyncFieldValidator(field, _ => Task.CompletedTask); + Assert.False(context.IsValidationFaulted(field)); + } + + [Fact] + public void UnrelatedCancellationFaults() + { + var context = new EditContext(new object()); + var field = context.Field("Name"); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + context.RegisterAsyncFieldValidator(field, _ => Task.FromCanceled(cancellation.Token)); + + Assert.False(context.IsValidationPending(field)); + Assert.True(context.IsValidationFaulted(field)); + } + + [Fact] + public async Task OwnedCancellationDoesNotFaultReplacement() + { + var context = new EditContext(new object()); + var field = context.Field("Name"); + Task? cancelledTask = null; + context.RegisterAsyncFieldValidator(field, token => + cancelledTask = Task.Delay(Timeout.InfiniteTimeSpan, token)); + + context.RegisterAsyncFieldValidator(field, _ => Task.CompletedTask); + + Assert.NotNull(cancelledTask); + await Assert.ThrowsAnyAsync(() => cancelledTask); + Assert.False(context.IsValidationPending(field)); + Assert.False(context.IsValidationFaulted(field)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task InvalidDelegateClearsPreviousValidation(bool throws) + { + var context = new EditContext(new object()); + var field = context.Field("Name"); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + CancellationToken previousToken = default; + context.RegisterAsyncFieldValidator(field, token => + { + previousToken = token; + return completion.Task; + }); + + Assert.Throws(() => context.RegisterAsyncFieldValidator(field, + _ => throws ? throw new InvalidOperationException("Delegate failed") : null!)); + + Assert.True(previousToken.IsCancellationRequested); + Assert.False(context.IsValidationPending(field)); + Assert.False(context.IsValidationFaulted(field)); + completion.SetResult(); + await completion.Task; + context.RegisterAsyncFieldValidator(field, _ => Task.CompletedTask); + Assert.False(context.IsValidationPending(field)); + } + + [Fact] + public async Task FormValidationCancelsFieldsAndClearsFaults() + { + var context = new EditContext(new object()); + var name = context.Field("Name"); + var age = context.Field("Age"); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + CancellationToken fieldToken = default; + context.RegisterAsyncFieldValidator(name, token => + { + fieldToken = token; + return completion.Task; + }); + context.RegisterAsyncFieldValidator(age, _ => Task.FromException(new InvalidOperationException("Old failure"))); + Assert.True(context.IsValidationFaulted(age)); + context.OnValidationRequested += (_, args) => + { + Assert.True(fieldToken.IsCancellationRequested); + Assert.False(context.IsValidationPending(name)); + Assert.False(context.IsValidationFaulted(age)); + args.AddAsyncValidator(_ => Task.CompletedTask); + }; + + Assert.True(await context.ValidateAsync(CancellationToken.None)); + Assert.True(fieldToken.WaitHandle.WaitOne(0)); + completion.SetResult(); + await completion.Task; + Assert.False(context.IsValidationPending(name)); + Assert.False(context.IsValidationFaulted(name)); + } + + private static TaskCompletionSource ObserveCompletion(EditContext context, FieldIdentifier field) + { + var settled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var wasPending = false; + context.OnValidationStateChanged += (_, _) => + { + if (context.IsValidationPending(field)) + { + wasPending = true; + } + else if (wasPending) + { + settled.TrySetResult(); + } + }; + return settled; + } +} diff --git a/test/UnitTest/Extensions/EditContextExtensionsTest.cs b/test/UnitTest/Extensions/EditContextExtensionsTest.cs new file mode 100644 index 00000000000..137d597fb11 --- /dev/null +++ b/test/UnitTest/Extensions/EditContextExtensionsTest.cs @@ -0,0 +1,378 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +using Microsoft.AspNetCore.Components.Forms; + +namespace UnitTest.Extensions; + +public class EditContextExtensionsTest +{ + [Fact] + public async Task ValidateAsync_SynchronousMessages() + { + var context = new EditContext(new object()); + var messages = new ValidationMessageStore(context); + context.OnValidationRequested += (_, _) => messages.Add(context.Field("Name"), "Required"); + + Assert.False(context.IsValidationPending()); + Assert.False(context.IsValidationFaulted()); + Assert.False(await context.ValidateAsync(CancellationToken.None)); + Assert.False(context.IsValidationFaulted()); + } + + [Fact] + public async Task ValidateAsync_AwaitsAllValidators() + { + var context = new EditContext(new object()); + var first = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var second = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var messages = new ValidationMessageStore(context); + var calls = new List(); + var pendingStates = new List(); + context.OnValidationStateChanged += (_, _) => pendingStates.Add(context.IsValidationPending()); + context.OnValidationRequested += (_, args) => + { + calls.Add("register"); + args.AddAsyncValidator(async _ => + { + calls.Add("first"); + await first.Task; + messages.Add(context.Field("Name"), "Invalid"); + }); + args.AddAsyncValidator(_ => + { + calls.Add("second"); + return second.Task; + }); + }; + + var validation = context.ValidateAsync(CancellationToken.None); + + Assert.Equal(["register", "first", "second"], calls); + Assert.True(context.IsValidationPending()); + Assert.False(validation.IsCompleted); + first.SetResult(); + Assert.False(validation.IsCompleted); + second.SetResult(); + + Assert.False(await validation); + Assert.False(context.IsValidationPending()); + Assert.Equal([true, false], pendingStates); + Assert.Equal(["Invalid"], context.GetValidationMessages()); + } + + [Fact] + public async Task ValidateAsync_UsesFinalMessages() + { + var context = new EditContext(new object()); + var messages = new ValidationMessageStore(context); + var count = 0; + context.OnValidationRequested += (_, args) => + { + messages.Add(context.Field("Name"), "Old error"); + args.AddAsyncValidator(_ => + { + count++; + messages.Clear(); + return Task.CompletedTask; + }); + }; + + Assert.True(await context.ValidateAsync(CancellationToken.None)); + Assert.True(await context.ValidateAsync(CancellationToken.None)); + Assert.Equal(2, count); + } + + [Fact] + public async Task ValidateAsync_IsolatesForms() + { + var first = new EditContext(new object()); + var second = new EditContext(new object()); + var firstCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var secondCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var firstMessages = new ValidationMessageStore(first); + using var firstCancellation = new CancellationTokenSource(); + using var secondCancellation = new CancellationTokenSource(); + first.OnValidationRequested += (_, args) => args.AddAsyncValidator(async token => + { + Assert.Equal(firstCancellation.Token, token); + await firstCompletion.Task; + firstMessages.Add(first.Field("Name"), "First form error"); + }); + second.OnValidationRequested += (_, args) => args.AddAsyncValidator(token => + { + Assert.Equal(secondCancellation.Token, token); + return secondCompletion.Task; + }); + + var firstValidation = first.ValidateAsync(firstCancellation.Token); + var secondValidation = second.ValidateAsync(secondCancellation.Token); + secondCompletion.SetResult(); + + Assert.True(await secondValidation); + Assert.False(firstValidation.IsCompleted); + firstCompletion.SetResult(); + Assert.False(await firstValidation); + Assert.Empty(second.GetValidationMessages()); + } + + [Fact] + public async Task ValidateAsync_RestoresNestedScope() + { + var outer = new EditContext(new object()); + var inner = new EditContext(new object()); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var calls = new List(); + inner.OnValidationRequested += (_, args) => args.AddAsyncValidator(_ => + { + calls.Add("inner"); + return completion.Task; + }); + outer.OnValidationRequested += (_, args) => + { + var innerValidation = inner.ValidateAsync(CancellationToken.None); + args.AddAsyncValidator(_ => + { + calls.Add("outer"); + return innerValidation; + }); + }; + + var validation = outer.ValidateAsync(CancellationToken.None); + Assert.Equal(["inner", "outer"], calls); + Assert.False(validation.IsCompleted); + completion.SetResult(); + Assert.True(await validation); + } + + [Fact] + public async Task ValidateAsync_FaultWaitsForOtherValidators() + { + var context = new EditContext(new object()); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var shouldFail = true; + context.OnValidationRequested += (_, args) => + { + args.AddAsyncValidator(_ => shouldFail + ? Task.FromException(new InvalidOperationException("Validation failed")) + : Task.CompletedTask); + args.AddAsyncValidator(_ => completion.Task); + }; + + var validation = context.ValidateAsync(CancellationToken.None); + Assert.False(validation.IsCompleted); + completion.SetResult(); + Assert.False(await validation); + Assert.True(context.IsValidationFaulted()); + Assert.False(context.IsValidationPending()); + Assert.Empty(context.GetValidationMessages()); + + shouldFail = false; + Assert.True(await context.ValidateAsync(CancellationToken.None)); + Assert.False(context.IsValidationFaulted()); + } + + [Fact] + public async Task ValidateAsync_CancellationWaitsForOtherValidators() + { + var context = new EditContext(new object()); + using var cancellation = new CancellationTokenSource(); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + context.OnValidationRequested += (_, args) => + { + args.AddAsyncValidator(token => + { + Assert.Equal(cancellation.Token, token); + return Task.Delay(Timeout.InfiniteTimeSpan, token); + }); + args.AddAsyncValidator(_ => completion.Task); + }; + + var validation = context.ValidateAsync(cancellation.Token); + cancellation.Cancel(); + Assert.False(validation.IsCompleted); + completion.SetResult(); + + await Assert.ThrowsAnyAsync(() => validation); + Assert.False(context.IsValidationPending()); + Assert.False(context.IsValidationFaulted()); + } + + [Fact] + public async Task ValidateAsync_PreCancelled() + { + var context = new EditContext(new object()); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(() => context.ValidateAsync(cancellation.Token)); + Assert.False(context.IsValidationPending()); + } + + [Fact] + public async Task ValidateAsync_UnrelatedCancellationFaults() + { + var context = new EditContext(new object()); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + context.OnValidationRequested += (_, args) => + args.AddAsyncValidator(_ => Task.FromCanceled(cancellation.Token)); + + Assert.False(await context.ValidateAsync(CancellationToken.None)); + Assert.True(context.IsValidationFaulted()); + + await Assert.ThrowsAnyAsync(() => context.ValidateAsync(cancellation.Token)); + Assert.True(context.IsValidationFaulted()); + } + + [Fact] + public async Task ValidateAsync_HandlerException() + { + var context = new EditContext(new object()); + var called = false; + EventHandler handler = (_, args) => + { + args.AddAsyncValidator(_ => + { + called = true; + return Task.CompletedTask; + }); + throw new InvalidOperationException("Handler failed"); + }; + context.OnValidationRequested += handler; + + await Assert.ThrowsAsync(() => context.ValidateAsync(CancellationToken.None)); + Assert.False(called); + Assert.False(context.IsValidationPending()); + context.OnValidationRequested -= handler; + Assert.True(await context.ValidateAsync(CancellationToken.None)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ValidateAsync_InvalidDelegate(bool throws) + { + var context = new EditContext(new object()); + EventHandler handler = (_, args) => + args.AddAsyncValidator(_ => throws ? throw new InvalidOperationException("Delegate failed") : null!); + context.OnValidationRequested += handler; + + await Assert.ThrowsAsync(() => context.ValidateAsync(CancellationToken.None)); + Assert.False(context.IsValidationPending()); + context.OnValidationRequested -= handler; + Assert.True(await context.ValidateAsync(CancellationToken.None)); + } + +#if !NET11_0_OR_GREATER + [Fact] + public async Task ValidateAsync_SerializesSameContext() + { + var context = new EditContext(new object()); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var count = 0; + context.OnValidationRequested += (_, args) => args.AddAsyncValidator(_ => + { + count++; + return completion.Task; + }); + + var first = context.ValidateAsync(CancellationToken.None); + var second = context.ValidateAsync(CancellationToken.None); + Assert.Equal(1, count); + completion.SetResult(); + + Assert.True(await first); + Assert.True(await second); + Assert.Equal(2, count); + } + + [Fact] + public async Task ValidateAsync_CancelsQueuedPass() + { + var context = new EditContext(new object()); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var count = 0; + using var cancellation = new CancellationTokenSource(); + context.OnValidationRequested += (_, args) => args.AddAsyncValidator(_ => + { + count++; + return completion.Task; + }); + + var first = context.ValidateAsync(CancellationToken.None); + var second = context.ValidateAsync(cancellation.Token); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(() => second); + Assert.Equal(1, count); + completion.SetResult(); + Assert.True(await first); + } + + [Fact] + public async Task ValidateAsync_RejectsReentrancy() + { + var context = new EditContext(new object()); + Task? nested = null; + context.OnValidationRequested += (_, _) => nested = context.ValidateAsync(CancellationToken.None); + + Assert.True(await context.ValidateAsync(CancellationToken.None)); + Assert.NotNull(nested); + await Assert.ThrowsAsync(() => nested); + } + + [Fact] + public async Task ValidateAsync_AllowsCompletedParentScope() + { + var context = new EditContext(new object()); + ExecutionContext? capturedContext = null; + EventHandler handler = (_, _) => + capturedContext = ExecutionContext.Capture(); + context.OnValidationRequested += handler; + + Assert.True(await context.ValidateAsync(CancellationToken.None)); + context.OnValidationRequested -= handler; + Assert.NotNull(capturedContext); + + Task? validation = null; + ExecutionContext.Run(capturedContext, _ => + validation = context.ValidateAsync(CancellationToken.None), null); + + Assert.NotNull(validation); + Assert.True(await validation); + } + + [Fact] + public async Task AddAsyncValidator_RejectsLateRegistration() + { + var context = new EditContext(new object()); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Task? registration = null; + context.OnValidationRequested += (_, args) => registration = RegisterAsync(args); + + Assert.True(await context.ValidateAsync(CancellationToken.None)); + completion.SetResult(); + Assert.NotNull(registration); + await Assert.ThrowsAsync(() => registration); + + async Task RegisterAsync(ValidationRequestedEventArgs args) + { + await completion.Task; + args.AddAsyncValidator(_ => Task.CompletedTask); + } + } + + [Fact] + public void AddAsyncValidator_SynchronousValidationThrows() + { + var context = new EditContext(new object()); + context.OnValidationRequested += (_, args) => args.AddAsyncValidator(_ => Task.CompletedTask); + + // 调用同步方法时抛出异常 + Assert.Throws(() => context.Validate()); + } +#endif +}