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
22 changes: 22 additions & 0 deletions TUnit.Core/Helpers/CastHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,28 @@ public static class CastHelper
return (T?)result;
}

/// <summary>
/// Returns the value as-is if it's null or already assignable to the target type;
/// otherwise delegates to <see cref="Cast"/> to apply conversion operators.
/// Unlike <see cref="Cast"/>, null input always returns null (no default-value creation for value types).
/// In Native AOT mode, throws <see cref="InvalidCastException"/> when reflection-based
/// operator discovery is unavailable.
/// </summary>
[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);
}

/// <summary>
/// Attempts to cast or convert a value to the specified type.
/// Conversion priority:
Expand Down
7 changes: 5 additions & 2 deletions TUnit.Engine/Services/ObjectLifecycleService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
[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());
Expand All @@ -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);
}
}
Expand All @@ -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);
}
Expand Down
18 changes: 16 additions & 2 deletions TUnit.Engine/Services/PropertyInjector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, object?>)testContext.Metadata.TestDetails.TestClassInjectedPropertyArguments)
.TryAdd(cacheKey, resolvedValue);
[cacheKey] = resolvedValue;
}
}

Expand All @@ -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,
Expand All @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2069,6 +2069,7 @@ namespace .Helpers
{
public static object? Cast( type, object? value) { }
public static T? Cast<T>(object? value) { }
public static object? CastIfNeeded( type, object? value) { }
}
public static class ClassConstructorHelper
{
Expand Down
29 changes: 29 additions & 0 deletions TUnit.TestProject/ImplicitOperatorPropertyInjectionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using TUnit.TestProject.Attributes;

namespace TUnit.TestProject;

[EngineTest(ExpectedResult.Pass)]
[DynamicCodeOnly]
public class ImplicitOperatorPropertyInjectionTests
{
[ClassDataSource<ImplicitDbFixture>]
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;
}
Loading
Loading