diff --git a/CommunityToolkit.Mvvm.SourceGenerators/AnalyzerReleases.Shipped.md b/CommunityToolkit.Mvvm.SourceGenerators/AnalyzerReleases.Shipped.md index 2207a2772..310001460 100644 --- a/CommunityToolkit.Mvvm.SourceGenerators/AnalyzerReleases.Shipped.md +++ b/CommunityToolkit.Mvvm.SourceGenerators/AnalyzerReleases.Shipped.md @@ -37,3 +37,4 @@ MVVMTK0027 | CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator MVVMTK0028 | CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator | Error | See https://aka.ms/mvvmtoolkit/error MVVMTK0029 | CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator | Warning | See https://aka.ms/mvvmtoolkit/error MVVMTK0030 | CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator | Warning | See https://aka.ms/mvvmtoolkit/error +MVVMTK0031 | CommunityToolkit.Mvvm.SourceGenerators.RelayCommandGenerator | Error | See https://aka.ms/mvvmtoolkit/error diff --git a/CommunityToolkit.Mvvm.SourceGenerators/Diagnostics/DiagnosticDescriptors.cs b/CommunityToolkit.Mvvm.SourceGenerators/Diagnostics/DiagnosticDescriptors.cs index 03059088f..64bcbe35d 100644 --- a/CommunityToolkit.Mvvm.SourceGenerators/Diagnostics/DiagnosticDescriptors.cs +++ b/CommunityToolkit.Mvvm.SourceGenerators/Diagnostics/DiagnosticDescriptors.cs @@ -191,17 +191,17 @@ internal static class DiagnosticDescriptors /// /// Gets a indicating when RelayCommandAttribute.AllowConcurrentExecutions is being set for a non-asynchronous method. /// - /// Format: "The method {0}.{1} cannot be annotated with the [RelayCommand] attribute specifying a concurrency control setting, as it maps to a non-asynchronous command type". + /// Format: "The method {0}.{1} cannot be annotated with the [RelayCommand] attribute specifying a concurrency control option, as it maps to a non-asynchronous command type". /// /// public static readonly DiagnosticDescriptor InvalidConcurrentExecutionsParameterError = new DiagnosticDescriptor( id: "MVVMTK0012", - title: "Invalid concurrency control setting usage", - messageFormat: "The method {0}.{1} cannot be annotated with the [RelayCommand] attribute specifying a concurrency control setting, as it maps to a non-asynchronous command type", + title: "Invalid concurrency control option usage", + messageFormat: "The method {0}.{1} cannot be annotated with the [RelayCommand] attribute specifying a concurrency control option, as it maps to a non-asynchronous command type", category: typeof(RelayCommandGenerator).FullName, defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true, - description: "Cannot apply the [RelayCommand] attribute specifying a concurrency control setting to methods mapping to non-asynchronous command types.", + description: "Cannot apply the [RelayCommand] attribute specifying a concurrency control option to methods mapping to non-asynchronous command types.", helpLinkUri: "https://aka.ms/mvvmtoolkit"); /// @@ -491,4 +491,20 @@ internal static class DiagnosticDescriptors isEnabledByDefault: true, description: "Annotating a field with [NotifyDataErrorInfo] is not necessary if the containing type has or inherits [NotifyDataErrorInfo] at the class-level.", helpLinkUri: "https://aka.ms/mvvmtoolkit"); + + /// + /// Gets a indicating when RelayCommandAttribute.FlowExceptionsToTaskScheduler is being set for a non-asynchronous method. + /// + /// Format: "The method {0}.{1} cannot be annotated with the [RelayCommand] attribute specifying an exception flow option, as it maps to a non-asynchronous command type". + /// + /// + public static readonly DiagnosticDescriptor InvalidFlowExceptionsToTaskSchedulerParameterError = new DiagnosticDescriptor( + id: "MVVMTK0031", + title: "Invalid task scheduler exception flow option usage", + messageFormat: "The method {0}.{1} cannot be annotated with the [RelayCommand] attribute specifying a task scheduler exception flow option, as it maps to a non-asynchronous command type", + category: typeof(RelayCommandGenerator).FullName, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Cannot apply the [RelayCommand] attribute specifying a task scheduler exception flow option to methods mapping to non-asynchronous command types.", + helpLinkUri: "https://aka.ms/mvvmtoolkit"); } diff --git a/CommunityToolkit.Mvvm.SourceGenerators/Input/Models/CommandInfo.cs b/CommunityToolkit.Mvvm.SourceGenerators/Input/Models/CommandInfo.cs index fc8c859e9..f9240249f 100644 --- a/CommunityToolkit.Mvvm.SourceGenerators/Input/Models/CommandInfo.cs +++ b/CommunityToolkit.Mvvm.SourceGenerators/Input/Models/CommandInfo.cs @@ -25,6 +25,7 @@ namespace CommunityToolkit.Mvvm.SourceGenerators.Input.Models; /// The member name for the can execute check, if available. /// The can execute expression type, if available. /// Whether or not concurrent executions have been enabled. +/// Whether or not exceptions should flow to the task scheduler. /// Whether or not to also generate a cancel command. internal sealed record CommandInfo( string MethodName, @@ -38,6 +39,7 @@ internal sealed record CommandInfo( string? CanExecuteMemberName, CanExecuteExpressionType? CanExecuteExpressionType, bool AllowConcurrentExecutions, + bool FlowExceptionsToTaskScheduler, bool IncludeCancelCommand) { /// @@ -59,6 +61,7 @@ protected override void AddToHashCode(ref HashCode hashCode, CommandInfo obj) hashCode.Add(obj.CanExecuteMemberName); hashCode.Add(obj.CanExecuteExpressionType); hashCode.Add(obj.AllowConcurrentExecutions); + hashCode.Add(obj.FlowExceptionsToTaskScheduler); hashCode.Add(obj.IncludeCancelCommand); } @@ -77,6 +80,7 @@ protected override bool AreEqual(CommandInfo x, CommandInfo y) x.CanExecuteMemberName == y.CanExecuteMemberName && x.CanExecuteExpressionType == y.CanExecuteExpressionType && x.AllowConcurrentExecutions == y.AllowConcurrentExecutions && + x.FlowExceptionsToTaskScheduler == y.FlowExceptionsToTaskScheduler && x.IncludeCancelCommand == y.IncludeCancelCommand; } } diff --git a/CommunityToolkit.Mvvm.SourceGenerators/Input/RelayCommandGenerator.Execute.cs b/CommunityToolkit.Mvvm.SourceGenerators/Input/RelayCommandGenerator.Execute.cs index 5f60675ad..1a438dd03 100644 --- a/CommunityToolkit.Mvvm.SourceGenerators/Input/RelayCommandGenerator.Execute.cs +++ b/CommunityToolkit.Mvvm.SourceGenerators/Input/RelayCommandGenerator.Execute.cs @@ -71,6 +71,17 @@ internal static class Execute goto Failure; } + // Check the switch to control exception flow + if (!TryGetFlowExceptionsToTaskSchedulerSwitch( + methodSymbol, + attributeData, + commandClassType, + builder, + out bool flowExceptionsToTaskScheduler)) + { + goto Failure; + } + // Get the CanExecute expression type, if any if (!TryGetCanExecuteExpressionType( methodSymbol, @@ -109,6 +120,7 @@ internal static class Execute canExecuteMemberName, canExecuteExpressionType, allowConcurrentExecutions, + flowExceptionsToTaskScheduler, generateCancelCommand); Failure: @@ -205,9 +217,37 @@ public static ImmutableArray GetSyntax(CommandInfo comm } // Enable concurrent executions, if requested - if (commandInfo.AllowConcurrentExecutions) + if (commandInfo.AllowConcurrentExecutions && !commandInfo.FlowExceptionsToTaskScheduler) + { + commandCreationArguments.Add( + Argument(MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("global::CommunityToolkit.Mvvm.Input.AsyncRelayCommandOptions"), + IdentifierName("AllowConcurrentExecutions")))); + } + else if (commandInfo.FlowExceptionsToTaskScheduler && !commandInfo.AllowConcurrentExecutions) { - commandCreationArguments.Add(Argument(LiteralExpression(SyntaxKind.TrueLiteralExpression))); + // Enable exception flow, if requested + commandCreationArguments.Add( + Argument(MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("global::CommunityToolkit.Mvvm.Input.AsyncRelayCommandOptions"), + IdentifierName("FlowExceptionsToTaskScheduler")))); + } + else if (commandInfo.AllowConcurrentExecutions && commandInfo.FlowExceptionsToTaskScheduler) + { + // Enable both concurrency control and exception flow + commandCreationArguments.Add( + Argument(BinaryExpression( + SyntaxKind.BitwiseOrExpression, + MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("global::CommunityToolkit.Mvvm.Input.AsyncRelayCommandOptions"), + IdentifierName("AllowConcurrentExecutions")), + MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("global::CommunityToolkit.Mvvm.Input.AsyncRelayCommandOptions"), + IdentifierName("FlowExceptionsToTaskScheduler"))))); } // Construct the generated property as follows (the explicit delegate cast is needed to avoid overload resolution conflicts): @@ -555,6 +595,43 @@ private static bool TryGetAllowConcurrentExecutionsSwitch( } } + /// + /// Checks whether or not the user has requested to configure the task scheduler exception flow option. + /// + /// The input instance to process. + /// The instance the method was annotated with. + /// The command class type name. + /// The current collection of gathered diagnostics. + /// Whether or not task scheduler exception flow have been enabled. + /// Whether or not a value for could be retrieved successfully. + private static bool TryGetFlowExceptionsToTaskSchedulerSwitch( + IMethodSymbol methodSymbol, + AttributeData attributeData, + string commandClassType, + ImmutableArray.Builder diagnostics, + out bool flowExceptionsToTaskScheduler) + { + // Try to get the custom switch for task scheduler exception flow (the default is false) + if (!attributeData.TryGetNamedArgument("FlowExceptionsToTaskScheduler", out flowExceptionsToTaskScheduler)) + { + flowExceptionsToTaskScheduler = false; + + return true; + } + + // Just like with the concurrency control option, check that the target command type is asynchronous + if (commandClassType is "global::CommunityToolkit.Mvvm.Input.AsyncRelayCommand") + { + return true; + } + else + { + diagnostics.Add(InvalidFlowExceptionsToTaskSchedulerParameterError, methodSymbol, methodSymbol.ContainingType, methodSymbol); + + return false; + } + } + /// /// Checks whether or not the user has requested to also generate a cancel command. /// diff --git a/CommunityToolkit.Mvvm/Input/AsyncRelayCommand.cs b/CommunityToolkit.Mvvm/Input/AsyncRelayCommand.cs index 20799aa2b..8b38c24e9 100644 --- a/CommunityToolkit.Mvvm/Input/AsyncRelayCommand.cs +++ b/CommunityToolkit.Mvvm/Input/AsyncRelayCommand.cs @@ -59,9 +59,9 @@ public sealed class AsyncRelayCommand : IAsyncRelayCommand, ICancellationAwareCo private readonly Func? canExecute; /// - /// Indicates whether or not concurrent executions of the command are allowed. + /// The options being set for the current command. /// - private readonly bool allowConcurrentExecutions; + private readonly AsyncRelayCommandOptions options; /// /// The instance to use to cancel . @@ -91,14 +91,14 @@ public AsyncRelayCommand(Func execute) /// Initializes a new instance of the class. /// /// The execution logic. - /// Whether or not to allow concurrent executions of the command. + /// The options to use to configure the async command. /// Thrown if is . - public AsyncRelayCommand(Func execute, bool allowConcurrentExecutions) + public AsyncRelayCommand(Func execute, AsyncRelayCommandOptions options) { ArgumentNullException.ThrowIfNull(execute); this.execute = execute; - this.allowConcurrentExecutions = allowConcurrentExecutions; + this.options = options; } /// @@ -117,14 +117,14 @@ public AsyncRelayCommand(Func cancelableExecute) /// Initializes a new instance of the class. /// /// The cancelable execution logic. - /// Whether or not to allow concurrent executions of the command. + /// The options to use to configure the async command. /// Thrown if is . - public AsyncRelayCommand(Func cancelableExecute, bool allowConcurrentExecutions) + public AsyncRelayCommand(Func cancelableExecute, AsyncRelayCommandOptions options) { ArgumentNullException.ThrowIfNull(cancelableExecute); this.cancelableExecute = cancelableExecute; - this.allowConcurrentExecutions = allowConcurrentExecutions; + this.options = options; } /// @@ -147,16 +147,16 @@ public AsyncRelayCommand(Func execute, Func canExecute) /// /// The execution logic. /// The execution status logic. - /// Whether or not to allow concurrent executions of the command. + /// The options to use to configure the async command. /// Thrown if or are . - public AsyncRelayCommand(Func execute, Func canExecute, bool allowConcurrentExecutions) + public AsyncRelayCommand(Func execute, Func canExecute, AsyncRelayCommandOptions options) { ArgumentNullException.ThrowIfNull(execute); ArgumentNullException.ThrowIfNull(canExecute); this.execute = execute; this.canExecute = canExecute; - this.allowConcurrentExecutions = allowConcurrentExecutions; + this.options = options; } /// @@ -179,16 +179,16 @@ public AsyncRelayCommand(Func cancelableExecute, Func /// The cancelable execution logic. /// The execution status logic. - /// Whether or not to allow concurrent executions of the command. + /// The options to use to configure the async command. /// Thrown if or are . - public AsyncRelayCommand(Func cancelableExecute, Func canExecute, bool allowConcurrentExecutions) + public AsyncRelayCommand(Func cancelableExecute, Func canExecute, AsyncRelayCommandOptions options) { ArgumentNullException.ThrowIfNull(cancelableExecute); ArgumentNullException.ThrowIfNull(canExecute); this.cancelableExecute = cancelableExecute; this.canExecute = canExecute; - this.allowConcurrentExecutions = allowConcurrentExecutions; + this.options = options; } private Task? executionTask; @@ -238,7 +238,7 @@ static async void MonitorTask(AsyncRelayCommand @this, Task task) @this.PropertyChanged?.Invoke(@this, CanBeCanceledChangedEventArgs); } - if (!@this.allowConcurrentExecutions) + if ((@this.options & AsyncRelayCommandOptions.AllowConcurrentExecutions) == 0) { @this.CanExecuteChanged?.Invoke(@this, EventArgs.Empty); } @@ -273,13 +273,20 @@ public bool CanExecute(object? parameter) { bool canExecute = this.canExecute?.Invoke() != false; - return canExecute && (this.allowConcurrentExecutions || ExecutionTask is not { IsCompleted: false }); + return canExecute && ((this.options & AsyncRelayCommandOptions.AllowConcurrentExecutions) != 0 || ExecutionTask is not { IsCompleted: false }); } /// public void Execute(object? parameter) { - _ = ExecuteAsync(parameter); + Task executionTask = ExecuteAsync(parameter); + + // If exceptions shouldn't flow to the task scheduler, await the resulting task. This is + // delegated to a separate method to keep this one more compact in case the option is set. + if ((this.options & AsyncRelayCommandOptions.FlowExceptionsToTaskScheduler) == 0) + { + AwaitAndThrowIfFailed(executionTask); + } } /// @@ -304,7 +311,7 @@ public Task ExecuteAsync(object? parameter) } // If concurrent executions are disabled, notify the can execute change as well - if (!this.allowConcurrentExecutions) + if ((this.options & AsyncRelayCommandOptions.AllowConcurrentExecutions) == 0) { CanExecuteChanged?.Invoke(this, EventArgs.Empty); } @@ -323,4 +330,24 @@ public void Cancel() PropertyChanged?.Invoke(this, IsCancellationRequestedChangedEventArgs); } } + + /// + /// Awaits an input and throws an exception on the calling context, if the task fails. + /// + /// The input instance to await. + internal static async void AwaitAndThrowIfFailed(Task executionTask) + { + // Note: this method is purposefully an async void method awaiting the input task. This is done so that + // if an async relay command is invoked synchronously (ie. when Execute is called, eg. from a binding), + // exceptions in the wrapped delegate will not be ignored or just become visible through the ExecutionTask + // property, but will be rethrown in the original synchronization context by default. This makes the behavior + // more consistent with how normal commands work (where exceptions are also just normally propagated to the + // caller context), and avoids getting an app into an inconsistent state in case a method faults without + // other components being notified. It is also possible to not await this task and to instead ignore exceptions + // and then inspect them manually from the ExecutionTask property, by constructing an async command instance + // using the AsyncRelayCommandOptions.FlowExceptionsToTaskScheduler option. That will cause this call to + // be skipped, and exceptions will just either normally be available through that property, or will otherwise + // flow to the static TaskScheduler.UnobservedTaskException event if otherwise unobserved (eg. for logging). + await executionTask; + } } diff --git a/CommunityToolkit.Mvvm/Input/AsyncRelayCommandOptions.cs b/CommunityToolkit.Mvvm/Input/AsyncRelayCommandOptions.cs new file mode 100644 index 000000000..50e4393a2 --- /dev/null +++ b/CommunityToolkit.Mvvm/Input/AsyncRelayCommandOptions.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; + +namespace CommunityToolkit.Mvvm.Input; + +/// +/// Options to customize the behavior of and instances. +/// +[Flags] +public enum AsyncRelayCommandOptions +{ + /// + /// No option is specified. The and types will use their default behavior: + /// + /// Concurrent execution is disallowed: a command is disabled if there is a pending asynchronous execution running. + /// + /// + /// Exceptions are thrown on the calling context: calling will await the + /// returned for the operation, and propagate the exception on the calling context. + /// + /// This behavior is consistent with synchronous commands, where exceptions in behave the same. + /// + /// + /// + None = 0, + + /// + /// Concurrent executions are allowed. This option makes it so that the same command can be invoked concurrently multiple times. + /// + /// Note that additional considerations should be taken into account in this case: + /// + /// If the command supports cancellation, previous invocations will automatically be canceled if a new one is started. + /// The property will always represent the operation that was started last. + /// + /// + /// + AllowConcurrentExecutions = 1 << 0, + + /// + /// Exceptions are not thrown on the calling context, and are propagated to instead. + /// + /// This affects how calls to behave. When this option is used, if an operation fails, that exception will not + /// be rethrown on the calling context (as it is not awaited there). Instead, it will flow to . + /// + /// + /// This option enables more advanced scenarios, where the property can be used to inspect the state of an operation + /// that was queued. That is, even if the operation failed or was canceled, the details of that can be retrieved at a later time by accessing this property. + /// + /// + FlowExceptionsToTaskScheduler = 1 << 1 +} diff --git a/CommunityToolkit.Mvvm/Input/AsyncRelayCommand{T}.cs b/CommunityToolkit.Mvvm/Input/AsyncRelayCommand{T}.cs index 792fc238d..d3bbd7b93 100644 --- a/CommunityToolkit.Mvvm/Input/AsyncRelayCommand{T}.cs +++ b/CommunityToolkit.Mvvm/Input/AsyncRelayCommand{T}.cs @@ -36,9 +36,9 @@ public sealed class AsyncRelayCommand : IAsyncRelayCommand, ICancellationA private readonly Predicate? canExecute; /// - /// Indicates whether or not concurrent executions of the command are allowed. + /// The options being set for the current command. /// - private readonly bool allowConcurrentExecutions; + private readonly AsyncRelayCommandOptions options; /// /// The instance to use to cancel . @@ -68,15 +68,15 @@ public AsyncRelayCommand(Func execute) /// Initializes a new instance of the class. /// /// The execution logic. - /// Whether or not to allow concurrent executions of the command. + /// The options to use to configure the async command. /// See notes in . /// Thrown if is . - public AsyncRelayCommand(Func execute, bool allowConcurrentExecutions) + public AsyncRelayCommand(Func execute, AsyncRelayCommandOptions options) { ArgumentNullException.ThrowIfNull(execute); this.execute = execute; - this.allowConcurrentExecutions = allowConcurrentExecutions; + this.options = options; } /// @@ -96,15 +96,15 @@ public AsyncRelayCommand(Func cancelableExecute) /// Initializes a new instance of the class. /// /// The cancelable execution logic. - /// Whether or not to allow concurrent executions of the command. + /// The options to use to configure the async command. /// See notes in . /// Thrown if is . - public AsyncRelayCommand(Func cancelableExecute, bool allowConcurrentExecutions) + public AsyncRelayCommand(Func cancelableExecute, AsyncRelayCommandOptions options) { ArgumentNullException.ThrowIfNull(cancelableExecute); this.cancelableExecute = cancelableExecute; - this.allowConcurrentExecutions = allowConcurrentExecutions; + this.options = options; } /// @@ -128,17 +128,17 @@ public AsyncRelayCommand(Func execute, Predicate canExecute) /// /// The execution logic. /// The execution status logic. - /// Whether or not to allow concurrent executions of the command. + /// The options to use to configure the async command. /// See notes in . /// Thrown if or are . - public AsyncRelayCommand(Func execute, Predicate canExecute, bool allowConcurrentExecutions) + public AsyncRelayCommand(Func execute, Predicate canExecute, AsyncRelayCommandOptions options) { ArgumentNullException.ThrowIfNull(execute); ArgumentNullException.ThrowIfNull(canExecute); this.execute = execute; this.canExecute = canExecute; - this.allowConcurrentExecutions = allowConcurrentExecutions; + this.options = options; } /// @@ -162,17 +162,17 @@ public AsyncRelayCommand(Func cancelableExecute, Pr /// /// The cancelable execution logic. /// The execution status logic. - /// Whether or not to allow concurrent executions of the command. + /// The options to use to configure the async command. /// See notes in . /// Thrown if or are . - public AsyncRelayCommand(Func cancelableExecute, Predicate canExecute, bool allowConcurrentExecutions) + public AsyncRelayCommand(Func cancelableExecute, Predicate canExecute, AsyncRelayCommandOptions options) { ArgumentNullException.ThrowIfNull(cancelableExecute); ArgumentNullException.ThrowIfNull(canExecute); this.cancelableExecute = cancelableExecute; this.canExecute = canExecute; - this.allowConcurrentExecutions = allowConcurrentExecutions; + this.options = options; } private Task? executionTask; @@ -220,7 +220,7 @@ static async void MonitorTask(AsyncRelayCommand @this, Task task) @this.PropertyChanged?.Invoke(@this, AsyncRelayCommand.CanBeCanceledChangedEventArgs); } - if (!@this.allowConcurrentExecutions) + if ((@this.options & AsyncRelayCommandOptions.AllowConcurrentExecutions) == 0) { @this.CanExecuteChanged?.Invoke(@this, EventArgs.Empty); } @@ -255,7 +255,7 @@ public bool CanExecute(T? parameter) { bool canExecute = this.canExecute?.Invoke(parameter) != false; - return canExecute && (this.allowConcurrentExecutions || ExecutionTask is not { IsCompleted: false }); + return canExecute && ((this.options & AsyncRelayCommandOptions.AllowConcurrentExecutions) != 0 || ExecutionTask is not { IsCompleted: false }); } /// @@ -275,13 +275,18 @@ public bool CanExecute(object? parameter) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Execute(T? parameter) { - _ = ExecuteAsync(parameter); + Task executionTask = ExecuteAsync(parameter); + + if ((this.options & AsyncRelayCommandOptions.FlowExceptionsToTaskScheduler) == 0) + { + AsyncRelayCommand.AwaitAndThrowIfFailed(executionTask); + } } /// public void Execute(object? parameter) { - _ = ExecuteAsync((T?)parameter); + Execute((T?)parameter); } /// @@ -306,7 +311,7 @@ public Task ExecuteAsync(T? parameter) } // If concurrent executions are disabled, notify the can execute change as well - if (!this.allowConcurrentExecutions) + if ((this.options & AsyncRelayCommandOptions.AllowConcurrentExecutions) == 0) { CanExecuteChanged?.Invoke(this, EventArgs.Empty); } diff --git a/CommunityToolkit.Mvvm/Input/Attributes/RelayCommandAttribute.cs b/CommunityToolkit.Mvvm/Input/Attributes/RelayCommandAttribute.cs index ee2c268f9..0a1d7f6b2 100644 --- a/CommunityToolkit.Mvvm/Input/Attributes/RelayCommandAttribute.cs +++ b/CommunityToolkit.Mvvm/Input/Attributes/RelayCommandAttribute.cs @@ -75,14 +75,30 @@ public sealed class RelayCommandAttribute : Attribute /// /// Gets or sets a value indicating whether or not to allow concurrent executions for an asynchronous command. + /// /// When set for an attribute used on a method that would result in an or an /// property to be generated, this will modify the behavior of these commands /// when an execution is invoked while a previous one is still running. It is the same as creating an instance of - /// these command types with a constructor such as . + /// these command types with a constructor such as + /// and using the value. + /// /// /// Using this property is not valid if the target command doesn't map to an asynchronous command. public bool AllowConcurrentExecutions { get; init; } + /// + /// Gets or sets a value indicating whether or not to exceptions should be propagated to . + /// + /// When set for an attribute used on a method that would result in an or an + /// property to be generated, this will modify the behavior of these commands + /// in case an exception is thrown by the underlying operation. It is the same as creating an instance of + /// these command types with a constructor such as + /// and using the value. + /// + /// + /// Using this property is not valid if the target command doesn't map to an asynchronous command. + public bool FlowExceptionsToTaskScheduler { get; init; } + /// /// Gets or sets a value indicating whether a cancel command should also be generated for an asynchronous command. /// diff --git a/tests/CommunityToolkit.Mvvm.SourceGenerators.UnitTests/Test_SourceGeneratorsDiagnostics.cs b/tests/CommunityToolkit.Mvvm.SourceGenerators.UnitTests/Test_SourceGeneratorsDiagnostics.cs index a6e8a86d7..263d66837 100644 --- a/tests/CommunityToolkit.Mvvm.SourceGenerators.UnitTests/Test_SourceGeneratorsDiagnostics.cs +++ b/tests/CommunityToolkit.Mvvm.SourceGenerators.UnitTests/Test_SourceGeneratorsDiagnostics.cs @@ -213,7 +213,7 @@ public partial class SampleViewModel } [TestMethod] - public void InvalidICommandMethodSignatureError() + public void InvalidRelayCommandMethodSignatureError() { string source = @" using CommunityToolkit.Mvvm.Input; @@ -310,7 +310,7 @@ public partial class SampleViewModel : ObservableValidator } [TestMethod] - public void UnsupportedCSharpLanguageVersion_FromICommandGenerator() + public void UnsupportedCSharpLanguageVersion_FromRelayCommandGenerator() { string source = @" using CommunityToolkit.Mvvm.Input; @@ -535,7 +535,7 @@ private void GreetUser(string name) } [TestMethod] - public void InvalidICommandAllowConcurrentExecutionsSettings() + public void InvalidRelayCommandAllowConcurrentExecutionsOption() { string source = @" using CommunityToolkit.Mvvm.Input; @@ -555,7 +555,7 @@ private void GreetUser(User user) } [TestMethod] - public void InvalidICommandIncludeCancelCommandSettings_SynchronousMethod() + public void InvalidRelayCommandIncludeCancelCommandSettings_SynchronousMethod() { string source = @" using CommunityToolkit.Mvvm.Input; @@ -575,7 +575,7 @@ private void GreetUser(User user) } [TestMethod] - public void InvalidICommandIncludeCancelCommandSettings_AsynchronousMethodWithNoCancellationToken() + public void InvalidRelayCommandIncludeCancelCommandSettings_AsynchronousMethodWithNoCancellationToken() { string source = @" using System.Threading.Tasks; @@ -596,7 +596,7 @@ private async Task DoWorkAsync() } [TestMethod] - public void InvalidICommandIncludeCancelCommandSettings_AsynchronousMethodWithParameterAndNoCancellationToken() + public void InvalidRelayCommandIncludeCancelCommandSettings_AsynchronousMethodWithParameterAndNoCancellationToken() { string source = @" using System.Threading.Tasks; @@ -1081,7 +1081,7 @@ public partial class MyViewModel : ObservableObject } [TestMethod] - public void MultipleICommandMethodOverloads_WithOverloads() + public void MultipleRelayCommandMethodOverloads_WithOverloads() { string source = @" using CommunityToolkit.Mvvm.Input; @@ -1106,7 +1106,7 @@ private void GreetUser(object value) } [TestMethod] - public void MultipleICommandMethodOverloads_WithOverloadInBaseType() + public void MultipleRelayCommandMethodOverloads_WithOverloadInBaseType() { string source = @" using CommunityToolkit.Mvvm.Input; @@ -1390,6 +1390,26 @@ public partial class MyViewModel : MyBaseViewModel VerifyGeneratedDiagnostics(source, "MVVMTK0030"); } + [TestMethod] + public void InvalidRelayCommandFlowExceptionsToTaskSchedulerOption() + { + string source = @" + using CommunityToolkit.Mvvm.Input; + + namespace MyApp + { + public partial class SampleViewModel + { + [RelayCommand(FlowExceptionsToTaskScheduler = false)] + private void GreetUser(User user) + { + } + } + }"; + + VerifyGeneratedDiagnostics(source, "MVVMTK0031"); + } + /// /// Verifies the output of a source generator. /// diff --git a/tests/CommunityToolkit.Mvvm.UnitTests/CommunityToolkit.Mvvm.UnitTests.csproj b/tests/CommunityToolkit.Mvvm.UnitTests/CommunityToolkit.Mvvm.UnitTests.csproj index 6e494cb70..bee92fbc3 100644 --- a/tests/CommunityToolkit.Mvvm.UnitTests/CommunityToolkit.Mvvm.UnitTests.csproj +++ b/tests/CommunityToolkit.Mvvm.UnitTests/CommunityToolkit.Mvvm.UnitTests.csproj @@ -5,6 +5,7 @@ + diff --git a/tests/CommunityToolkit.Mvvm.UnitTests/Test_ArgumentNullException.Input.cs b/tests/CommunityToolkit.Mvvm.UnitTests/Test_ArgumentNullException.Input.cs index 234950085..bf3d66110 100644 --- a/tests/CommunityToolkit.Mvvm.UnitTests/Test_ArgumentNullException.Input.cs +++ b/tests/CommunityToolkit.Mvvm.UnitTests/Test_ArgumentNullException.Input.cs @@ -30,33 +30,33 @@ public void Test_ArgumentNullException_RelayCommandOfT() public void Test_ArgumentNullException_AsyncRelayCommand() { Assert(() => new AsyncRelayCommand(execute: null!), "execute"); - Assert(() => new AsyncRelayCommand(execute: null!, true), "execute"); + Assert(() => new AsyncRelayCommand(execute: null!, AsyncRelayCommandOptions.AllowConcurrentExecutions), "execute"); Assert(() => new AsyncRelayCommand(cancelableExecute: null!), "cancelableExecute"); - Assert(() => new AsyncRelayCommand(cancelableExecute: null!, true), "cancelableExecute"); + Assert(() => new AsyncRelayCommand(cancelableExecute: null!, AsyncRelayCommandOptions.AllowConcurrentExecutions), "cancelableExecute"); Assert(() => new AsyncRelayCommand(execute: null!, () => true), "execute"); Assert(() => new AsyncRelayCommand(() => Task.CompletedTask, canExecute: null!), "canExecute"); - Assert(() => new AsyncRelayCommand(execute: null!, () => true, true), "execute"); - Assert(() => new AsyncRelayCommand(() => Task.CompletedTask, canExecute: null!, true), "canExecute"); + Assert(() => new AsyncRelayCommand(execute: null!, () => true, AsyncRelayCommandOptions.AllowConcurrentExecutions), "execute"); + Assert(() => new AsyncRelayCommand(() => Task.CompletedTask, canExecute: null!, AsyncRelayCommandOptions.AllowConcurrentExecutions), "canExecute"); Assert(() => new AsyncRelayCommand(cancelableExecute: null!, () => true), "cancelableExecute"); Assert(() => new AsyncRelayCommand(t => Task.CompletedTask, canExecute: null!), "canExecute"); - Assert(() => new AsyncRelayCommand(cancelableExecute: null!, () => true, true), "cancelableExecute"); - Assert(() => new AsyncRelayCommand(t => Task.CompletedTask, canExecute: null!, true), "canExecute"); + Assert(() => new AsyncRelayCommand(cancelableExecute: null!, () => true, AsyncRelayCommandOptions.AllowConcurrentExecutions), "cancelableExecute"); + Assert(() => new AsyncRelayCommand(t => Task.CompletedTask, canExecute: null!, AsyncRelayCommandOptions.AllowConcurrentExecutions), "canExecute"); } [TestMethod] public void Test_ArgumentNullException_AsyncRelayCommandOfT() { Assert(() => new AsyncRelayCommand(execute: null!), "execute"); - Assert(() => new AsyncRelayCommand(execute: null!, true), "execute"); + Assert(() => new AsyncRelayCommand(execute: null!, AsyncRelayCommandOptions.AllowConcurrentExecutions), "execute"); Assert(() => new AsyncRelayCommand(cancelableExecute: null!), "cancelableExecute"); - Assert(() => new AsyncRelayCommand(cancelableExecute: null!, true), "cancelableExecute"); + Assert(() => new AsyncRelayCommand(cancelableExecute: null!, AsyncRelayCommandOptions.AllowConcurrentExecutions), "cancelableExecute"); Assert(() => new AsyncRelayCommand(execute: null!, s => true), "execute"); Assert(() => new AsyncRelayCommand(s => Task.CompletedTask, canExecute: null!), "canExecute"); - Assert(() => new AsyncRelayCommand(execute: null!, s => true, true), "execute"); - Assert(() => new AsyncRelayCommand(s => Task.CompletedTask, canExecute: null!, true), "canExecute"); + Assert(() => new AsyncRelayCommand(execute: null!, s => true, AsyncRelayCommandOptions.AllowConcurrentExecutions), "execute"); + Assert(() => new AsyncRelayCommand(s => Task.CompletedTask, canExecute: null!, AsyncRelayCommandOptions.AllowConcurrentExecutions), "canExecute"); Assert(() => new AsyncRelayCommand(cancelableExecute: null!, s => true), "cancelableExecute"); Assert(() => new AsyncRelayCommand(t => Task.CompletedTask, canExecute: null!), "canExecute"); - Assert(() => new AsyncRelayCommand(cancelableExecute: null!, s => true, true), "cancelableExecute"); - Assert(() => new AsyncRelayCommand(t => Task.CompletedTask, canExecute: null!, true), "canExecute"); + Assert(() => new AsyncRelayCommand(cancelableExecute: null!, s => true, AsyncRelayCommandOptions.AllowConcurrentExecutions), "cancelableExecute"); + Assert(() => new AsyncRelayCommand(t => Task.CompletedTask, canExecute: null!, AsyncRelayCommandOptions.AllowConcurrentExecutions), "canExecute"); } } diff --git a/tests/CommunityToolkit.Mvvm.UnitTests/Test_AsyncRelayCommand.cs b/tests/CommunityToolkit.Mvvm.UnitTests/Test_AsyncRelayCommand.cs index adb3fa407..c786e0de0 100644 --- a/tests/CommunityToolkit.Mvvm.UnitTests/Test_AsyncRelayCommand.cs +++ b/tests/CommunityToolkit.Mvvm.UnitTests/Test_AsyncRelayCommand.cs @@ -10,6 +10,7 @@ using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.UnitTests.Helpers; using Microsoft.VisualStudio.TestTools.UnitTesting; +using Nito.AsyncEx; namespace CommunityToolkit.Mvvm.UnitTests; @@ -190,7 +191,7 @@ public async Task Test_AsyncRelayCommand_AllowConcurrentExecutions_Enable() new TaskCompletionSource() }; - AsyncRelayCommand? command = new(() => cancellationTokenSources[index++].Task, allowConcurrentExecutions: true); + AsyncRelayCommand? command = new(() => cancellationTokenSources[index++].Task, AsyncRelayCommandOptions.AllowConcurrentExecutions); Assert.IsTrue(command.CanExecute(null)); Assert.IsTrue(command.CanExecute(new object())); @@ -247,7 +248,7 @@ public async Task Test_AsyncRelayCommand_AllowConcurrentExecutions_Enable() [TestMethod] public async Task Test_AsyncRelayCommand_AllowConcurrentExecutions_Disabled() { - await Test_AsyncRelayCommand_AllowConcurrentExecutions_TestLogic(static task => new(async () => await task, allowConcurrentExecutions: false)); + await Test_AsyncRelayCommand_AllowConcurrentExecutions_TestLogic(static task => new(async () => await task, AsyncRelayCommandOptions.None)); } [TestMethod] @@ -320,6 +321,72 @@ private static async Task Test_AsyncRelayCommand_AllowConcurrentExecutions_TestL Assert.AreSame(args.Item2, EventArgs.Empty); } + [TestMethod] + public void Test_AsyncRelayCommand_EnsureExceptionThrown_Synchronously() + { + Exception? executeException = null; + + AsyncRelayCommand command = new(async () => + { + await Task.CompletedTask; + + throw new Exception(nameof(Test_AsyncRelayCommand_EnsureExceptionThrown_Synchronously)); + }); + + try + { + AsyncContext.Run(async () => + { + command.Execute(null); + + await Task.Delay(500); + }); + } + catch (Exception e) + { + executeException = e; + } + + Assert.AreEqual(nameof(Test_AsyncRelayCommand_EnsureExceptionThrown_Synchronously), executeException?.Message); + } + + // See https://github.com/CommunityToolkit/dotnet/pull/251 + [TestMethod] + public async Task Test_AsyncRelayCommand_EnsureExceptionThrown() + { + const int delay = 500; + + Exception? executeException = null; + Exception? executeAsyncException = null; + + AsyncRelayCommand command = new(async () => + { + await Task.Delay(delay); + + throw new Exception(nameof(Test_AsyncRelayCommand_EnsureExceptionThrown)); + }); + + try + { + // Use AsyncContext to test async void methods https://stackoverflow.com/a/14207615/5953643 + AsyncContext.Run(async () => + { + command.Execute(null); + + await Task.Delay(delay * 2); // Ensure we don't escape `AsyncContext` before command throws Exception + }); + } + catch (Exception e) + { + executeException = e; + } + + executeAsyncException = await Assert.ThrowsExceptionAsync(() => command.ExecuteAsync(null)); + + Assert.AreEqual(nameof(Test_AsyncRelayCommand_EnsureExceptionThrown), executeException?.Message); + Assert.AreEqual(nameof(Test_AsyncRelayCommand_EnsureExceptionThrown), executeAsyncException?.Message); + } + [TestMethod] public async Task Test_AsyncRelayCommand_ThrowingTaskBubblesToUnobservedTaskException() { @@ -332,7 +399,33 @@ static async Task TestMethodAsync(Action action) async void TestCallback(Action throwAction, Action completeAction) { - AsyncRelayCommand command = new(() => TestMethodAsync(throwAction)); + AsyncRelayCommand command = new(() => TestMethodAsync(throwAction), AsyncRelayCommandOptions.FlowExceptionsToTaskScheduler); + + command.Execute(null); + + await Task.Delay(200); + + completeAction(); + } + + bool success = await TaskSchedulerTestHelper.IsExceptionBubbledUpToUnobservedTaskExceptionAsync(TestCallback); + + Assert.IsTrue(success); + } + + [TestMethod] + public async Task Test_AsyncRelayCommand_ThrowingTaskBubblesToUnobservedTaskException_Synchronously() + { + static async Task TestMethodAsync(Action action) + { + await Task.CompletedTask; + + action(); + } + + async void TestCallback(Action throwAction, Action completeAction) + { + AsyncRelayCommand command = new(() => TestMethodAsync(throwAction), AsyncRelayCommandOptions.FlowExceptionsToTaskScheduler); command.Execute(null); @@ -352,7 +445,7 @@ public void Test_AsyncRelayCommand_ExecuteDoesNotRaiseCanExecuteChanged() { TaskCompletionSource tcs = new(); - AsyncRelayCommand command = new(() => tcs.Task, allowConcurrentExecutions: true); + AsyncRelayCommand command = new(() => tcs.Task, AsyncRelayCommandOptions.AllowConcurrentExecutions); (object? Sender, EventArgs? Args) args = default; @@ -375,7 +468,7 @@ public void Test_AsyncRelayCommand_ExecuteWithoutConcurrencyRaisesCanExecuteChan { TaskCompletionSource tcs = new(); - AsyncRelayCommand command = new(() => tcs.Task, allowConcurrentExecutions: false); + AsyncRelayCommand command = new(() => tcs.Task, AsyncRelayCommandOptions.None); (object? Sender, EventArgs? Args) args = default; @@ -398,7 +491,7 @@ public void Test_AsyncRelayCommand_ExecuteDoesNotRaiseCanExecuteChanged_WithCanc { TaskCompletionSource tcs = new(); - AsyncRelayCommand command = new(token => tcs.Task, allowConcurrentExecutions: true); + AsyncRelayCommand command = new(token => tcs.Task, AsyncRelayCommandOptions.AllowConcurrentExecutions); (object? Sender, EventArgs? Args) args = default; @@ -421,7 +514,7 @@ public void Test_AsyncRelayCommand_ExecuteWithoutConcurrencyRaisesCanExecuteChan { TaskCompletionSource tcs = new(); - AsyncRelayCommand command = new(token => tcs.Task, allowConcurrentExecutions: false); + AsyncRelayCommand command = new(token => tcs.Task, AsyncRelayCommandOptions.None); (object? Sender, EventArgs? Args) args = default; diff --git a/tests/CommunityToolkit.Mvvm.UnitTests/Test_AsyncRelayCommand{T}.cs b/tests/CommunityToolkit.Mvvm.UnitTests/Test_AsyncRelayCommand{T}.cs index 9498c9478..18e2e42a0 100644 --- a/tests/CommunityToolkit.Mvvm.UnitTests/Test_AsyncRelayCommand{T}.cs +++ b/tests/CommunityToolkit.Mvvm.UnitTests/Test_AsyncRelayCommand{T}.cs @@ -10,6 +10,7 @@ using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.UnitTests.Helpers; using Microsoft.VisualStudio.TestTools.UnitTesting; +using Nito.AsyncEx; namespace CommunityToolkit.Mvvm.UnitTests; @@ -185,7 +186,7 @@ public async Task Test_AsyncRelayCommandOfT_WithCancellation() [TestMethod] public async Task Test_AsyncRelayCommandOfT_AllowConcurrentExecutions_Disabled() { - await Test_AsyncRelayCommandOfT_AllowConcurrentExecutions_TestLogic(static task => new(async _ => await task, allowConcurrentExecutions: false)); + await Test_AsyncRelayCommandOfT_AllowConcurrentExecutions_TestLogic(static task => new(async _ => await task, AsyncRelayCommandOptions.None)); } [TestMethod] @@ -241,6 +242,106 @@ private static async Task Test_AsyncRelayCommandOfT_AllowConcurrentExecutions_Te _ = Assert.ThrowsException(() => command.Execute(new object())); } + [TestMethod] + public void Test_AsyncRelayCommandOfT_EnsureExceptionThrown_Synchronously() + { + Exception? executeException = null; + + AsyncRelayCommand command = new(async delay => + { + await Task.CompletedTask; + + throw new Exception(nameof(Test_AsyncRelayCommandOfT_EnsureExceptionThrown_Synchronously)); + }); + + try + { + AsyncContext.Run(async () => + { + command.Execute((object)42); + + await Task.Delay(500); + }); + } + catch (Exception e) + { + executeException = e; + } + + Assert.AreEqual(nameof(Test_AsyncRelayCommandOfT_EnsureExceptionThrown_Synchronously), executeException?.Message); + } + + // See https://github.com/CommunityToolkit/dotnet/pull/251 + [TestMethod] + public async Task Test_AsyncRelayCommandOfT_EnsureExceptionThrown() + { + const int delay = 500; + + Exception? executeException = null; + Exception? executeAsyncException = null; + + AsyncRelayCommand command = new(async delay => + { + await Task.Delay(delay); + + throw new Exception(nameof(Test_AsyncRelayCommandOfT_EnsureExceptionThrown)); + }); + + try + { + AsyncContext.Run(async () => + { + command.Execute((object)delay); + + await Task.Delay(delay * 2); + }); + } + catch (Exception e) + { + executeException = e; + } + + executeAsyncException = await Assert.ThrowsExceptionAsync(() => command.ExecuteAsync((object)delay)); + + Assert.AreEqual(nameof(Test_AsyncRelayCommandOfT_EnsureExceptionThrown), executeException?.Message); + Assert.AreEqual(nameof(Test_AsyncRelayCommandOfT_EnsureExceptionThrown), executeAsyncException?.Message); + } + + [TestMethod] + public async Task Test_AsyncRelayCommandOfT_EnsureExceptionThrown_GenericExecute() + { + const int delay = 500; + + Exception? executeTException = null; + Exception? executeAsyncException = null; + + AsyncRelayCommand command = new(async delay => + { + await Task.Delay(delay); + + throw new Exception(nameof(Test_AsyncRelayCommandOfT_EnsureExceptionThrown_GenericExecute)); + }); + + try + { + AsyncContext.Run(async () => + { + command.Execute(delay); + + await Task.Delay(delay * 2); + }); + } + catch (Exception e) + { + executeTException = e; + } + + executeAsyncException = await Assert.ThrowsExceptionAsync(() => command.ExecuteAsync(delay)); + + Assert.AreEqual(nameof(Test_AsyncRelayCommandOfT_EnsureExceptionThrown_GenericExecute), executeTException?.Message); + Assert.AreEqual(nameof(Test_AsyncRelayCommandOfT_EnsureExceptionThrown_GenericExecute), executeAsyncException?.Message); + } + [TestMethod] public async Task Test_AsyncRelayCommandOfT_ThrowingTaskBubblesToUnobservedTaskException() { @@ -253,7 +354,33 @@ static async Task TestMethodAsync(Action action) async void TestCallback(Action throwAction, Action completeAction) { - AsyncRelayCommand command = new(s => TestMethodAsync(throwAction)); + AsyncRelayCommand command = new(s => TestMethodAsync(throwAction), AsyncRelayCommandOptions.FlowExceptionsToTaskScheduler); + + command.Execute(null); + + await Task.Delay(200); + + completeAction(); + } + + bool success = await TaskSchedulerTestHelper.IsExceptionBubbledUpToUnobservedTaskExceptionAsync(TestCallback); + + Assert.IsTrue(success); + } + + [TestMethod] + public async Task Test_AsyncRelayCommandOfT_ThrowingTaskBubblesToUnobservedTaskException_Synchronously() + { + static async Task TestMethodAsync(Action action) + { + await Task.CompletedTask; + + action(); + } + + async void TestCallback(Action throwAction, Action completeAction) + { + AsyncRelayCommand command = new(s => TestMethodAsync(throwAction), AsyncRelayCommandOptions.FlowExceptionsToTaskScheduler); command.Execute(null); @@ -271,7 +398,7 @@ public async Task Test_AsyncRelayCommand_ExecuteDoesNotRaiseCanExecuteChanged() { TaskCompletionSource tcs = new(); - AsyncRelayCommand command = new(s => tcs.Task, allowConcurrentExecutions: true); + AsyncRelayCommand command = new(s => tcs.Task, AsyncRelayCommandOptions.AllowConcurrentExecutions); (object? Sender, EventArgs? Args) args = default; @@ -302,7 +429,7 @@ public async Task Test_AsyncRelayCommand_ExecuteWithoutConcurrencyRaisesCanExecu { TaskCompletionSource tcs = new(); - AsyncRelayCommand command = new(s => tcs.Task, allowConcurrentExecutions: false); + AsyncRelayCommand command = new(s => tcs.Task, AsyncRelayCommandOptions.None); (object? Sender, EventArgs? Args) args = default; @@ -333,7 +460,7 @@ public void Test_AsyncRelayCommand_ExecuteDoesNotRaiseCanExecuteChanged_WithCanc { TaskCompletionSource tcs = new(); - AsyncRelayCommand command = new((s, token) => tcs.Task, allowConcurrentExecutions: true); + AsyncRelayCommand command = new((s, token) => tcs.Task, AsyncRelayCommandOptions.AllowConcurrentExecutions); (object? Sender, EventArgs? Args) args = default; @@ -356,7 +483,7 @@ public void Test_AsyncRelayCommand_ExecuteWithoutConcurrencyRaisesCanExecuteChan { TaskCompletionSource tcs = new(); - AsyncRelayCommand command = new((s, token) => tcs.Task, allowConcurrentExecutions: false); + AsyncRelayCommand command = new((s, token) => tcs.Task, AsyncRelayCommandOptions.None); (object? Sender, EventArgs? Args) args = default; diff --git a/tests/CommunityToolkit.Mvvm.UnitTests/Test_RelayCommandAttribute.cs b/tests/CommunityToolkit.Mvvm.UnitTests/Test_RelayCommandAttribute.cs index 3df57ccd4..220ebeafb 100644 --- a/tests/CommunityToolkit.Mvvm.UnitTests/Test_RelayCommandAttribute.cs +++ b/tests/CommunityToolkit.Mvvm.UnitTests/Test_RelayCommandAttribute.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Reflection; using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; @@ -517,6 +518,42 @@ public void Test_RelayCommandAttribute_VerifyNoWarningsForNullableValues() model.TupleWithNullableElementsCommand.Execute((null, null, null, null)); } + [TestMethod] + public void Test_RelayCommandAttribute_VerifyOptions() + { + ModelWithCommandsWithCustomOptions model = new(); + + static void AssertOptions(IAsyncRelayCommand command, AsyncRelayCommandOptions options) + { + AsyncRelayCommandOptions commandOptions = + (AsyncRelayCommandOptions)typeof(AsyncRelayCommand) + .GetField("options", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(command)!; + + Assert.AreEqual(commandOptions, options); + } + + static void AssertOptionsOfT(IAsyncRelayCommand command, AsyncRelayCommandOptions options) + { + AsyncRelayCommandOptions commandOptions = + (AsyncRelayCommandOptions)typeof(AsyncRelayCommand) + .GetField("options", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(command)!; + + Assert.AreEqual(commandOptions, options); + } + + AssertOptions(model.DefaultCommand, AsyncRelayCommandOptions.None); + AssertOptions(model.AllowConcurrentExecutionsCommand, AsyncRelayCommandOptions.AllowConcurrentExecutions); + AssertOptions(model.FlowExceptionsToTaskSchedulerCommand, AsyncRelayCommandOptions.FlowExceptionsToTaskScheduler); + AssertOptions(model.AllowConcurrentExecutionsAndFlowExceptionsToTaskSchedulerCommand, AsyncRelayCommandOptions.AllowConcurrentExecutions | AsyncRelayCommandOptions.FlowExceptionsToTaskScheduler); + + AssertOptionsOfT(model.OfTDefaultCommand, AsyncRelayCommandOptions.None); + AssertOptionsOfT(model.OfTAndAllowConcurrentExecutionsCommand, AsyncRelayCommandOptions.AllowConcurrentExecutions); + AssertOptionsOfT(model.OfTAndFlowExceptionsToTaskSchedulerCommand, AsyncRelayCommandOptions.FlowExceptionsToTaskScheduler); + AssertOptionsOfT(model.OfTAndAllowConcurrentExecutionsAndFlowExceptionsToTaskSchedulerCommand, AsyncRelayCommandOptions.AllowConcurrentExecutions | AsyncRelayCommandOptions.FlowExceptionsToTaskScheduler); + } + #region Region public class Region { @@ -906,4 +943,55 @@ private void TupleWithNullableElements((DateTime? date, string? message, bool? s { } } + + partial class ModelWithCommandsWithCustomOptions + { + [RelayCommand] + private Task Default() + { + return Task.CompletedTask; + } + + [RelayCommand] + private Task OfTDefault(string obj) + { + return Task.CompletedTask; + } + + [RelayCommand(AllowConcurrentExecutions = true)] + private Task AllowConcurrentExecutions() + { + return Task.CompletedTask; + } + + [RelayCommand(AllowConcurrentExecutions = true)] + private Task OfTAndAllowConcurrentExecutions(string obj) + { + return Task.CompletedTask; + } + + [RelayCommand(FlowExceptionsToTaskScheduler = true)] + private Task FlowExceptionsToTaskScheduler() + { + return Task.CompletedTask; + } + + [RelayCommand(FlowExceptionsToTaskScheduler = true)] + private Task OfTAndFlowExceptionsToTaskScheduler(string obj) + { + return Task.CompletedTask; + } + + [RelayCommand(AllowConcurrentExecutions = true, FlowExceptionsToTaskScheduler = true)] + private Task AllowConcurrentExecutionsAndFlowExceptionsToTaskScheduler() + { + return Task.CompletedTask; + } + + [RelayCommand(AllowConcurrentExecutions = true, FlowExceptionsToTaskScheduler = true)] + private Task OfTAndAllowConcurrentExecutionsAndFlowExceptionsToTaskScheduler(string obj) + { + return Task.CompletedTask; + } + } }