diff --git a/TUnit.Core/Helpers/CastHelper.cs b/TUnit.Core/Helpers/CastHelper.cs index 837c482d5c..060b439e71 100644 --- a/TUnit.Core/Helpers/CastHelper.cs +++ b/TUnit.Core/Helpers/CastHelper.cs @@ -29,6 +29,28 @@ public static class CastHelper return (T?)result; } + /// + /// Returns the value as-is if it's null or already assignable to the target type; + /// otherwise delegates to to apply conversion operators. + /// Unlike , null input always returns null (no default-value creation for value types). + /// In Native AOT mode, throws when reflection-based + /// operator discovery is unavailable. + /// + [UnconditionalSuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.")] + public static object? CastIfNeeded( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor + | DynamicallyAccessedMemberTypes.Interfaces + | DynamicallyAccessedMemberTypes.PublicMethods)] Type type, + object? value) + { + if (value is null || value.GetType().IsAssignableTo(type)) + { + return value; + } + + return Cast(type, value); + } + /// /// Attempts to cast or convert a value to the specified type. /// Conversion priority: diff --git a/TUnit.Engine/Services/ObjectLifecycleService.cs b/TUnit.Engine/Services/ObjectLifecycleService.cs index bbc0f0590b..a582ffa897 100644 --- a/TUnit.Engine/Services/ObjectLifecycleService.cs +++ b/TUnit.Engine/Services/ObjectLifecycleService.cs @@ -173,6 +173,7 @@ public async Task InitializeObjectForExecutionAsync(object? obj, CancellationTok /// This is used to apply cached property values to new instances created during retries. /// [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Reflection mode is not used in AOT")] + [UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "PropertyType is preserved through source generation or reflection discovery — annotation can't flow through PropertyInfo/PropertyInjectionMetadata")] private void SetCachedPropertiesOnInstance(object instance, TestContext testContext) { var plan = PropertyInjectionCache.GetOrCreatePlan(instance.GetType()); @@ -192,7 +193,8 @@ private void SetCachedPropertiesOnInstance(object instance, TestContext testCont if (cachedProperties.TryGetValue(cacheKey, out var cachedValue) && cachedValue != null) { - // Set the cached value on the new instance + // Convert if needed — cached values may be unconverted when pre-resolved during registration + cachedValue = CastHelper.CastIfNeeded(metadata.PropertyType, cachedValue); metadata.SetProperty(instance, cachedValue); } } @@ -205,7 +207,8 @@ private void SetCachedPropertiesOnInstance(object instance, TestContext testCont if (cachedProperties.TryGetValue(cacheKey, out var cachedValue) && cachedValue != null) { - // Set the cached value on the new instance + // Convert if needed — cached values may be unconverted when pre-resolved during registration + cachedValue = CastHelper.CastIfNeeded(property.PropertyType, cachedValue); var setter = PropertySetterFactory.CreateSetter(property); setter(instance, cachedValue); } diff --git a/TUnit.Engine/Services/PropertyInjector.cs b/TUnit.Engine/Services/PropertyInjector.cs index ec81f56c15..f2df872ea6 100644 --- a/TUnit.Engine/Services/PropertyInjector.cs +++ b/TUnit.Engine/Services/PropertyInjector.cs @@ -241,6 +241,7 @@ private Task InjectSourceGeneratedPropertiesAsync( } [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Source-gen properties are AOT-safe")] + [UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "PropertyType is preserved through source generation — annotation can't flow through PropertyInjectionMetadata interface")] [UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "ContainingType is annotated with DynamicallyAccessedMembers in PropertyInjectionMetadata")] private async Task InjectSourceGeneratedPropertyAsync( object instance, @@ -285,14 +286,21 @@ private async Task InjectSourceGeneratedPropertyAsync( } } + // Convert the value if the runtime type doesn't match the property type. + // This handles implicit/explicit conversion operators when the source generator + // doesn't know the data source type (e.g., custom data sources). + resolvedValue = CastHelper.CastIfNeeded(metadata.PropertyType, resolvedValue); + // Set the property value metadata.SetProperty(instance, resolvedValue); - // Store for potential reuse with composite key + // Store the converted value for potential reuse (e.g., retries). + // Use indexer to overwrite any pre-resolved unconverted value so that + // SetCachedPropertiesOnInstance can use the value directly without re-converting. if (testContext != null) { ((ConcurrentDictionary)testContext.Metadata.TestDetails.TestClassInjectedPropertyArguments) - .TryAdd(cacheKey, resolvedValue); + [cacheKey] = resolvedValue; } } @@ -311,6 +319,7 @@ private Task InjectReflectionPropertiesAsync( } [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Reflection mode is not used in AOT")] + [UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "PropertyType is preserved through reflection discovery — annotation can't flow through PropertyInfo.PropertyType")] private async Task InjectReflectionPropertyAsync( object instance, PropertyInfo property, @@ -334,6 +343,11 @@ private async Task InjectReflectionPropertyAsync( return; } + // Convert the value if the runtime type doesn't match the property type. + // This handles implicit/explicit conversion operators when the source generator + // doesn't know the data source type (e.g., custom data sources). + resolvedValue = CastHelper.CastIfNeeded(property.PropertyType, resolvedValue); + propertySetter(instance, resolvedValue); } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt index e88e081dd5..a750c49483 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -2132,6 +2132,9 @@ namespace .Helpers "ile time. The runtime TryParsableConvert fallback is only used in non-AOT scenar" + "ios.")] public static T? Cast<[.(..PublicParameterlessConstructor)] T>(object? value) { } + [.("AOT", "IL3050:Calling members annotated with \'RequiresDynamicCodeAttribute\' may break fu" + + "nctionality when AOT compiling.")] + public static object? CastIfNeeded([.(..None | ..PublicParameterlessConstructor | ..PublicMethods | ..Interfaces)] type, object? value) { } } public static class ClassConstructorHelper { diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt index c522b2d530..348b7a337e 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -2132,6 +2132,9 @@ namespace .Helpers "ile time. The runtime TryParsableConvert fallback is only used in non-AOT scenar" + "ios.")] public static T? Cast<[.(..PublicParameterlessConstructor)] T>(object? value) { } + [.("AOT", "IL3050:Calling members annotated with \'RequiresDynamicCodeAttribute\' may break fu" + + "nctionality when AOT compiling.")] + public static object? CastIfNeeded([.(..None | ..PublicParameterlessConstructor | ..PublicMethods | ..Interfaces)] type, object? value) { } } public static class ClassConstructorHelper { diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt index f3bcb841ed..f3a7961d03 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -2132,6 +2132,9 @@ namespace .Helpers "ile time. The runtime TryParsableConvert fallback is only used in non-AOT scenar" + "ios.")] public static T? Cast<[.(..PublicParameterlessConstructor)] T>(object? value) { } + [.("AOT", "IL3050:Calling members annotated with \'RequiresDynamicCodeAttribute\' may break fu" + + "nctionality when AOT compiling.")] + public static object? CastIfNeeded([.(..None | ..PublicParameterlessConstructor | ..PublicMethods | ..Interfaces)] type, object? value) { } } public static class ClassConstructorHelper { diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt index e15ce58db6..685adf2d3a 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -2069,6 +2069,7 @@ namespace .Helpers { public static object? Cast( type, object? value) { } public static T? Cast(object? value) { } + public static object? CastIfNeeded( type, object? value) { } } public static class ClassConstructorHelper { diff --git a/TUnit.TestProject/ImplicitOperatorPropertyInjectionTests.cs b/TUnit.TestProject/ImplicitOperatorPropertyInjectionTests.cs new file mode 100644 index 0000000000..7b2ccb155f --- /dev/null +++ b/TUnit.TestProject/ImplicitOperatorPropertyInjectionTests.cs @@ -0,0 +1,29 @@ +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject; + +[EngineTest(ExpectedResult.Pass)] +[DynamicCodeOnly] +public class ImplicitOperatorPropertyInjectionTests +{ + [ClassDataSource] + public required ImplicitDbContext Db { get; init; } + + [Test] + public async Task Injection_Works_With_Implicit_Operator() + { + await Assert.That(Db).IsNotNull(); + await Assert.That(Db.ConnStr).IsEqualTo("test-conn"); + } +} + +public class ImplicitDbContext(string connStr) +{ + public string ConnStr { get; } = connStr; +} + +public class ImplicitDbFixture : IAsyncDisposable +{ + public static implicit operator ImplicitDbContext(ImplicitDbFixture f) => new("test-conn"); + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} diff --git a/TUnit.TestProject/RuntimeConversionPropertyInjectionTests.cs b/TUnit.TestProject/RuntimeConversionPropertyInjectionTests.cs new file mode 100644 index 0000000000..9d6b05cb53 --- /dev/null +++ b/TUnit.TestProject/RuntimeConversionPropertyInjectionTests.cs @@ -0,0 +1,293 @@ +#pragma warning disable TUnit0042 + +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject; + +#region Types for runtime conversion tests + +// --- Implicit operator on source type (source defines how to convert to target) --- + +public class RuntimeSourceWithImplicit +{ + public string Value { get; init; } = "from-source-implicit"; + public static implicit operator RuntimeTarget(RuntimeSourceWithImplicit s) => new() { Value = s.Value }; +} + +public class RuntimeTarget +{ + public string Value { get; init; } = ""; +} + +// --- Explicit operator on source type --- + +public class RuntimeSourceWithExplicit +{ + public string Value { get; init; } = "from-source-explicit"; + public static explicit operator RuntimeExplicitTarget(RuntimeSourceWithExplicit s) => new() { Value = s.Value }; +} + +public class RuntimeExplicitTarget +{ + public string Value { get; init; } = ""; +} + +// --- Implicit operator on target type (target defines how to convert from source) --- + +public class RuntimeTargetDefinesImplicit +{ + public string Value { get; init; } = ""; + public static implicit operator RuntimeTargetDefinesImplicit(RuntimeSourceForTargetImplicit s) => new() { Value = s.Value }; +} + +public class RuntimeSourceForTargetImplicit +{ + public string Value { get; init; } = "from-target-implicit"; +} + +// --- Struct with implicit operator --- + +public readonly struct RuntimeValueWrapper +{ + public string Value { get; init; } + public RuntimeValueWrapper(string value) => Value = value; + public static implicit operator RuntimeValueTarget(RuntimeValueWrapper w) => new() { Value = w.Value }; +} + +public class RuntimeValueTarget +{ + public string Value { get; init; } = ""; +} + +// --- Interface-based conversion (same type, no conversion needed) --- + +public class RuntimeSameTypeData : IAsyncDisposable +{ + public string Value { get; init; } = "same-type"; + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} + +// --- Custom UntypedDataSourceGeneratorAttribute implementations --- + +/// +/// Custom data source that yields a RuntimeSourceWithImplicit instance +/// for a property typed as RuntimeTarget. The source generator has no type info +/// for the produced value — conversion must happen at runtime via CastHelper. +/// +public class ImplicitSourceDataSourceAttribute : UntypedDataSourceGeneratorAttribute +{ + protected override IEnumerable> GenerateDataSources(DataGeneratorMetadata dataGeneratorMetadata) + { + yield return () => [new RuntimeSourceWithImplicit { Value = "custom-implicit" }]; + } +} + +/// +/// Custom data source that yields a RuntimeSourceWithExplicit instance +/// for a property typed as RuntimeExplicitTarget. +/// +public class ExplicitSourceDataSourceAttribute : UntypedDataSourceGeneratorAttribute +{ + protected override IEnumerable> GenerateDataSources(DataGeneratorMetadata dataGeneratorMetadata) + { + yield return () => [new RuntimeSourceWithExplicit { Value = "custom-explicit" }]; + } +} + +/// +/// Custom data source where the target type defines the implicit conversion from the source type. +/// +public class TargetDefinesImplicitDataSourceAttribute : UntypedDataSourceGeneratorAttribute +{ + protected override IEnumerable> GenerateDataSources(DataGeneratorMetadata dataGeneratorMetadata) + { + yield return () => [new RuntimeSourceForTargetImplicit { Value = "target-defines-implicit" }]; + } +} + +/// +/// Custom data source that yields a value type (struct) with an implicit operator. +/// +public class StructImplicitDataSourceAttribute : UntypedDataSourceGeneratorAttribute +{ + protected override IEnumerable> GenerateDataSources(DataGeneratorMetadata dataGeneratorMetadata) + { + yield return () => [new RuntimeValueWrapper("struct-implicit")]; + } +} + +/// +/// Custom data source that yields the same type as the property (no conversion needed). +/// Baseline test to ensure no regression when types match. +/// +public class SameTypeDataSourceAttribute : UntypedDataSourceGeneratorAttribute +{ + protected override IEnumerable> GenerateDataSources(DataGeneratorMetadata dataGeneratorMetadata) + { + yield return () => [new RuntimeSameTypeData { Value = "same-type-custom" }]; + } +} + +#endregion + +#region Test classes + +/// +/// Tests the runtime conversion fallback with a custom untyped data source +/// that yields a type with an implicit operator defined on the source type. +/// The source generator cannot know what type the custom data source will produce, +/// so conversion must happen at runtime via CastHelper. +/// +[EngineTest(ExpectedResult.Pass)] +[DynamicCodeOnly] +public class RuntimeImplicitConversionFromCustomDataSourceTests +{ + [ImplicitSourceDataSource] + public required RuntimeTarget Target { get; init; } + + [Test] + public async Task Custom_DataSource_With_Implicit_Operator_On_Source() + { + await Assert.That(Target).IsNotNull(); + await Assert.That(Target.Value).IsEqualTo("custom-implicit"); + } +} + +/// +/// Tests the runtime conversion fallback with a custom untyped data source +/// that yields a type with an explicit operator. +/// +[EngineTest(ExpectedResult.Pass)] +[DynamicCodeOnly] +public class RuntimeExplicitConversionFromCustomDataSourceTests +{ + [ExplicitSourceDataSource] + public required RuntimeExplicitTarget Target { get; init; } + + [Test] + public async Task Custom_DataSource_With_Explicit_Operator() + { + await Assert.That(Target).IsNotNull(); + await Assert.That(Target.Value).IsEqualTo("custom-explicit"); + } +} + +/// +/// Tests the runtime conversion fallback where the target type defines the implicit +/// conversion operator (as opposed to the source type). +/// +[EngineTest(ExpectedResult.Pass)] +[DynamicCodeOnly] +public class RuntimeImplicitOnTargetTypeConversionTests +{ + [TargetDefinesImplicitDataSource] + public required RuntimeTargetDefinesImplicit Target { get; init; } + + [Test] + public async Task Custom_DataSource_With_Implicit_Operator_On_Target() + { + await Assert.That(Target).IsNotNull(); + await Assert.That(Target.Value).IsEqualTo("target-defines-implicit"); + } +} + +/// +/// Tests the runtime conversion fallback with a struct (value type) that defines +/// an implicit operator. This exercises the boxing/unboxing + conversion path. +/// +[EngineTest(ExpectedResult.Pass)] +[DynamicCodeOnly] +public class RuntimeStructImplicitConversionTests +{ + [StructImplicitDataSource] + public required RuntimeValueTarget Target { get; init; } + + [Test] + public async Task Custom_DataSource_With_Struct_Implicit_Operator() + { + await Assert.That(Target).IsNotNull(); + await Assert.That(Target.Value).IsEqualTo("struct-implicit"); + } +} + +/// +/// Baseline test: custom data source yields the same type as the property. +/// No conversion is needed — verifies no regression when types already match. +/// +[EngineTest(ExpectedResult.Pass)] +[DynamicCodeOnly] +public class RuntimeSameTypeNoConversionTests +{ + [SameTypeDataSource] + public required RuntimeSameTypeData Data { get; init; } + + [Test] + public async Task Custom_DataSource_Same_Type_No_Conversion_Needed() + { + await Assert.That(Data).IsNotNull(); + await Assert.That(Data.Value).IsEqualTo("same-type-custom"); + } +} + +/// +/// Tests the runtime conversion fallback via MethodDataSource which returns a +/// different type than the property type. The method returns RuntimeSourceWithImplicit +/// but the property expects RuntimeTarget. +/// +[EngineTest(ExpectedResult.Pass)] +[DynamicCodeOnly] +public class RuntimeMethodDataSourceImplicitConversionTests +{ + [MethodDataSource(nameof(GetSource))] + public required RuntimeTarget Target { get; init; } + + public static RuntimeSourceWithImplicit GetSource() => new() { Value = "method-source-implicit" }; + + [Test] + public async Task MethodDataSource_With_Implicit_Operator_Runtime_Conversion() + { + await Assert.That(Target).IsNotNull(); + await Assert.That(Target.Value).IsEqualTo("method-source-implicit"); + } +} + +/// +/// Explicitly exercises the cached property path used for retry-created instances. +/// The first attempt fails after the converted property has been applied, and the retry +/// must reapply the cached raw value through the cached-property preparation path to a new instance. +/// +[EngineTest(ExpectedResult.Pass)] +[DynamicCodeOnly] +[NotInParallel(nameof(RuntimeCachedRetryImplicitConversionTests))] +public class RuntimeCachedRetryImplicitConversionTests +{ + private static int _attemptCount; + + [ImplicitSourceDataSource] + public required RuntimeTarget Target { get; init; } + + [Before(TestSession)] + public static void ResetAttempts() + { + _attemptCount = 0; + } + + [Test] + [Retry(1)] + public async Task Cached_Runtime_Conversion_Works_For_Retry_Created_Instance() + { + _attemptCount++; + + await Assert.That(Target).IsNotNull(); + await Assert.That(Target.Value).IsEqualTo("custom-implicit"); + + if (_attemptCount == 1) + { + throw new Exception("Force a retry so cached property values are applied to a new instance"); + } + + await Assert.That(_attemptCount).IsEqualTo(2); + } +} + +#endregion