From 3a434f7a8bd78417878e4b90028824a08b5d3b90 Mon Sep 17 00:00:00 2001 From: Stefan Nikolei Date: Sat, 30 May 2026 14:25:32 +0200 Subject: [PATCH] Port ConditionalTheory and Fact Source of it: https://github.com/dotnet/arcade/tree/main/src/Microsoft.DotNet.XUnitExtensions.Shared/Attributes --- .../Image/ImageTests.Decode_Cancellation.cs | 2 +- .../UniformUnmanagedMemoryPoolTests.Trim.cs | 2 +- .../UniformUnmanagedMemoryPoolTests.cs | 2 +- ...niformUnmanagedPoolMemoryAllocatorTests.cs | 2 +- .../PixelOperations/PixelOperationsTests.cs | 32 ---- .../XUnit/ConditionalDiscovererException.cs | 13 ++ .../TestUtilities/XUnit/ConditionalFact.cs | 38 +++++ .../XUnit/ConditionalTestDiscoverer.cs | 146 ++++++++++++++++++ .../TestUtilities/XUnit/ConditionalTheory.cs | 42 +++++ .../XUnit/StaticReflectionConstants.cs | 64 ++++++++ 10 files changed, 307 insertions(+), 36 deletions(-) create mode 100644 tests/ImageSharp.Tests/TestUtilities/XUnit/ConditionalDiscovererException.cs create mode 100644 tests/ImageSharp.Tests/TestUtilities/XUnit/ConditionalFact.cs create mode 100644 tests/ImageSharp.Tests/TestUtilities/XUnit/ConditionalTestDiscoverer.cs create mode 100644 tests/ImageSharp.Tests/TestUtilities/XUnit/ConditionalTheory.cs create mode 100644 tests/ImageSharp.Tests/TestUtilities/XUnit/StaticReflectionConstants.cs diff --git a/tests/ImageSharp.Tests/Image/ImageTests.Decode_Cancellation.cs b/tests/ImageSharp.Tests/Image/ImageTests.Decode_Cancellation.cs index 06ce7fd634..0656f9e0bc 100644 --- a/tests/ImageSharp.Tests/Image/ImageTests.Decode_Cancellation.cs +++ b/tests/ImageSharp.Tests/Image/ImageTests.Decode_Cancellation.cs @@ -58,7 +58,7 @@ public partial class ImageTests public static TheoryData LoadData { get; } = CreateLoadData(); // TODO: Figure out cancellation failures on Linux - [Theory(Skip = "Skipped on non-Windows platforms", SkipType = typeof(TestEnvironment), SkipUnless = nameof(TestEnvironment.IsWindows))] + [ConditionalTheory(typeof(TestEnvironment), nameof(TestEnvironment.IsWindows))] [MemberData(nameof(LoadData))] public async Task LoadAsync_IsCancellable(bool useMemoryStream, string file, double percentageOfStreamReadToCancel) { diff --git a/tests/ImageSharp.Tests/Memory/Allocators/UniformUnmanagedMemoryPoolTests.Trim.cs b/tests/ImageSharp.Tests/Memory/Allocators/UniformUnmanagedMemoryPoolTests.Trim.cs index 453b0ed036..d53e2ca86a 100644 --- a/tests/ImageSharp.Tests/Memory/Allocators/UniformUnmanagedMemoryPoolTests.Trim.cs +++ b/tests/ImageSharp.Tests/Memory/Allocators/UniformUnmanagedMemoryPoolTests.Trim.cs @@ -57,7 +57,7 @@ public partial class UniformUnmanagedMemoryPoolTests // TODO: Investigate failures on macOS. All handles are released after GC. // (It seems to happen more consistently on .NET 6.) - [Fact(Skip = "Skipped on macOS", SkipUnless = nameof(IsNotMacOS))] + [ConditionalFact(nameof(IsNotMacOS))] public void MultiplePoolInstances_TrimPeriodElapsed_AllAreTrimmed() { if (!TestEnvironment.RunsOnCI) diff --git a/tests/ImageSharp.Tests/Memory/Allocators/UniformUnmanagedMemoryPoolTests.cs b/tests/ImageSharp.Tests/Memory/Allocators/UniformUnmanagedMemoryPoolTests.cs index 7e9e8294fc..18d2ead83e 100644 --- a/tests/ImageSharp.Tests/Memory/Allocators/UniformUnmanagedMemoryPoolTests.cs +++ b/tests/ImageSharp.Tests/Memory/Allocators/UniformUnmanagedMemoryPoolTests.cs @@ -251,7 +251,7 @@ public partial class UniformUnmanagedMemoryPoolTests public static bool IsNotMacOS => !TestEnvironment.IsMacOS; // TODO: Investigate macOS failures - [Theory(Skip = "Skipped on macOS", SkipUnless = nameof(IsNotMacOS))] + [ConditionalTheory(nameof(IsNotMacOS))] [InlineData(false)] [InlineData(true)] public void RentReturnRelease_SubsequentRentReturnsDifferentHandles(bool multiple) diff --git a/tests/ImageSharp.Tests/Memory/Allocators/UniformUnmanagedPoolMemoryAllocatorTests.cs b/tests/ImageSharp.Tests/Memory/Allocators/UniformUnmanagedPoolMemoryAllocatorTests.cs index 887b266856..b4593d4d09 100644 --- a/tests/ImageSharp.Tests/Memory/Allocators/UniformUnmanagedPoolMemoryAllocatorTests.cs +++ b/tests/ImageSharp.Tests/Memory/Allocators/UniformUnmanagedPoolMemoryAllocatorTests.cs @@ -584,7 +584,7 @@ public class UniformUnmanagedPoolMemoryAllocatorTests } } - [Fact(Skip = "Requires 64-bit process", SkipType = typeof(Environment), SkipUnless = nameof(Environment.Is64BitProcess))] + [ConditionalFact(typeof(Environment), nameof(Environment.Is64BitProcess))] public void MemoryAllocator_Create_SetHighLimit() { RemoteExecutor.Invoke(RunTest).Dispose(); diff --git a/tests/ImageSharp.Tests/PixelFormats/PixelOperations/PixelOperationsTests.cs b/tests/ImageSharp.Tests/PixelFormats/PixelOperations/PixelOperationsTests.cs index ba8dbceb01..7315f0510e 100644 --- a/tests/ImageSharp.Tests/PixelFormats/PixelOperations/PixelOperationsTests.cs +++ b/tests/ImageSharp.Tests/PixelFormats/PixelOperations/PixelOperationsTests.cs @@ -312,38 +312,6 @@ public abstract class PixelOperationsTests : MeasureFixture (s, d) => this.Operations.ToVector4(this.Configuration, s, d.GetSpan())); } - public static readonly TheoryData Generic_To_Data = new() - { - (object)new TestPixel(), - (object)new TestPixel(), - (object)new TestPixel(), - (object)new TestPixel(), - (object)new TestPixel(), - (object)new TestPixel(), - (object)new TestPixel(), - (object)new TestPixel(), - (object)new TestPixel(), - (object)new TestPixel(), - (object)new TestPixel(), - (object)new TestPixel(), - (object)new TestPixel(), - (object)new TestPixel(), - (object)new TestPixel(), - (object)new TestPixel(), - (object)new TestPixel(), - (object)new TestPixel(), - (object)new TestPixel(), - (object)new TestPixel(), - (object)new TestPixel(), - (object)new TestPixel(), - (object)new TestPixel(), - (object)new TestPixel(), - (object)new TestPixel(), - (object)new TestPixel(), - (object)new TestPixel(), - (object)new TestPixel(), - (object)new TestPixel(), - }; public static readonly TheoryData Generic_To_Data = new( new object[] { diff --git a/tests/ImageSharp.Tests/TestUtilities/XUnit/ConditionalDiscovererException.cs b/tests/ImageSharp.Tests/TestUtilities/XUnit/ConditionalDiscovererException.cs new file mode 100644 index 0000000000..74d60451fc --- /dev/null +++ b/tests/ImageSharp.Tests/TestUtilities/XUnit/ConditionalDiscovererException.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace Microsoft.DotNet.XUnitExtensions +{ + internal class ConditionalDiscovererException : Exception + { + public ConditionalDiscovererException(string message) + : base(message) { } + } +} diff --git a/tests/ImageSharp.Tests/TestUtilities/XUnit/ConditionalFact.cs b/tests/ImageSharp.Tests/TestUtilities/XUnit/ConditionalFact.cs new file mode 100644 index 0000000000..1e09034517 --- /dev/null +++ b/tests/ImageSharp.Tests/TestUtilities/XUnit/ConditionalFact.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. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.DotNet.XUnitExtensions; +using Xunit.Sdk; + +namespace Xunit +{ + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] + public sealed class ConditionalFactAttribute : FactAttribute + { + [DynamicallyAccessedMembers(StaticReflectionConstants.ConditionalMemberKinds)] + public Type CalleeType { get; private set; } + + public string[] ConditionMemberNames { get; private set; } + + public ConditionalFactAttribute( + [DynamicallyAccessedMembers(StaticReflectionConstants.ConditionalMemberKinds)] + Type calleeType, + params string[] conditionMemberNames) + { + CalleeType = calleeType; + ConditionMemberNames = conditionMemberNames; + string skipReason = ConditionalTestDiscoverer.EvaluateSkipConditions(calleeType, conditionMemberNames); + if (skipReason != null) + Skip = skipReason; + } + + [Obsolete( + "Use the overload that takes a Type parameter: ConditionalFact(typeof(MyClass), nameof(MyCondition)).")] + public ConditionalFactAttribute(params string[] conditionMemberNames) + { + ConditionMemberNames = conditionMemberNames; + } + } +} diff --git a/tests/ImageSharp.Tests/TestUtilities/XUnit/ConditionalTestDiscoverer.cs b/tests/ImageSharp.Tests/TestUtilities/XUnit/ConditionalTestDiscoverer.cs new file mode 100644 index 0000000000..a5bc58c5a2 --- /dev/null +++ b/tests/ImageSharp.Tests/TestUtilities/XUnit/ConditionalTestDiscoverer.cs @@ -0,0 +1,146 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using Xunit.Sdk; + +namespace Microsoft.DotNet.XUnitExtensions +{ + // Internal helper class for code common to conditional test discovery through + // [ConditionalFact] and [ConditionalTheory] + internal static class ConditionalTestDiscoverer + { + /// + /// Evaluates skip conditions given an explicit callee type and condition member names. + /// Used by attribute constructors in xunit v3 where discoverers are not needed. + /// + internal static string EvaluateSkipConditions( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type calleeType, + string[] conditionMemberNames + ) + { + if ( + calleeType == null + || conditionMemberNames == null + || conditionMemberNames.Length == 0 + ) + return null; + + List falseConditions = new(conditionMemberNames.Length); + foreach (string entry in conditionMemberNames) + { + if (string.IsNullOrWhiteSpace(entry)) + continue; + + Func conditionFunc = LookupConditionalMember(calleeType, entry); + if (conditionFunc == null) + throw new ConditionalDiscovererException( + GetFailedLookupString(entry, calleeType) + ); + + try + { + if (!conditionFunc()) + falseConditions.Add(entry); + } + catch (Exception exc) + { + falseConditions.Add($"{entry} ({exc.GetType().Name})"); + } + } + + return falseConditions.Count > 0 + ? string.Format( + "Condition(s) not met: \"{0}\"", + string.Join("\", \"", falseConditions) + ) + : null; + } + + internal static string GetFailedLookupString(string name, Type type) + { + return $"An appropriate member '{name}' could not be found. " + + $"The conditional method needs to be a static method, property, or field on the type {type} or any ancestor, " + + "of any visibility, accepting zero arguments, and having a return type of Boolean."; + } + + internal static Func LookupConditionalMember(Type t, string name) + { + if (t == null || name == null) + return null; + + TypeInfo ti = t.GetTypeInfo(); + + MethodInfo mi = ti.GetDeclaredMethod(name); + if ( + mi != null + && mi.IsStatic + && mi.GetParameters().Length == 0 + && mi.ReturnType == typeof(bool) + ) + return () => (bool)mi.Invoke(null, null); + + PropertyInfo pi = ti.GetDeclaredProperty(name); + if ( + pi != null + && pi.PropertyType == typeof(bool) + && pi.GetMethod != null + && pi.GetMethod.IsStatic + && pi.GetMethod.GetParameters().Length == 0 + ) + return () => (bool)pi.GetValue(null); + + FieldInfo fi = ti.GetDeclaredField(name); + if (fi != null && fi.FieldType == typeof(bool) && fi.IsStatic) + return () => (bool)fi.GetValue(null); + + return LookupConditionalMember(ti.BaseType, name); + } + + internal static bool CheckInputToSkipExecution( + object[] conditionArguments, + ref Type calleeType, + ref string[] conditionMemberNames, + object testMethod = null + ) + { + // A null or empty list of conditionArguments is treated as "no conditions". + // and the test cases will be executed. + // Example: [ConditionalClass()] + if (conditionArguments == null || conditionArguments.Length == 0) + return true; + + calleeType = conditionArguments[0] as Type; + if (calleeType != null) + { + if (conditionArguments.Length < 2) + { + // [ConditionalFact(typeof(x))] no provided methods. + return true; + } + + // [ConditionalFact(typeof(x), "MethodName")] + conditionMemberNames = conditionArguments[1] as string[]; + } + else + { + // For [ConditionalClass], unable to get the Type info. All test cases will be executed. + if (testMethod == null) + return true; + + // [ConditionalFact("MethodName")] + conditionMemberNames = conditionArguments[0] as string[]; + } + + // [ConditionalFact((string[]) null)] + if (conditionMemberNames == null || conditionMemberNames.Count() == 0) + return true; + + return false; + } + } +} diff --git a/tests/ImageSharp.Tests/TestUtilities/XUnit/ConditionalTheory.cs b/tests/ImageSharp.Tests/TestUtilities/XUnit/ConditionalTheory.cs new file mode 100644 index 0000000000..0c1959acf6 --- /dev/null +++ b/tests/ImageSharp.Tests/TestUtilities/XUnit/ConditionalTheory.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.DotNet.XUnitExtensions; +using Xunit.Sdk; + +namespace Xunit +{ + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] + public sealed class ConditionalTheoryAttribute : TheoryAttribute + { + [DynamicallyAccessedMembers(StaticReflectionConstants.ConditionalMemberKinds)] + public Type CalleeType { get; private set; } + public string[] ConditionMemberNames { get; private set; } + + public ConditionalTheoryAttribute( + [DynamicallyAccessedMembers(StaticReflectionConstants.ConditionalMemberKinds)] + Type calleeType, + params string[] conditionMemberNames + ) + { + CalleeType = calleeType; + ConditionMemberNames = conditionMemberNames; + string skipReason = ConditionalTestDiscoverer.EvaluateSkipConditions( + calleeType, + conditionMemberNames + ); + if (skipReason != null) + Skip = skipReason; + } + + [Obsolete( + "Use the overload that takes a Type parameter: ConditionalTheory(typeof(MyClass), nameof(MyCondition))." + )] + public ConditionalTheoryAttribute(params string[] conditionMemberNames) + { + ConditionMemberNames = conditionMemberNames; + } + } +} diff --git a/tests/ImageSharp.Tests/TestUtilities/XUnit/StaticReflectionConstants.cs b/tests/ImageSharp.Tests/TestUtilities/XUnit/StaticReflectionConstants.cs new file mode 100644 index 0000000000..4f1c19d574 --- /dev/null +++ b/tests/ImageSharp.Tests/TestUtilities/XUnit/StaticReflectionConstants.cs @@ -0,0 +1,64 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Xunit +{ + internal static class StaticReflectionConstants + { + // ConditionalTestDiscoverer looks at all fields/methods/properties, recursively. + internal const DynamicallyAccessedMemberTypes ConditionalMemberKinds = + DynamicallyAccessedMemberTypes.AllMethods + | DynamicallyAccessedMemberTypes.AllFields + | DynamicallyAccessedMemberTypes.AllProperties; + } +} + +namespace System.Diagnostics.CodeAnalysis +{ + // This is a copy of the attribute from CoreLib. The attribute shipped in .NET 5. + // The tooling looks for the attribute by name, so we can define a local copy. + // This copy can be deleted once we stop supporting anything below .NET 5. + internal sealed class DynamicallyAccessedMembersAttribute : Attribute + { + public DynamicallyAccessedMembersAttribute(DynamicallyAccessedMemberTypes memberTypes) => + MemberTypes = memberTypes; + + public DynamicallyAccessedMemberTypes MemberTypes { get; } + } + + internal enum DynamicallyAccessedMemberTypes + { + None = 0, + PublicParameterlessConstructor = 0x0001, + PublicConstructors = 0x0002 | PublicParameterlessConstructor, + NonPublicConstructors = 0x0004, + PublicMethods = 0x0008, + NonPublicMethods = 0x0010, + PublicFields = 0x0020, + NonPublicFields = 0x0040, + PublicNestedTypes = 0x0080, + NonPublicNestedTypes = 0x0100, + PublicProperties = 0x0200, + NonPublicProperties = 0x0400, + PublicEvents = 0x0800, + NonPublicEvents = 0x1000, + Interfaces = 0x2000, + NonPublicConstructorsWithInherited = NonPublicConstructors | 0x4000, + NonPublicMethodsWithInherited = NonPublicMethods | 0x8000, + NonPublicFieldsWithInherited = NonPublicFields | 0x10000, + NonPublicNestedTypesWithInherited = NonPublicNestedTypes | 0x20000, + NonPublicPropertiesWithInherited = NonPublicProperties | 0x40000, + NonPublicEventsWithInherited = NonPublicEvents | 0x80000, + PublicConstructorsWithInherited = PublicConstructors | 0x100000, + PublicNestedTypesWithInherited = PublicNestedTypes | 0x200000, + AllConstructors = PublicConstructorsWithInherited | NonPublicConstructorsWithInherited, + AllMethods = PublicMethods | NonPublicMethodsWithInherited, + AllFields = PublicFields | NonPublicFieldsWithInherited, + AllNestedTypes = PublicNestedTypesWithInherited | NonPublicNestedTypesWithInherited, + AllProperties = PublicProperties | NonPublicPropertiesWithInherited, + AllEvents = PublicEvents | NonPublicEventsWithInherited, + All = ~None, + } +}