From aa78337652c2dd9bf4be316f2b08258bdfe17e7a Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Thu, 23 Jul 2026 20:47:59 +0200 Subject: [PATCH 1/2] docs: add plan for type assignability assertion --- ai-plans/0167-type-assignability.md | 44 +++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 ai-plans/0167-type-assignability.md diff --git a/ai-plans/0167-type-assignability.md b/ai-plans/0167-type-assignability.md new file mode 100644 index 0000000..ed79059 --- /dev/null +++ b/ai-plans/0167-type-assignability.md @@ -0,0 +1,44 @@ +# Type-Assignability Assertion + +## Rationale + +Parent issue #162 (point 3) identifies six BrilliantMessaging guards that reject a `Type` unless it can be assigned to a required base type or interface. The existing type-relation predicates do not provide a direct throwing assertion, so callers must translate the relationship into a boolean condition and choose the correct comparison direction themselves. + +Add `MustBeAssignableTo` with the exact semantics of `Type.IsAssignableFrom`, expressed in the fluent direction `candidateType.MustBeAssignableTo(requiredType)`, and keep it available on `netstandard2.0`. Do not add `MustImplement`: the assignability guard already covers interfaces, while a second API would duplicate behavior and risk suggesting the open-generic equivalence semantics of the existing `Implements` predicate. + +## Acceptance Criteria + +- [ ] `candidateType.MustBeAssignableTo(requiredType, parameterName, message)` returns the original candidate `Type` when `requiredType.IsAssignableFrom(candidateType)` is true and throws `ArgumentException` when it is false, identically on .NET Standard 2.0, .NET Standard 2.1, and .NET 10; the exception exposes the candidate parameter name and honors an optional custom message. +- [ ] The default overload throws `ArgumentNullException` when either the candidate or required type is null, attributing a null candidate to the caller-captured parameter name and a null required type to `requiredType`; no new public exception type is introduced. +- [ ] A custom-exception-factory overload accepts `Func`, passes the original candidate and required types to the factory, invokes it only when either input is null or the assignability check fails, and a null factory on a failing check throws `ArgumentNullException` via the existing `Throw.CustomException` convention. +- [ ] Automated tests cover identity, direct and indirect base-class relationships, interface implementation, value types, variant generics, and representative open-generic BCL semantics; reversed and unrelated failure cases; both null inputs; return-value identity; parameter-name and custom-message propagation; the factory arguments and concrete exception; no factory invocation on success; null-factory behavior; and nullable-flow analysis. +- [ ] No `MustImplement` convenience assertion is added; interface assignability is documented and tested through `MustBeAssignableTo`. +- [ ] The source-export whitelist catalog and committed settings contain `MustBeAssignableTo`, and focused source-export tests cover retention of the guard, its throw helper, and the two-argument custom-exception helper as well as trimming of the exception-factory overload when configured. +- [ ] The committed .NET Standard 2.0 single-file distribution is regenerated with the assertion and validates for both supported source-export targets. +- [ ] The type-relation assertion documentation lists `MustBeAssignableTo`, and the package release notes mention the new guard. +- [ ] The complete solution restores and builds without warnings in Release configuration, and all automated tests pass on the pinned SDK. + +## Technical Details + +Add `Check.MustBeAssignableTo.cs` using the conventions of the other value-returning guards (aggressive inlining, JetBrains contract annotations, nullable flow annotations, caller-argument-expression capture, and XML documentation). The exact public shape is: + +```csharp +public static Type MustBeAssignableTo( + [NotNull] [ValidatedNotNull] this Type? parameter, + [NotNull] [ValidatedNotNull] Type? requiredType, + [CallerArgumentExpression("parameter")] string? parameterName = null, + string? message = null +); + +public static Type MustBeAssignableTo( + [NotNull] [ValidatedNotNull] this Type? parameter, + [NotNull] [ValidatedNotNull] Type? requiredType, + Func exceptionFactory +); +``` + +The check must evaluate `requiredType.IsAssignableFrom(parameter)`. This makes the direction explicit: after success, a value whose runtime type is `parameter` can be stored in a variable declared as `requiredType`. Use the BCL behavior verbatim, including equality, inheritance, interface implementation, array compatibility, and generic variance; do not route through `InheritsFrom`, `IsOrInheritsFrom`, or `IsEquivalentTypeTo`, whose constructed/open-generic handling is intentionally different. No generic `MustBeAssignableTo` overload is included because the motivating scenario supplies the required type at runtime. + +Route default relation failures through a non-returning `Throw.MustBeAssignableTo` helper that constructs `ArgumentException`. Its default message should state both types and the assignment direction without implying that the `Type` object itself failed a CLR cast. The default overload validates both type arguments before evaluating assignability; the factory overload treats either null as a validation failure and passes both original values to the factory. No trimming annotations or microbenchmarks are required because `Type.IsAssignableFrom` does not enumerate reflected members and this guard is a thin wrapper around the BCL operation. + +Register `MustBeAssignableTo` in `AssertionWhitelist` and `settings.json`, add focused `SourceFileMergerWhitelistTests`, add `MustBeAssignableToTests` under the type assertions, extend the value-returning assertion coverage in `Issue72NotNullAttributeTests`, update the type-relation table in `docs/assertion-overview.md`, and regenerate `Light.GuardClauses.SingleFile.cs` through the source-export tool's committed settings. From 18daf30158cc1e854ab255bbec53d6ac453c366b Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Thu, 23 Jul 2026 21:02:19 +0200 Subject: [PATCH 2/2] feat: add type assignability guard --- Light.GuardClauses.SingleFile.cs | 64 +++++++ ai-plans/0167-type-assignability.md | 18 +- docs/assertion-overview.md | 5 +- src/Directory.Build.props | 2 +- .../Check.MustBeAssignableTo.cs | 79 ++++++++ .../Throw.MustBeAssignableTo.cs | 26 +++ .../SourceFileMergerWhitelistTests.cs | 45 +++++ .../Issues/Issue72NotNullAttributeTests.cs | 28 +++ .../TypeAssertions/MustBeAssignableToTests.cs | 170 ++++++++++++++++++ .../AssertionWhitelist.cs | 2 + .../settings.json | 1 + 11 files changed, 429 insertions(+), 11 deletions(-) create mode 100644 src/Light.GuardClauses/Check.MustBeAssignableTo.cs create mode 100644 src/Light.GuardClauses/ExceptionFactory/Throw.MustBeAssignableTo.cs create mode 100644 tests/Light.GuardClauses.Tests/TypeAssertions/MustBeAssignableToTests.cs diff --git a/Light.GuardClauses.SingleFile.cs b/Light.GuardClauses.SingleFile.cs index 3976649..fedfcc0 100644 --- a/Light.GuardClauses.SingleFile.cs +++ b/Light.GuardClauses.SingleFile.cs @@ -2083,6 +2083,63 @@ public static ReadOnlyMemory MustBeAscii(this ReadOnlyMemory paramet return parameter; } + /// + /// Ensures that values of can be assigned to variables of + /// , or otherwise throws an . + /// + /// The candidate type to be checked. + /// The type to which values of the candidate type must be assignable. + /// The name of the parameter (optional). + /// The message that will be passed to the resulting exception (optional). + /// The original candidate type. + /// + /// Thrown when values of cannot be assigned to variables of + /// . + /// + /// + /// Thrown when or is null. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("parameter:null => halt; parameter:notnull => notnull; requiredType:null => halt")] + public static Type MustBeAssignableTo([NotNull][ValidatedNotNull] this Type? parameter, [NotNull][ValidatedNotNull] Type? requiredType, [CallerArgumentExpression("parameter")] string? parameterName = null, string? message = null) + { + parameter.MustNotBeNull(parameterName, message); + requiredType.MustNotBeNull(nameof(requiredType), message); + if (!requiredType.IsAssignableFrom(parameter)) + { + Throw.MustBeAssignableTo(parameter, requiredType, parameterName, message); + } + + return parameter; + } + + /// + /// Ensures that values of can be assigned to variables of + /// , or otherwise throws your custom exception. + /// + /// The candidate type to be checked. + /// The type to which values of the candidate type must be assignable. + /// + /// The delegate that creates your custom exception. The original candidate and required types are passed to this + /// delegate. + /// + /// The original candidate type. + /// + /// Your custom exception thrown when either type is null or the candidate type is not assignable to the required + /// type. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("parameter:null => halt; parameter:notnull => notnull; requiredType:null => halt; exceptionFactory:null => halt")] + public static Type MustBeAssignableTo([NotNull][ValidatedNotNull] this Type? parameter, [NotNull][ValidatedNotNull] Type? requiredType, Func exceptionFactory) + { + if (parameter is null || requiredType is null || !requiredType.IsAssignableFrom(parameter)) + { + Throw.CustomException(exceptionFactory, parameter, requiredType); + } + + return parameter; + } + /// /// Ensures that the string is standard Base64 with valid padding. Space, tab, carriage return, and line feed are ignored. /// Empty and whitespace-only strings are valid. @@ -11800,6 +11857,13 @@ public static void InvalidMinimumImmutableArrayLength(ImmutableArray param [DoesNotReturn] public static void MustBeApproximately(T parameter, T other, T tolerance, [CallerArgumentExpression("parameter")] string? parameterName = null, string? message = null) => throw new ArgumentOutOfRangeException(parameterName, message ?? $"{parameterName ?? "The value"} must be approximately equal to {other} with a tolerance of {tolerance}, but it actually is {parameter}."); /// + /// Throws the default indicating that values of the candidate type cannot be + /// assigned to variables of the required type, using the optional parameter name and message. + /// + [ContractAnnotation("=> halt")] + [DoesNotReturn] + public static void MustBeAssignableTo(Type parameter, Type requiredType, string? parameterName = null, string? message = null) => throw new ArgumentException(message ?? $"Values of type \"{parameter}\" must be assignable to variables of type \"{requiredType}\", but they are not.", parameterName); + /// /// Throws the default indicating that a comparable value must be greater /// than the given boundary value, using the optional parameter name and message. /// diff --git a/ai-plans/0167-type-assignability.md b/ai-plans/0167-type-assignability.md index ed79059..48b0d55 100644 --- a/ai-plans/0167-type-assignability.md +++ b/ai-plans/0167-type-assignability.md @@ -8,15 +8,15 @@ Add `MustBeAssignableTo` with the exact semantics of `Type.IsAssignableFrom`, ex ## Acceptance Criteria -- [ ] `candidateType.MustBeAssignableTo(requiredType, parameterName, message)` returns the original candidate `Type` when `requiredType.IsAssignableFrom(candidateType)` is true and throws `ArgumentException` when it is false, identically on .NET Standard 2.0, .NET Standard 2.1, and .NET 10; the exception exposes the candidate parameter name and honors an optional custom message. -- [ ] The default overload throws `ArgumentNullException` when either the candidate or required type is null, attributing a null candidate to the caller-captured parameter name and a null required type to `requiredType`; no new public exception type is introduced. -- [ ] A custom-exception-factory overload accepts `Func`, passes the original candidate and required types to the factory, invokes it only when either input is null or the assignability check fails, and a null factory on a failing check throws `ArgumentNullException` via the existing `Throw.CustomException` convention. -- [ ] Automated tests cover identity, direct and indirect base-class relationships, interface implementation, value types, variant generics, and representative open-generic BCL semantics; reversed and unrelated failure cases; both null inputs; return-value identity; parameter-name and custom-message propagation; the factory arguments and concrete exception; no factory invocation on success; null-factory behavior; and nullable-flow analysis. -- [ ] No `MustImplement` convenience assertion is added; interface assignability is documented and tested through `MustBeAssignableTo`. -- [ ] The source-export whitelist catalog and committed settings contain `MustBeAssignableTo`, and focused source-export tests cover retention of the guard, its throw helper, and the two-argument custom-exception helper as well as trimming of the exception-factory overload when configured. -- [ ] The committed .NET Standard 2.0 single-file distribution is regenerated with the assertion and validates for both supported source-export targets. -- [ ] The type-relation assertion documentation lists `MustBeAssignableTo`, and the package release notes mention the new guard. -- [ ] The complete solution restores and builds without warnings in Release configuration, and all automated tests pass on the pinned SDK. +- [x] `candidateType.MustBeAssignableTo(requiredType, parameterName, message)` returns the original candidate `Type` when `requiredType.IsAssignableFrom(candidateType)` is true and throws `ArgumentException` when it is false, identically on .NET Standard 2.0, .NET Standard 2.1, and .NET 10; the exception exposes the candidate parameter name and honors an optional custom message. +- [x] The default overload throws `ArgumentNullException` when either the candidate or required type is null, attributing a null candidate to the caller-captured parameter name and a null required type to `requiredType`; no new public exception type is introduced. +- [x] A custom-exception-factory overload accepts `Func`, passes the original candidate and required types to the factory, invokes it only when either input is null or the assignability check fails, and a null factory on a failing check throws `ArgumentNullException` via the existing `Throw.CustomException` convention. +- [x] Automated tests cover identity, direct and indirect base-class relationships, interface implementation, value types, variant generics, and representative open-generic BCL semantics; reversed and unrelated failure cases; both null inputs; return-value identity; parameter-name and custom-message propagation; the factory arguments and concrete exception; no factory invocation on success; null-factory behavior; and nullable-flow analysis. +- [x] No `MustImplement` convenience assertion is added; interface assignability is documented and tested through `MustBeAssignableTo`. +- [x] The source-export whitelist catalog and committed settings contain `MustBeAssignableTo`, and focused source-export tests cover retention of the guard, its throw helper, and the two-argument custom-exception helper as well as trimming of the exception-factory overload when configured. +- [x] The committed .NET Standard 2.0 single-file distribution is regenerated with the assertion and validates for both supported source-export targets. +- [x] The type-relation assertion documentation lists `MustBeAssignableTo`, and the package release notes mention the new guard. +- [x] The complete solution restores and builds without warnings in Release configuration, and all automated tests pass on the pinned SDK. ## Technical Details diff --git a/docs/assertion-overview.md b/docs/assertion-overview.md index 4a66510..b79af37 100644 --- a/docs/assertion-overview.md +++ b/docs/assertion-overview.md @@ -169,13 +169,16 @@ while preserving its concrete stream type, and all three guards support custom m | Assertion | Behavior | | --- | --- | +| `MustBeAssignableTo` | Requires CLR assignability from a candidate type to a required base type or interface | | `IsEquivalentTypeTo` | Treats equal types and constructed-generic/definition pairs as equivalent | | `Implements`, `IsOrImplements` | Test interface implementation, optionally allowing equality | | `DerivesFrom`, `IsOrDerivesFrom` | Test base-class derivation, optionally allowing equality | | `InheritsFrom`, `IsOrInheritsFrom` | Test derivation or interface implementation, optionally allowing equality | | `IsOpenConstructedGenericType` | Tests for a constructed generic type that still has open parameters | -The relation methods provide comparer overloads where applicable. +`MustBeAssignableTo` uses `requiredType.IsAssignableFrom(candidateType)` directly, so it covers interface +assignability, generic variance, and the BCL's open-generic behavior. The other relation methods provide comparer +overloads where applicable. ## URI assertions diff --git a/src/Directory.Build.props b/src/Directory.Build.props index ac44bf7..f84f8ec 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -20,7 +20,7 @@ Light.GuardClauses 15.1.0 -------------------------------- - - new assertions: MustBeUri, ObjectDisposed + - new assertions: MustBeAssignableTo, MustBeUri, ObjectDisposed diff --git a/src/Light.GuardClauses/Check.MustBeAssignableTo.cs b/src/Light.GuardClauses/Check.MustBeAssignableTo.cs new file mode 100644 index 0000000..26513f2 --- /dev/null +++ b/src/Light.GuardClauses/Check.MustBeAssignableTo.cs @@ -0,0 +1,79 @@ +using System; +using System.Runtime.CompilerServices; +using JetBrains.Annotations; +using Light.GuardClauses.ExceptionFactory; +using NotNullAttribute = System.Diagnostics.CodeAnalysis.NotNullAttribute; + +namespace Light.GuardClauses; + +public static partial class Check +{ + /// + /// Ensures that values of can be assigned to variables of + /// , or otherwise throws an . + /// + /// The candidate type to be checked. + /// The type to which values of the candidate type must be assignable. + /// The name of the parameter (optional). + /// The message that will be passed to the resulting exception (optional). + /// The original candidate type. + /// + /// Thrown when values of cannot be assigned to variables of + /// . + /// + /// + /// Thrown when or is null. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("parameter:null => halt; parameter:notnull => notnull; requiredType:null => halt")] + public static Type MustBeAssignableTo( + [NotNull] [ValidatedNotNull] this Type? parameter, + [NotNull] [ValidatedNotNull] Type? requiredType, + [CallerArgumentExpression("parameter")] string? parameterName = null, + string? message = null + ) + { + parameter.MustNotBeNull(parameterName, message); + requiredType.MustNotBeNull(nameof(requiredType), message); + + if (!requiredType.IsAssignableFrom(parameter)) + { + Throw.MustBeAssignableTo(parameter, requiredType, parameterName, message); + } + + return parameter; + } + + /// + /// Ensures that values of can be assigned to variables of + /// , or otherwise throws your custom exception. + /// + /// The candidate type to be checked. + /// The type to which values of the candidate type must be assignable. + /// + /// The delegate that creates your custom exception. The original candidate and required types are passed to this + /// delegate. + /// + /// The original candidate type. + /// + /// Your custom exception thrown when either type is null or the candidate type is not assignable to the required + /// type. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation( + "parameter:null => halt; parameter:notnull => notnull; requiredType:null => halt; exceptionFactory:null => halt" + )] + public static Type MustBeAssignableTo( + [NotNull] [ValidatedNotNull] this Type? parameter, + [NotNull] [ValidatedNotNull] Type? requiredType, + Func exceptionFactory + ) + { + if (parameter is null || requiredType is null || !requiredType.IsAssignableFrom(parameter)) + { + Throw.CustomException(exceptionFactory, parameter, requiredType); + } + + return parameter; + } +} diff --git a/src/Light.GuardClauses/ExceptionFactory/Throw.MustBeAssignableTo.cs b/src/Light.GuardClauses/ExceptionFactory/Throw.MustBeAssignableTo.cs new file mode 100644 index 0000000..bfc9458 --- /dev/null +++ b/src/Light.GuardClauses/ExceptionFactory/Throw.MustBeAssignableTo.cs @@ -0,0 +1,26 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using JetBrains.Annotations; + +namespace Light.GuardClauses.ExceptionFactory; + +public static partial class Throw +{ + /// + /// Throws the default indicating that values of the candidate type cannot be + /// assigned to variables of the required type, using the optional parameter name and message. + /// + [ContractAnnotation("=> halt")] + [DoesNotReturn] + public static void MustBeAssignableTo( + Type parameter, + Type requiredType, + string? parameterName = null, + string? message = null + ) => + throw new ArgumentException( + message ?? + $"Values of type \"{parameter}\" must be assignable to variables of type \"{requiredType}\", but they are not.", + parameterName + ); +} diff --git a/tests/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs b/tests/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs index fac56fc..675380c 100644 --- a/tests/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs +++ b/tests/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs @@ -528,6 +528,51 @@ public static void ObjectDisposedWhitelistExportsGuardThrowHelperAndExceptionFac sourceCode.Should().NotContain("public static void InvalidOperation("); } + [Fact] + public static void MustBeAssignableToWhitelistExportsGuardThrowHelperAndFactoryOnBothTargets() + { + using var temporaryDirectory = new TemporaryDirectory(); + var portableFile = Path.Combine(temporaryDirectory.DirectoryPath, "MustBeAssignableToPortable.cs"); + var modernFile = Path.Combine(temporaryDirectory.DirectoryPath, "MustBeAssignableToModern.cs"); + var whitelist = CreateWhitelist( + includedAssertions: [new ("MustBeAssignableTo", true)] + ); + + SourceFileMerger.CreateSingleSourceFile(CreateOptions(portableFile, whitelist)); + SourceFileMerger.CreateSingleSourceFile( + CreateOptions(modernFile, whitelist, SourceTargetFramework.Net10_0) + ); + + foreach (var sourceCode in new[] { File.ReadAllText(portableFile), File.ReadAllText(modernFile) }) + { + sourceCode.Should().Contain("public static Type MustBeAssignableTo("); + sourceCode.Should().Contain("public static void MustBeAssignableTo("); + sourceCode.Should().Contain("Func exceptionFactory"); + sourceCode.Should().Contain("public static void CustomException("); + sourceCode.Should().NotContain("public static bool IsEquivalentTypeTo("); + } + } + + [Fact] + public static void MustBeAssignableToWhitelistTrimsExceptionFactoryOverload() + { + using var temporaryDirectory = new TemporaryDirectory(); + var targetFile = Path.Combine(temporaryDirectory.DirectoryPath, "MustBeAssignableToWithoutFactory.cs"); + + SourceFileMerger.CreateSingleSourceFile( + CreateOptions( + targetFile, + CreateWhitelist(includedAssertions: [new ("MustBeAssignableTo", false)]) + ) + ); + var sourceCode = File.ReadAllText(targetFile); + + sourceCode.Should().Contain("public static Type MustBeAssignableTo("); + sourceCode.Should().Contain("public static void MustBeAssignableTo("); + sourceCode.Should().NotContain("Func exceptionFactory"); + sourceCode.Should().NotContain("public static void CustomException("); + } + [Fact] public static void MustBeUriWhitelistExportsGuardThrowHelperExceptionAndFactories() { diff --git a/tests/Light.GuardClauses.Tests/Issues/Issue72NotNullAttributeTests.cs b/tests/Light.GuardClauses.Tests/Issues/Issue72NotNullAttributeTests.cs index 7d269b6..484ce97 100644 --- a/tests/Light.GuardClauses.Tests/Issues/Issue72NotNullAttributeTests.cs +++ b/tests/Light.GuardClauses.Tests/Issues/Issue72NotNullAttributeTests.cs @@ -1062,6 +1062,34 @@ public static void CheckIsOrInheritsFrom() } } + [Fact] + public static void CheckMustBeAssignableTo() + { + TestMustBeAssignableTo(typeof(FileStream), typeof(Stream)) + .Should().Be((typeof(FileStream), typeof(Stream))); + TestMustBeAssignableToWithDelegate(typeof(FileStream), typeof(Stream)) + .Should().Be((typeof(FileStream), typeof(Stream))); + return; + + static (Type CandidateType, Type RequiredType) TestMustBeAssignableTo( + Type? candidateType, + Type? requiredType + ) + { + candidateType.MustBeAssignableTo(requiredType); + return (candidateType, requiredType); + } + + static (Type CandidateType, Type RequiredType) TestMustBeAssignableToWithDelegate( + Type? candidateType, + Type? requiredType + ) + { + candidateType.MustBeAssignableTo(requiredType, (_, _) => new Exception()); + return (candidateType, requiredType); + } + } + [Fact] public static void CheckMustBeAbsoluteUri() { diff --git a/tests/Light.GuardClauses.Tests/TypeAssertions/MustBeAssignableToTests.cs b/tests/Light.GuardClauses.Tests/TypeAssertions/MustBeAssignableToTests.cs new file mode 100644 index 0000000..df77631 --- /dev/null +++ b/tests/Light.GuardClauses.Tests/TypeAssertions/MustBeAssignableToTests.cs @@ -0,0 +1,170 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using FluentAssertions; +using Xunit; + +namespace Light.GuardClauses.Tests.TypeAssertions; + +public static class MustBeAssignableToTests +{ + [Theory(DisplayName = "MustBeAssignableTo must return the candidate type for valid CLR assignability relations.")] + [MemberData(nameof(AssignableTypes))] + public static void Assignable(Type candidateType, Type requiredType) + { + var result = candidateType.MustBeAssignableTo(requiredType); + + result.Should().BeSameAs(candidateType); + } + + public static readonly TheoryData AssignableTypes = + new () + { + { typeof(string), typeof(string) }, + { typeof(ArgumentNullException), typeof(ArgumentException) }, + { typeof(ArgumentNullException), typeof(Exception) }, + { typeof(List), typeof(IEnumerable) }, + { typeof(int), typeof(ValueType) }, + { typeof(IEnumerable), typeof(IEnumerable) }, + { typeof(IComparer), typeof(IComparer) }, + { typeof(string[]), typeof(object[]) }, + { typeof(IEnumerable<>), typeof(IEnumerable<>) }, + }; + + [Theory(DisplayName = "MustBeAssignableTo must follow CLR failures, including reversed and open-generic relations.")] + [MemberData(nameof(UnassignableTypes))] + public static void Unassignable(Type candidateType, Type requiredType) + { + Action act = () => candidateType.MustBeAssignableTo(requiredType); + + var exception = act.Should().Throw().Which; + exception.ParamName.Should().Be(nameof(candidateType)); + exception.Message.Should().Contain($"\"{candidateType}\""); + exception.Message.Should().Contain($"\"{requiredType}\""); + exception.Message.Should().Contain("assignable to"); + } + + public static readonly TheoryData UnassignableTypes = + new () + { + { typeof(Exception), typeof(ArgumentException) }, + { typeof(string), typeof(IDisposable) }, + { typeof(int), typeof(long) }, + { typeof(int[]), typeof(object[]) }, + { typeof(List<>), typeof(IEnumerable<>) }, + { typeof(List), typeof(IEnumerable<>) }, + }; + + [Fact] + public static void NullCandidateType() + { + Type? candidateType = null; + + Action act = () => candidateType.MustBeAssignableTo(typeof(object)); + + act.Should().Throw() + .WithParameterName(nameof(candidateType)); + } + + [Fact] + public static void NullRequiredType() + { + Type? requiredType = null; + + Action act = () => typeof(string).MustBeAssignableTo(requiredType); + + act.Should().Throw() + .WithParameterName(nameof(requiredType)); + } + + [Fact] + public static void ExplicitParameterName() + { + Action act = () => typeof(object).MustBeAssignableTo(typeof(string), "serviceType"); + + act.Should().Throw() + .WithParameterName("serviceType"); + } + + [Fact] + public static void CustomMessage() => + Test.CustomMessage( + message => typeof(object).MustBeAssignableTo(typeof(string), message: message) + ); + + [Theory] + [MemberData(nameof(FactoryFailureData))] + public static void FactoryReceivesOriginalTypesAndItsExceptionIsThrown(Type? candidateType, Type? requiredType) + { + var expectedException = new InvalidOperationException("Custom assignability failure"); + Type? capturedCandidate = typeof(void); + Type? capturedRequired = typeof(void); + + Exception ExceptionFactory(Type? candidate, Type? required) + { + capturedCandidate = candidate; + capturedRequired = required; + return expectedException; + } + + Action act = () => candidateType.MustBeAssignableTo(requiredType, ExceptionFactory); + + act.Should().Throw() + .Which.Should().BeSameAs(expectedException); + capturedCandidate.Should().BeSameAs(candidateType); + capturedRequired.Should().BeSameAs(requiredType); + } + + public static readonly TheoryData FactoryFailureData = + new () + { + { typeof(string), typeof(IDisposable) }, + { null, typeof(object) }, + { typeof(string), null }, + }; + + [Fact] + public static void FactoryIsNotInvokedOnSuccess() + { + var candidateType = typeof(string); + var wasInvoked = false; + + var result = candidateType.MustBeAssignableTo( + typeof(object), + (_, _) => + { + wasInvoked = true; + return new Exception(); + } + ); + + result.Should().BeSameAs(candidateType); + wasInvoked.Should().BeFalse(); + } + + [Fact] + public static void NullFactoryIsIgnoredOnSuccess() + { + var candidateType = typeof(string); + + var result = candidateType.MustBeAssignableTo( + typeof(object), + (Func) null! + ); + + result.Should().BeSameAs(candidateType); + } + + [Fact] + public static void NullFactoryThrowsArgumentNullExceptionOnFailure() + { + Action act = () => typeof(object).MustBeAssignableTo( + typeof(string), + (Func) null! + ); + + act.Should().Throw() + .WithParameterName("exceptionFactory"); + } +} diff --git a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/AssertionWhitelist.cs b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/AssertionWhitelist.cs index 02474db..685bb87 100644 --- a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/AssertionWhitelist.cs +++ b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/AssertionWhitelist.cs @@ -107,6 +107,8 @@ public sealed class AssertionWhitelist public AssertionEntry MustBeApproximately { get; init; } = new(); + public AssertionEntry MustBeAssignableTo { get; init; } = new(); + public AssertionEntry MustBeEmailAddress { get; init; } = new(); public AssertionEntry MustBeFileExtension { get; init; } = new(); diff --git a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json index 18f4c9f..4aa7a05 100644 --- a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json +++ b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json @@ -67,6 +67,7 @@ "MustBe": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustBeAbsoluteUri": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustBeApproximately": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeAssignableTo": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustBeEmailAddress": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustBeFileExtension": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustBeFinite": { "Include": true, "IncludeExceptionFactoryOverload": true },