diff --git a/CommunityToolkit.Mvvm/Input/AsyncRelayCommand{T}.cs b/CommunityToolkit.Mvvm/Input/AsyncRelayCommand{T}.cs index d3bbd7b93..8e914dc04 100644 --- a/CommunityToolkit.Mvvm/Input/AsyncRelayCommand{T}.cs +++ b/CommunityToolkit.Mvvm/Input/AsyncRelayCommand{T}.cs @@ -262,13 +262,18 @@ public bool CanExecute(T? parameter) [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool CanExecute(object? parameter) { - if (default(T) is not null && - parameter is null) + // Special case, see RelayCommand.CanExecute(object?) for more info + if (parameter is null && default(T) is not null) { return false; } - return CanExecute((T?)parameter); + if (!RelayCommand.TryGetCommandArgument(parameter, out T? result)) + { + RelayCommand.ThrowArgumentExceptionForInvalidCommandArgument(parameter); + } + + return CanExecute(result); } /// @@ -286,7 +291,12 @@ public void Execute(T? parameter) /// public void Execute(object? parameter) { - Execute((T?)parameter); + if (!RelayCommand.TryGetCommandArgument(parameter, out T? result)) + { + RelayCommand.ThrowArgumentExceptionForInvalidCommandArgument(parameter); + } + + Execute(result); } /// @@ -322,7 +332,12 @@ public Task ExecuteAsync(T? parameter) /// public Task ExecuteAsync(object? parameter) { - return ExecuteAsync((T?)parameter); + if (!RelayCommand.TryGetCommandArgument(parameter, out T? result)) + { + RelayCommand.ThrowArgumentExceptionForInvalidCommandArgument(parameter); + } + + return ExecuteAsync(result); } /// diff --git a/CommunityToolkit.Mvvm/Input/Interfaces/IAsyncRelayCommand.cs b/CommunityToolkit.Mvvm/Input/Interfaces/IAsyncRelayCommand.cs index 693a3fe5a..c8897aef3 100644 --- a/CommunityToolkit.Mvvm/Input/Interfaces/IAsyncRelayCommand.cs +++ b/CommunityToolkit.Mvvm/Input/Interfaces/IAsyncRelayCommand.cs @@ -63,6 +63,7 @@ public interface IAsyncRelayCommand : IRelayCommand, INotifyPropertyChanged /// /// The input parameter. /// The representing the async operation being executed. + /// Thrown if is incompatible with the underlying command implementation. Task ExecuteAsync(object? parameter); /// diff --git a/CommunityToolkit.Mvvm/Input/RelayCommand{T}.cs b/CommunityToolkit.Mvvm/Input/RelayCommand{T}.cs index 42fa3f34b..66ca947a5 100644 --- a/CommunityToolkit.Mvvm/Input/RelayCommand{T}.cs +++ b/CommunityToolkit.Mvvm/Input/RelayCommand{T}.cs @@ -6,6 +6,7 @@ // more info in ThirdPartyNotices.txt in the root of the project. using System; +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; namespace CommunityToolkit.Mvvm.Input; @@ -81,13 +82,19 @@ public bool CanExecute(T? parameter) /// public bool CanExecute(object? parameter) { - if (default(T) is not null && - parameter is null) + // Special case a null value for a value type argument type. + // This ensures that no exceptions are thrown during initialization. + if (parameter is null && default(T) is not null) { return false; } - return CanExecute((T?)parameter); + if (!TryGetCommandArgument(parameter, out T? result)) + { + ThrowArgumentExceptionForInvalidCommandArgument(parameter); + } + + return CanExecute(result); } /// @@ -100,6 +107,66 @@ public void Execute(T? parameter) /// public void Execute(object? parameter) { - Execute((T?)parameter); + if (!TryGetCommandArgument(parameter, out T? result)) + { + ThrowArgumentExceptionForInvalidCommandArgument(parameter); + } + + Execute(result); + } + + /// + /// Tries to get a command argument of compatible type from an input . + /// + /// The input parameter. + /// The resulting value, if any. + /// Whether or not a compatible command argument could be retrieved. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static bool TryGetCommandArgument(object? parameter, out T? result) + { + // If the argument is null and the default value of T is also null, then the + // argument is valid. T might be a reference type or a nullable value type. + if (parameter is null && default(T) is null) + { + result = default; + + return true; + } + + // Check if the argument is a T value, so either an instance of a type or a derived + // type of T is a reference type, an interface implementation if T is an interface, + // or a boxed value type in case T was a value type. + if (parameter is T argument) + { + result = argument; + + return true; + } + + result = default; + + return false; + } + + /// + /// Throws an if an invalid command argument is used. + /// + /// The input parameter. + /// Thrown with an error message to give info on the invalid parameter. + [DoesNotReturn] + internal static void ThrowArgumentExceptionForInvalidCommandArgument(object? parameter) + { + [MethodImpl(MethodImplOptions.NoInlining)] + static Exception GetException(object? parameter) + { + if (parameter is null) + { + return new ArgumentException($"Parameter \"{nameof(parameter)}\" (object) must not be null, as the command type requires an argument of type {typeof(T)}.", nameof(parameter)); + } + + return new ArgumentException($"Parameter \"{nameof(parameter)}\" (object) cannot be of type {parameter.GetType()}, as the command type requires an argument of type {typeof(T)}.", nameof(parameter)); + } + + throw GetException(parameter); } } diff --git a/tests/CommunityToolkit.Mvvm.UnitTests/Helpers/ExceptionHelper.cs b/tests/CommunityToolkit.Mvvm.UnitTests/Helpers/ExceptionHelper.cs new file mode 100644 index 000000000..03b7713cb --- /dev/null +++ b/tests/CommunityToolkit.Mvvm.UnitTests/Helpers/ExceptionHelper.cs @@ -0,0 +1,38 @@ +// 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; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace CommunityToolkit.Mvvm.UnitTests.Helpers; + +/// +/// A helper class to validate scenarios related to -s. +/// +internal static class ExceptionHelper +{ + /// + /// Asserts that a given action throws an with a specific parameter name. + /// + /// The input to invoke. + /// The expected parameter name. + public static void ThrowsArgumentExceptionWithParameterName(Action action, string parameterName) + { + bool success = false; + + try + { + action(); + } + catch (Exception e) + { + Assert.IsTrue(e.GetType() == typeof(ArgumentException)); + Assert.AreEqual(parameterName, ((ArgumentException)e).ParamName); + + success = true; + } + + Assert.IsTrue(success); + } +} diff --git a/tests/CommunityToolkit.Mvvm.UnitTests/Test_AsyncRelayCommand{T}.cs b/tests/CommunityToolkit.Mvvm.UnitTests/Test_AsyncRelayCommand{T}.cs index 18e2e42a0..481d72113 100644 --- a/tests/CommunityToolkit.Mvvm.UnitTests/Test_AsyncRelayCommand{T}.cs +++ b/tests/CommunityToolkit.Mvvm.UnitTests/Test_AsyncRelayCommand{T}.cs @@ -62,7 +62,11 @@ public async Task Test_AsyncRelayCommandOfT_AlwaysEnabled() Assert.AreEqual(ticks, 2); - _ = Assert.ThrowsException(() => command.Execute(new object())); + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.CanExecute(new object()), "parameter"); + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.CanExecute(42), "parameter"); + + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.Execute(new object()), "parameter"); + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.Execute(42), "parameter"); } [TestMethod] @@ -87,6 +91,12 @@ public void Test_AsyncRelayCommandOfT_WithCanExecuteFunctionTrue() command.Execute("2"); Assert.AreEqual(ticks, 2); + + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.CanExecute(new object()), "parameter"); + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.CanExecute(42), "parameter"); + + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.Execute(new object()), "parameter"); + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.Execute(42), "parameter"); } [TestMethod] @@ -112,10 +122,16 @@ public void Test_AsyncRelayCommandOfT_WithCanExecuteFunctionFalse() command.Execute("42"); Assert.AreEqual(ticks, 42); + + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.CanExecute(new object()), "parameter"); + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.CanExecute(42), "parameter"); + + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.Execute(new object()), "parameter"); + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.Execute(42), "parameter"); } [TestMethod] - public void Test_AsyncRelayCommandOfT_NullWithValueType() + public void Test_AsyncRelayCommandOfT_InvalidArgumentWithValueType() { int n = 0; @@ -125,18 +141,38 @@ public void Test_AsyncRelayCommandOfT_NullWithValueType() return Task.CompletedTask; }); + // Special case Assert.IsFalse(command.CanExecute(null)); - _ = Assert.ThrowsException(() => command.Execute(null)); - command = new AsyncRelayCommand( + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.CanExecute("Hello"), "parameter"); + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.CanExecute(3.14f), "parameter"); + + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.Execute(null), "parameter"); + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.Execute("Hello"), "parameter"); + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.Execute(3.14f), "parameter"); + } + + [TestMethod] + public void Test_AsyncRelayCommandOfT_InvalidArgumentWithValueType_WithCanExecute() + { + int n = 0; + + AsyncRelayCommand? command = new( i => { n = i; return Task.CompletedTask; }, i => i > 0); + // Special case Assert.IsFalse(command.CanExecute(null)); - _ = Assert.ThrowsException(() => command.Execute(null)); + + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.CanExecute("Hello"), "parameter"); + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.CanExecute(3.14f), "parameter"); + + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.Execute(null), "parameter"); + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.Execute("Hello"), "parameter"); + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.Execute(3.14f), "parameter"); } [TestMethod] @@ -239,7 +275,11 @@ private static async Task Test_AsyncRelayCommandOfT_AllowConcurrentExecutions_Te Assert.IsFalse(command.IsRunning); - _ = Assert.ThrowsException(() => command.Execute(new object())); + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.CanExecute(new object()), "parameter"); + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.CanExecute(3.14f), "parameter"); + + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.Execute(new object()), "parameter"); + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.Execute(3.14f), "parameter"); } [TestMethod] diff --git a/tests/CommunityToolkit.Mvvm.UnitTests/Test_RelayCommand{T}.cs b/tests/CommunityToolkit.Mvvm.UnitTests/Test_RelayCommand{T}.cs index a787e93a1..e4064bc40 100644 --- a/tests/CommunityToolkit.Mvvm.UnitTests/Test_RelayCommand{T}.cs +++ b/tests/CommunityToolkit.Mvvm.UnitTests/Test_RelayCommand{T}.cs @@ -4,6 +4,7 @@ using System; using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.UnitTests.Helpers; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace CommunityToolkit.Mvvm.UnitTests; @@ -21,7 +22,11 @@ public void Test_RelayCommandOfT_AlwaysEnabled() Assert.IsTrue(command.CanExecute("Text")); Assert.IsTrue(command.CanExecute(null)); - _ = Assert.ThrowsException(() => command.CanExecute(new object())); + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.CanExecute(new object()), "parameter"); + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.CanExecute(42), "parameter"); + + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.Execute(new object()), "parameter"); + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.Execute(42), "parameter"); (object?, EventArgs?) args = default; @@ -51,7 +56,11 @@ public void Test_RelayCommand_WithCanExecuteFunction() Assert.IsTrue(command.CanExecute("Text")); Assert.IsFalse(command.CanExecute(null)); - _ = Assert.ThrowsException(() => command.CanExecute(new object())); + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.CanExecute(new object()), "parameter"); + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.CanExecute(42), "parameter"); + + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.Execute(new object()), "parameter"); + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.Execute(42), "parameter"); command.Execute((object)"Hello"); @@ -64,18 +73,38 @@ public void Test_RelayCommand_WithCanExecuteFunction() } [TestMethod] - public void Test_RelayCommand_NullWithValueType() + public void Test_RelayCommand_InvalidArgumentWithValueType() { int n = 0; RelayCommand? command = new(i => n = i); + // Special case Assert.IsFalse(command.CanExecute(null)); - _ = Assert.ThrowsException(() => command.Execute(null)); - command = new RelayCommand(i => n = i, i => i > 0); + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.CanExecute("Hello"), "parameter"); + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.CanExecute(3.14f), "parameter"); + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.Execute(null), "parameter"); + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.Execute("Hello"), "parameter"); + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.Execute(3.14f), "parameter"); + } + + [TestMethod] + public void Test_RelayCommand_InvalidArgumentWithValueType_WithCanExecute() + { + int n = 0; + + RelayCommand? command = new(i => n = i, i => i > 0); + + // Special case Assert.IsFalse(command.CanExecute(null)); - _ = Assert.ThrowsException(() => command.Execute(null)); + + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.CanExecute("Hello"), "parameter"); + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.CanExecute(3.14f), "parameter"); + + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.Execute(null), "parameter"); + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.Execute("Hello"), "parameter"); + ExceptionHelper.ThrowsArgumentExceptionWithParameterName(() => command.Execute(3.14f), "parameter"); } }