Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions CommunityToolkit.Mvvm/Input/AsyncRelayCommand{T}.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>.CanExecute(object?) for more info
if (parameter is null && default(T) is not null)
{
return false;
}

return CanExecute((T?)parameter);
if (!RelayCommand<T>.TryGetCommandArgument(parameter, out T? result))
{
RelayCommand<T>.ThrowArgumentExceptionForInvalidCommandArgument(parameter);
}

return CanExecute(result);
}

/// <inheritdoc/>
Expand All @@ -286,7 +291,12 @@ public void Execute(T? parameter)
/// <inheritdoc/>
public void Execute(object? parameter)
{
Execute((T?)parameter);
if (!RelayCommand<T>.TryGetCommandArgument(parameter, out T? result))
{
RelayCommand<T>.ThrowArgumentExceptionForInvalidCommandArgument(parameter);
}

Execute(result);
}

/// <inheritdoc/>
Expand Down Expand Up @@ -322,7 +332,12 @@ public Task ExecuteAsync(T? parameter)
/// <inheritdoc/>
public Task ExecuteAsync(object? parameter)
{
return ExecuteAsync((T?)parameter);
if (!RelayCommand<T>.TryGetCommandArgument(parameter, out T? result))
{
RelayCommand<T>.ThrowArgumentExceptionForInvalidCommandArgument(parameter);
}

return ExecuteAsync(result);
}

/// <inheritdoc/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ public interface IAsyncRelayCommand : IRelayCommand, INotifyPropertyChanged
/// </summary>
/// <param name="parameter">The input parameter.</param>
/// <returns>The <see cref="Task"/> representing the async operation being executed.</returns>
/// <exception cref="System.ArgumentException">Thrown if <paramref name="parameter"/> is incompatible with the underlying command implementation.</exception>
Task ExecuteAsync(object? parameter);

/// <summary>
Expand Down
75 changes: 71 additions & 4 deletions CommunityToolkit.Mvvm/Input/RelayCommand{T}.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -81,13 +82,19 @@ public bool CanExecute(T? parameter)
/// <inheritdoc/>
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);
}

/// <inheritdoc/>
Expand All @@ -100,6 +107,66 @@ public void Execute(T? parameter)
/// <inheritdoc/>
public void Execute(object? parameter)
{
Execute((T?)parameter);
if (!TryGetCommandArgument(parameter, out T? result))
{
ThrowArgumentExceptionForInvalidCommandArgument(parameter);
}

Execute(result);
}

/// <summary>
/// Tries to get a command argument of compatible type <typeparamref name="T"/> from an input <see cref="object"/>.
/// </summary>
/// <param name="parameter">The input parameter.</param>
/// <param name="result">The resulting <typeparamref name="T"/> value, if any.</param>
/// <returns>Whether or not a compatible command argument could be retrieved.</returns>
[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;
}

/// <summary>
/// Throws an <see cref="ArgumentException"/> if an invalid command argument is used.
/// </summary>
/// <param name="parameter">The input parameter.</param>
/// <exception cref="ArgumentException">Thrown with an error message to give info on the invalid parameter.</exception>
[DoesNotReturn]
internal static void ThrowArgumentExceptionForInvalidCommandArgument(object? parameter)
{
[MethodImpl(MethodImplOptions.NoInlining)]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is NoInlining intended here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah it's to make sure the JIT doesn't mess up handling the caller as a throw helper.

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);
}
}
38 changes: 38 additions & 0 deletions tests/CommunityToolkit.Mvvm.UnitTests/Helpers/ExceptionHelper.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// A helper class to validate scenarios related to <see cref="Exception"/>-s.
/// </summary>
internal static class ExceptionHelper
{
/// <summary>
/// Asserts that a given action throws an <see cref="ArgumentException"/> with a specific parameter name.
/// </summary>
/// <param name="action">The input <see cref="Action"/> to invoke.</param>
/// <param name="parameterName">The expected parameter name.</param>
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);
}
}
52 changes: 46 additions & 6 deletions tests/CommunityToolkit.Mvvm.UnitTests/Test_AsyncRelayCommand{T}.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,11 @@ public async Task Test_AsyncRelayCommandOfT_AlwaysEnabled()

Assert.AreEqual(ticks, 2);

_ = Assert.ThrowsException<InvalidCastException>(() => 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]
Expand All @@ -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]
Expand All @@ -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;

Expand All @@ -125,18 +141,38 @@ public void Test_AsyncRelayCommandOfT_NullWithValueType()
return Task.CompletedTask;
});

// Special case
Assert.IsFalse(command.CanExecute(null));
_ = Assert.ThrowsException<NullReferenceException>(() => command.Execute(null));

command = new AsyncRelayCommand<int>(
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<int>? command = new(
i =>
{
n = i;
return Task.CompletedTask;
}, i => i > 0);

// Special case
Assert.IsFalse(command.CanExecute(null));
_ = Assert.ThrowsException<NullReferenceException>(() => 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]
Expand Down Expand Up @@ -239,7 +275,11 @@ private static async Task Test_AsyncRelayCommandOfT_AllowConcurrentExecutions_Te

Assert.IsFalse(command.IsRunning);

_ = Assert.ThrowsException<InvalidCastException>(() => 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]
Expand Down
41 changes: 35 additions & 6 deletions tests/CommunityToolkit.Mvvm.UnitTests/Test_RelayCommand{T}.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

using System;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.UnitTests.Helpers;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace CommunityToolkit.Mvvm.UnitTests;
Expand All @@ -21,7 +22,11 @@ public void Test_RelayCommandOfT_AlwaysEnabled()
Assert.IsTrue(command.CanExecute("Text"));
Assert.IsTrue(command.CanExecute(null));

_ = Assert.ThrowsException<InvalidCastException>(() => 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;

Expand Down Expand Up @@ -51,7 +56,11 @@ public void Test_RelayCommand_WithCanExecuteFunction()
Assert.IsTrue(command.CanExecute("Text"));
Assert.IsFalse(command.CanExecute(null));

_ = Assert.ThrowsException<InvalidCastException>(() => 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");

Expand All @@ -64,18 +73,38 @@ public void Test_RelayCommand_WithCanExecuteFunction()
}

[TestMethod]
public void Test_RelayCommand_NullWithValueType()
public void Test_RelayCommand_InvalidArgumentWithValueType()
{
int n = 0;

RelayCommand<int>? command = new(i => n = i);

// Special case
Assert.IsFalse(command.CanExecute(null));
_ = Assert.ThrowsException<NullReferenceException>(() => command.Execute(null));

command = new RelayCommand<int>(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<int>? command = new(i => n = i, i => i > 0);

// Special case
Assert.IsFalse(command.CanExecute(null));
_ = Assert.ThrowsException<NullReferenceException>(() => 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");
}
}