diff --git a/src/TUnit.Core.SourceGenerator/Generators/AotConverterGenerator.cs b/src/TUnit.Core.SourceGenerator/Generators/AotConverterGenerator.cs index 8c6efe04c77..1845ed02e23 100644 --- a/src/TUnit.Core.SourceGenerator/Generators/AotConverterGenerator.cs +++ b/src/TUnit.Core.SourceGenerator/Generators/AotConverterGenerator.cs @@ -3,6 +3,7 @@ using System.Collections.Immutable; using System.Runtime.CompilerServices; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using TUnit.Core.SourceGenerator.CodeGenerators.Helpers; using TUnit.Core.SourceGenerator.Extensions; @@ -102,10 +103,10 @@ public void Initialize(IncrementalGeneratorInitializationContext context) foreach (var parameter in methodSymbol.Parameters) { AddType(parameter.Type, methodTypes, orderedMethodTypes); - ScanAttributesForTypes(parameter.GetAttributes(), methodTypes, orderedMethodTypes); + ScanAttributesForTypes(parameter.GetAttributes(), methodTypes, orderedMethodTypes, methodSymbol.ContainingType, compilation, caches); } - ScanAttributesForTypes(methodSymbol.GetAttributes(), methodTypes, orderedMethodTypes); + ScanAttributesForTypes(methodSymbol.GetAttributes(), methodTypes, orderedMethodTypes, methodSymbol.ContainingType, compilation, caches); var classConversions = methodSymbol.ContainingType is { } containingType ? caches.GetClassConversions(containingType, cancellationToken) @@ -180,7 +181,7 @@ private static void AddType(ITypeSymbol type, HashSet typesToScan, } } - private static void ScanAttributesForTypes(ImmutableArray attributes, HashSet typesToScan, List orderedTypes) + private static void ScanAttributesForTypes(ImmutableArray attributes, HashSet typesToScan, List orderedTypes, INamedTypeSymbol containingType, Compilation compilation, CompilationCaches caches) { foreach (var attribute in attributes) { @@ -194,6 +195,8 @@ private static void ScanAttributesForTypes(ImmutableArray attribu continue; } + ScanMethodDataSourceReturnType(attribute, containingType, compilation, caches, typesToScan, orderedTypes); + if (attribute.AttributeClass.IsGenericType) { foreach (var typeArg in attribute.AttributeClass.TypeArguments) @@ -214,6 +217,57 @@ private static void ScanAttributesForTypes(ImmutableArray attribu } } + private static void ScanMethodDataSourceReturnType(AttributeData attribute, INamedTypeSymbol containingType, + Compilation compilation, CompilationCaches caches, HashSet typesToScan, List orderedTypes) + { + // Include derived attributes such as InstanceMethodDataSourceAttribute. + var sourceAttribute = attribute.AttributeClass; + while (sourceAttribute is not null && !caches.IsMethodDataSourceAttribute(sourceAttribute)) + { + sourceAttribute = sourceAttribute.BaseType; + } + + if (sourceAttribute is null || attribute.ConstructorArguments.Length == 0) + { + return; + } + + ITypeSymbol providerType = containingType; + string? memberName = null; + if (attribute.ConstructorArguments is [{ Value: ITypeSymbol explicitType }, { Value: string explicitName }, ..]) + { + providerType = explicitType; + memberName = explicitName; + } + else if (attribute.ConstructorArguments[0].Value is string name) + { + memberName = name; + if (sourceAttribute is { IsGenericType: true, TypeArguments: [var genericProvider] }) + { + providerType = genericProvider; + } + } + + if (memberName is null) + { + return; + } + + // Reuse the factory resolver, including overload selection, inherited members, properties and fields. + var member = TestMetadataGenerator.ResolveMethodDataSourceMember(providerType, memberName, attribute, compilation); + var returnType = member switch + { + IMethodSymbol method => method.ReturnType, + IPropertySymbol property => property.Type, + IFieldSymbol field => field.Type, + _ => null + }; + if (returnType is not null) + { + AddType(returnType, typesToScan, orderedTypes); + } + } + private static void ScanTypedConstantForTypes(TypedConstant constant, HashSet typesToScan, List orderedTypes) { if (constant.IsNull) @@ -239,29 +293,39 @@ private static void ScanTypedConstantForTypes(TypedConstant constant, HashSet conversions, Compilation compilation) + private static void CollectConversionsForType(ITypeSymbol type, List conversions, Compilation compilation, HashSet visited, HashSet expandingSequences) { - if (type is not INamedTypeSymbol namedType) + if (!visited.Add(type)) { return; } - if (!ShouldIncludeType(namedType, compilation)) + if (type is IArrayTypeSymbol arrayType) { + CollectConversionsForType(arrayType.ElementType, conversions, compilation, visited, expandingSequences); return; } - var conversionOperators = namedType.GetMembers() - .OfType() - .Where(m => (m.Name == "op_Implicit" || m.Name == "op_Explicit") && - m is { IsStatic: true, Parameters.Length: 1 }); + if (type is not INamedTypeSymbol namedType) + { + return; + } - foreach (var method in conversionOperators) + // Inaccessible wrappers can still expose accessible sequence elements. + if (ShouldIncludeType(namedType, compilation)) { - var conversionInfo = GetConversionInfoFromSymbol(method, compilation); - if (conversionInfo != null) + var conversionOperators = namedType.GetMembers() + .OfType() + .Where(m => (m.Name == "op_Implicit" || m.Name == "op_Explicit") && + m is { IsStatic: true, Parameters.Length: 1 }); + + foreach (var method in conversionOperators) { - conversions.Add(ToConversionEntry(conversionInfo)); + var conversionInfo = GetConversionInfoFromSymbol(method, compilation); + if (conversionInfo != null) + { + conversions.Add(ToConversionEntry(conversionInfo)); + } } } @@ -269,9 +333,37 @@ private static void CollectConversionsForType(ITypeSymbol type, List : IEnumerable>. + // Track definitions on the current sequence path, while still scanning concrete + // type arguments and operators above, and allowing sibling instantiations. + if (!expandingSequences.Add(namedType.OriginalDefinition)) + { + return; + } + + try + { + // Concrete collections can hide their element type in a base class or interface. + // Only sequence interfaces describe data rows; arbitrary interfaces are not data wrappers. + foreach (var sequence in namedType.AllInterfaces) + { + if (sequence is { Arity: 1, Name: "IEnumerable" or "IAsyncEnumerable" } && + sequence.ContainingNamespace is + { Name: "Generic", ContainingNamespace: { Name: "Collections", ContainingNamespace: + { Name: "System", ContainingNamespace.IsGlobalNamespace: true } } }) + { + CollectConversionsForType(sequence.TypeArguments[0], conversions, compilation, visited, expandingSequences); + } + } + } + finally + { + expandingSequences.Remove(namedType.OriginalDefinition); + } } private static ConversionEntry ToConversionEntry(ConversionInfo conversion) @@ -288,6 +380,10 @@ private static ConversionEntry ToConversionEntry(ConversionInfo conversion) TargetType = ToTypeMetadata(conversion.TargetType), TypesAreDifferent = !SymbolEqualityComparer.Default.Equals(conversion.SourceType, conversion.TargetType), IsImplicit = conversion.IsImplicit, + RoundsDecimalToIntegral = conversion.SourceType.SpecialType == SpecialType.System_Decimal && + conversion.TargetType.SpecialType is SpecialType.System_SByte or SpecialType.System_Byte or + SpecialType.System_Int16 or SpecialType.System_UInt16 or SpecialType.System_Int32 or + SpecialType.System_UInt32 or SpecialType.System_Int64 or SpecialType.System_UInt64, }); } @@ -324,70 +420,6 @@ private static bool ShouldIncludeType(INamedTypeSymbol type, Compilation compila private static bool IsAccessibleType(ITypeSymbol type, Compilation compilation) { - if (type == null || compilation == null) - { - return false; - } - - if (type.SpecialType != SpecialType.None) - { - return true; - } - - if (type.TypeKind == TypeKind.TypeParameter) - { - return true; - } - - if (type is INamedTypeSymbol namedType) - { - var typeAssembly = namedType.ContainingAssembly; - var currentAssembly = compilation.Assembly; - - if (currentAssembly != null && SymbolEqualityComparer.Default.Equals(typeAssembly, currentAssembly)) - { - return true; - } - - if (namedType.DeclaredAccessibility == Accessibility.Public) - { - return true; - } - - if (namedType.DeclaredAccessibility == Accessibility.Internal) - { - if (currentAssembly == null) - { - return false; - } - - if (typeAssembly != null && typeAssembly.GivesAccessTo(currentAssembly)) - { - return true; - } - - return false; - } - - if (namedType.IsGenericType) - { - foreach (var typeArg in namedType.TypeArguments) - { - if (!IsAccessibleType(typeArg, compilation)) - { - return false; - } - } - } - - if (namedType.ContainingType != null) - { - return IsAccessibleType(namedType.ContainingType, compilation); - } - - return false; - } - if (type is IArrayTypeSymbol arrayType) { return IsAccessibleType(arrayType.ElementType, compilation); @@ -398,7 +430,15 @@ private static bool IsAccessibleType(ITypeSymbol type, Compilation compilation) return IsAccessibleType(pointerType.PointedAtType, compilation); } - return false; + // Converters live in a separate top-level class, so same-assembly membership alone + // does not grant access to private/protected types or their containing types. + if (!compilation.IsSymbolAccessibleWithin(type, compilation.Assembly)) + { + return false; + } + + return type is not INamedTypeSymbol namedType || + namedType.TypeArguments.All(argument => IsAccessibleType(argument, compilation)); } private static ConversionInfo? GetConversionInfoFromSymbol(IMethodSymbol methodSymbol, Compilation compilation) @@ -451,6 +491,20 @@ private static bool IsAccessibleType(ITypeSymbol type, Compilation compilation) return null; } + // Match the assignment/cast that will be emitted. An operator on each endpoint + // can make the conversion ambiguous even when the test never needs that pair. + var conversion = ((CSharpCompilation)compilation).ClassifyConversion(sourceType, targetType); + // Decimal exposes operators that Roslyn classifies as built-in numeric + // conversions, with no selected method. They still need AOT registrations. + if (!conversion.Exists || + (isImplicit && !conversion.IsImplicit) || + (conversion.IsUserDefined + ? !SymbolEqualityComparer.Default.Equals(conversion.MethodSymbol, methodSymbol) + : !conversion.IsNumeric)) + { + return null; + } + return new ConversionInfo { SourceType = sourceType, @@ -610,7 +664,12 @@ private static void GenerateConverters(SourceProductionContext context, Equatabl writer.Indent(); // For explicit conversions, we need to use an explicit cast // For implicit conversions, variable assignment works fine - if (conversion.IsImplicit) + if (conversion.RoundsDecimalToIntegral) + { + // Match CastHelper's primitive fallback while preserving registered-converter priority. + writer.AppendLine($"{targetTypeName} converted = ({targetTypeName})global::System.Convert.ChangeType(sourceTypedValue, typeof({targetTypeName}));"); + } + else if (conversion.IsImplicit) { writer.AppendLine($"{targetTypeName} converted = sourceTypedValue;"); } @@ -678,6 +737,7 @@ public record ConversionMetadata public required TypeMetadata TargetType { get; init; } public required bool TypesAreDifferent { get; init; } public required bool IsImplicit { get; init; } + public bool RoundsDecimalToIntegral { get; init; } } /// @@ -703,6 +763,22 @@ private sealed class CompilationCaches(Compilation compilation) { private readonly ConcurrentDictionary _typeConversions = new(SymbolEqualityComparer.IncludeNullability); private readonly ConcurrentDictionary _classConversions = new(SymbolEqualityComparer.Default); + private readonly ConcurrentDictionary _methodSourceDefinitions = new(SymbolEqualityComparer.Default); + + private sealed record MethodSourceDefinitions(INamedTypeSymbol? NonGeneric, INamedTypeSymbol? Generic); + + public bool IsMethodDataSourceAttribute(INamedTypeSymbol attributeType) + { + // Resolve in the bound symbol's assembly: compilation-wide lookup can be ambiguous + // when another reference defines the same metadata name (including extern aliases). + var definitions = _methodSourceDefinitions.GetOrAdd(attributeType.ContainingAssembly, static assembly => + new MethodSourceDefinitions( + assembly.GetTypeByMetadataName("TUnit.Core.MethodDataSourceAttribute"), + assembly.GetTypeByMetadataName("TUnit.Core.MethodDataSourceAttribute`1"))); + return SymbolEqualityComparer.Default.Equals(attributeType.OriginalDefinition, definitions.NonGeneric) || + SymbolEqualityComparer.Default.Equals(attributeType.OriginalDefinition, definitions.Generic); + } + private readonly object _treeIndicesLock = new(); private Dictionary? _treeIndices; @@ -742,7 +818,7 @@ public EquatableArray GetConversions(List types, C if (!_typeConversions.TryGetValue(type, out var typeConversions)) { var collected = new List(); - CollectConversionsForType(type, collected, compilation); + CollectConversionsForType(type, collected, compilation, new HashSet(SymbolEqualityComparer.Default), new HashSet(SymbolEqualityComparer.Default)); typeConversions = _typeConversions.GetOrAdd(type, collected.ToArray()); } @@ -787,7 +863,15 @@ public EquatableArray GetConversions(List types, C var typesToScan = new HashSet(SymbolEqualityComparer.Default); var orderedTypes = new List(); - ScanAttributesForTypes(classSymbol.GetAttributes(), typesToScan, orderedTypes); + ScanAttributesForTypes(classSymbol.GetAttributesIncludingBaseTypes().ToImmutableArray(), typesToScan, orderedTypes, classSymbol, compilation, this); + + foreach (var type in classSymbol.GetSelfAndBaseTypes()) + { + foreach (var property in type.GetMembers().OfType()) + { + ScanAttributesForTypes(property.GetAttributes(), typesToScan, orderedTypes, classSymbol, compilation, this); + } + } foreach (var constructor in classSymbol.Constructors) { @@ -799,7 +883,7 @@ public EquatableArray GetConversions(List types, C foreach (var parameter in constructor.Parameters) { AddType(parameter.Type, typesToScan, orderedTypes); - ScanAttributesForTypes(parameter.GetAttributes(), typesToScan, orderedTypes); + ScanAttributesForTypes(parameter.GetAttributes(), typesToScan, orderedTypes, classSymbol, compilation, this); } } diff --git a/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs b/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs index 85263ee5c63..da9e7800315 100644 --- a/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs +++ b/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs @@ -8361,7 +8361,7 @@ private static string BuildDataSourceCallArguments(IMethodSymbol method, TypedCo /// resolution on the target type would discard a base overload once a derived one is applicable /// (https://github.com/thomhurst/TUnit/issues/7202). /// - private static ISymbol? ResolveMethodDataSourceMember(ITypeSymbol targetType, string memberName, AttributeData attr, Compilation compilation) + internal static ISymbol? ResolveMethodDataSourceMember(ITypeSymbol targetType, string memberName, AttributeData attr, Compilation compilation) { var argumentsProperty = attr.NamedArguments.FirstOrDefault(x => x.Key == "Arguments"); var arguments = argumentsProperty is { Key: not null, Value: { IsNull: false, Kind: TypedConstantKind.Array } } diff --git a/src/TUnit.Core/Helpers/CastHelper.cs b/src/TUnit.Core/Helpers/CastHelper.cs index 497cafe8723..77ff532de38 100644 --- a/src/TUnit.Core/Helpers/CastHelper.cs +++ b/src/TUnit.Core/Helpers/CastHelper.cs @@ -124,6 +124,8 @@ internal static bool TryConvertTrailingArray(Type arrayType, object value, out o /// 1. Fast path: null handling, direct cast, nullable unwrapping /// 2. AOT-safe: AotConverterRegistry, primitives, enums /// 3. Reflection fallback: custom operators, arrays (throws in AOT) + /// Built-in decimal-to-integral conversions round midpoint values to even and throw + /// OverflowException for out-of-range values. Explicitly registered converters take priority. /// [UnconditionalSuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.")] public static object? Cast([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor | DynamicallyAccessedMemberTypes.Interfaces | DynamicallyAccessedMemberTypes.PublicMethods)] Type type, object? value) @@ -197,6 +199,18 @@ private static bool TryAotSafeConversion(Type targetType, Type sourceType, objec return true; } + // Decimal operators truncate integral results, whereas the primitive fallback + // rounds to nearest even. Generated numeric converters use this same policy; + // explicitly registered converters retain priority above. + if (value is decimal && (targetType == typeof(sbyte) || targetType == typeof(byte) || + targetType == typeof(short) || targetType == typeof(ushort) || + targetType == typeof(int) || targetType == typeof(uint) || + targetType == typeof(long) || targetType == typeof(ulong))) + { + result = Convert.ChangeType(value, targetType); + return true; + } + // Handle IConvertible primitives (int, string, double, etc.) if (value is IConvertible && targetType.IsPrimitive) { diff --git a/tests/TUnit.Core.SourceGenerator.Tests/AotConverterScanTests.FindsMethodSourceOnlyConversions.verified.txt b/tests/TUnit.Core.SourceGenerator.Tests/AotConverterScanTests.FindsMethodSourceOnlyConversions.verified.txt new file mode 100644 index 00000000000..4d96d3f321b --- /dev/null +++ b/tests/TUnit.Core.SourceGenerator.Tests/AotConverterScanTests.FindsMethodSourceOnlyConversions.verified.txt @@ -0,0 +1,132 @@ +// +#pragma warning disable + +#nullable enable +using System; +using TUnit.Core.Converters; +namespace TUnit.Generated +{ + internal sealed class AotConverter_0 : IAotConverter + { + public Type SourceType => typeof(global::ClassValue); + public Type TargetType => typeof(int); + public object? Convert(object? value) + { + if (value == null) return null; + if (value is int targetTypedValue) + { + return targetTypedValue; + } + if (value is global::ClassValue sourceTypedValue) + { + int converted = sourceTypedValue; + return converted; + } + return value; // Return original value if type doesn't match + } + } + internal sealed class AotConverter_1 : IAotConverter + { + public Type SourceType => typeof(global::ScalarValue); + public Type TargetType => typeof(int); + public object? Convert(object? value) + { + if (value == null) return null; + if (value is int targetTypedValue) + { + return targetTypedValue; + } + if (value is global::ScalarValue sourceTypedValue) + { + int converted = sourceTypedValue; + return converted; + } + return value; // Return original value if type doesn't match + } + } + internal sealed class AotConverter_2 : IAotConverter + { + public Type SourceType => typeof(global::TupleValue); + public Type TargetType => typeof(global::System.ValueTuple); + public object? Convert(object? value) + { + if (value == null) return null; + if (value is global::System.ValueTuple targetTypedValue) + { + return targetTypedValue; + } + if (value is global::TupleValue sourceTypedValue) + { + global::System.ValueTuple converted = (global::System.ValueTuple)sourceTypedValue; + return converted; + } + return value; // Return original value if type doesn't match + } + } + internal sealed class AotConverter_3 : IAotConverter + { + public Type SourceType => typeof(global::ArrayValue); + public Type TargetType => typeof(int); + public object? Convert(object? value) + { + if (value == null) return null; + if (value is int targetTypedValue) + { + return targetTypedValue; + } + if (value is global::ArrayValue sourceTypedValue) + { + int converted = sourceTypedValue; + return converted; + } + return value; // Return original value if type doesn't match + } + } + internal sealed class AotConverter_4 : IAotConverter + { + public Type SourceType => typeof(global::AsyncValue); + public Type TargetType => typeof(int); + public object? Convert(object? value) + { + if (value == null) return null; + if (value is int targetTypedValue) + { + return targetTypedValue; + } + if (value is global::AsyncValue sourceTypedValue) + { + int converted = (int)sourceTypedValue; + return converted; + } + return value; // Return original value if type doesn't match + } + } + internal sealed class AotConverter_5 : IAotConverter + { + public Type SourceType => typeof(global::ParameterValue); + public Type TargetType => typeof(int); + public object? Convert(object? value) + { + if (value == null) return null; + if (value is int targetTypedValue) + { + return targetTypedValue; + } + if (value is global::ParameterValue sourceTypedValue) + { + int converted = sourceTypedValue; + return converted; + } + return value; // Return original value if type doesn't match + } + } + internal static partial class TUnit_ConverterRegistration + { + static readonly int _r_AotConverter_0 = global::TUnit.Core.Converters.AotConverterRegistry.Register(new AotConverter_0()); + static readonly int _r_AotConverter_1 = global::TUnit.Core.Converters.AotConverterRegistry.Register(new AotConverter_1()); + static readonly int _r_AotConverter_2 = global::TUnit.Core.Converters.AotConverterRegistry.Register(new AotConverter_2()); + static readonly int _r_AotConverter_3 = global::TUnit.Core.Converters.AotConverterRegistry.Register(new AotConverter_3()); + static readonly int _r_AotConverter_4 = global::TUnit.Core.Converters.AotConverterRegistry.Register(new AotConverter_4()); + static readonly int _r_AotConverter_5 = global::TUnit.Core.Converters.AotConverterRegistry.Register(new AotConverter_5()); + } +} diff --git a/tests/TUnit.Core.SourceGenerator.Tests/AotConverterScanTests.cs b/tests/TUnit.Core.SourceGenerator.Tests/AotConverterScanTests.cs index 081aef2ec99..582029fa387 100644 --- a/tests/TUnit.Core.SourceGenerator.Tests/AotConverterScanTests.cs +++ b/tests/TUnit.Core.SourceGenerator.Tests/AotConverterScanTests.cs @@ -6,6 +6,287 @@ namespace TUnit.Core.SourceGenerator.Tests; public class AotConverterScanTests { + [Test] + public async Task ExplicitDecimalConverterTakesPriority() + { + // This assembly has no decimal data sources, so the converter is registered manually. + TUnit.Core.Converters.AotConverterRegistry.Register(value => (int)value); + await Assert.That(TUnit.Core.Helpers.CastHelper.Cast(1.9m)).IsEqualTo(1); + } + + [Test] + public async Task PreservesBuiltInDecimalConversions() + { + const string source = """ + using TUnit.Core; + public class Tests + { + [Test, Arguments('A')] + public void Decimal(decimal value) { } + } + """; + var parseOptions = new CSharpParseOptions(LanguageVersion.Preview); + var compilation = CSharpCompilation.Create("DecimalConverters", + [CSharpSyntaxTree.ParseText(source, parseOptions)], ReferencesHelper.References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + GeneratorDriver driver = CSharpGeneratorDriver.Create( + [new AotConverterGenerator().AsSourceGenerator()], parseOptions: parseOptions); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var output, out var diagnostics); + + await Assert.That(diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error)).IsEmpty(); + await Assert.That(output.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error)).IsEmpty(); + var generated = string.Join("\n", driver.GetRunResult().GeneratedTrees.Select(t => t.ToString())); + await Assert.That(System.Text.RegularExpressions.Regex.IsMatch(generated, @"SourceType => typeof\(char\);\s+public Type TargetType => typeof\(decimal\);")).IsTrue(); + await Assert.That(System.Text.RegularExpressions.Regex.IsMatch(generated, @"SourceType => typeof\(decimal\);\s+public Type TargetType => typeof\(char\);")).IsTrue(); + await Assert.That(generated.Contains("int converted = (int)global::System.Convert.ChangeType(sourceTypedValue, typeof(int));")).IsTrue(); + } + + [Test] + public async Task HandlesInheritedPropertyAndAmbiguousSources() + { + const string providerSource = """ + public struct HiddenRow { public static implicit operator int(HiddenRow value) => 44; } + public struct ClassRow { public static implicit operator int(ClassRow value) => 42; } + public class Provider + { + protected class HiddenRows : System.Collections.Generic.List { } + protected static HiddenRows Hidden() => new(); + public static ClassRow ClassData() => new(); + } + """; + const string source = """ + using TUnit.Core; + public struct PropertyRow { public static implicit operator int(PropertyRow value) => 43; } + public struct AmbiguousRow { public static implicit operator Target(AmbiguousRow value) => new(); } + public struct Target { public static implicit operator Target(AmbiguousRow value) => new(); } + public struct ExplicitRow { public static explicit operator ExplicitTarget(ExplicitRow value) => new(); } + public struct ExplicitTarget { public static explicit operator ExplicitTarget(ExplicitRow value) => new(); } + public struct LocalRow { public static implicit operator int(LocalRow value) => 45; } + [MethodDataSource(typeof(Provider), nameof(Provider.ClassData))] + public class Base + { + [MethodDataSource("PropertyData")] + public int Value { get; set; } + } + public class Tests : Base + { + public Tests(int value) { } + public static PropertyRow PropertyData() => new(); + public static AmbiguousRow Rows() => new(); + public static ExplicitRow ExplicitRows() => new(); + [Test, MethodDataSource(nameof(Rows))] + public void Ambiguous(object value) { } + [Test, MethodDataSource(nameof(ExplicitRows))] + public void Explicit(object value) { } + [Test, MethodDataSource(typeof(Provider), "Hidden")] + public void Hidden(int value) { } + [MethodDataSource(nameof(LocalData))] + public int Local { get; set; } + public static LocalRow LocalData() => new(); + } + """; + var parseOptions = new CSharpParseOptions(LanguageVersion.Preview); + var provider = CSharpCompilation.Create("ExternalProvider", + [CSharpSyntaxTree.ParseText(providerSource, parseOptions)], ReferencesHelper.References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + using var image = new MemoryStream(); + await Assert.That(provider.Emit(image).Success).IsTrue(); + var compilation = CSharpCompilation.Create("InheritedAndAmbiguousSources", + [CSharpSyntaxTree.ParseText(source, parseOptions)], + ReferencesHelper.References.Append(MetadataReference.CreateFromImage(image.ToArray())), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + GeneratorDriver driver = CSharpGeneratorDriver.Create( + [new AotConverterGenerator().AsSourceGenerator()], parseOptions: parseOptions); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var output, out var diagnostics); + + await Assert.That(diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error)).IsEmpty(); + await Assert.That(output.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error)).IsEmpty(); + var generated = string.Join("\n", driver.GetRunResult().GeneratedTrees.Select(t => t.ToString())); + foreach (var type in new[] { "ClassRow", "PropertyRow", "LocalRow", "HiddenRow" }) + { + await Assert.That(generated).Contains("typeof(global::" + type + ")"); + } + await Assert.That(generated).DoesNotContain("AmbiguousRow"); + await Assert.That(generated).DoesNotContain("ExplicitRow"); + await Assert.That(generated).DoesNotContain("HiddenRows"); + } + + [Test] + public async Task FindsMethodSourceWhenMetadataNamesAreAmbiguous() + { + var parseOptions = new CSharpParseOptions(LanguageVersion.Preview); + var options = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary); + var duplicate = CSharpCompilation.Create("DuplicateAttributes", + [CSharpSyntaxTree.ParseText(""" + namespace TUnit.Core + { + public class MethodDataSourceAttribute : System.Attribute { } + public class MethodDataSourceAttribute : System.Attribute { } + } + """, parseOptions)], ReferencesHelper.References, options); + using var image = new MemoryStream(); + var emit = duplicate.Emit(image); + await Assert.That(emit.Success).IsTrue(); + + var references = ReferencesHelper.References.Select(reference => + Path.GetFileName(reference.FilePath) == "TUnit.Core.dll" + ? reference.WithAliases(["global", "core"]) + : reference).Append(MetadataReference.CreateFromImage(image.ToArray())); + var compilation = CSharpCompilation.Create("AmbiguousMethodSources", + [CSharpSyntaxTree.ParseText(""" + extern alias core; + using TUnit.Core; + public struct ScalarValue { public static implicit operator int(ScalarValue value) => 42; } + public struct GenericValue { public static implicit operator int(GenericValue value) => 43; } + public class Tests + { + public static ScalarValue Rows() => new(); + public static GenericValue GenericRows() => new(); + [Test, core::TUnit.Core.MethodDataSource(nameof(Rows))] + public void Scalar(int value) { } + [Test, core::TUnit.Core.MethodDataSource(nameof(GenericRows))] + public void Generic(int value) { } + } + """, parseOptions)], references, options); + await Assert.That(compilation.GetTypeByMetadataName("TUnit.Core.MethodDataSourceAttribute")).IsNull(); + await Assert.That(compilation.GetTypeByMetadataName("TUnit.Core.MethodDataSourceAttribute`1")).IsNull(); + GeneratorDriver driver = CSharpGeneratorDriver.Create( + [new AotConverterGenerator().AsSourceGenerator()], parseOptions: parseOptions); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var output, out var diagnostics); + + await Assert.That(diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error)).IsEmpty(); + await Assert.That(output.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error)).IsEmpty(); + var generated = string.Join("\n", driver.GetRunResult().GeneratedTrees.Select(t => t.ToString())); + await Assert.That(generated).Contains("typeof(global::ScalarValue)"); + await Assert.That(generated).Contains("typeof(global::GenericValue)"); + } + + [Test] + public async Task HandlesConcreteCollectionsAndInaccessibleSourceTypes() + { + const string source = """ + using System.Collections.Generic; + using TUnit.Core; + internal struct CollectionValue { public static implicit operator int(CollectionValue value) => 42; } + internal class Values : List { } + internal struct RecursiveValue { public static implicit operator int(RecursiveValue value) => 42; } + internal class Rows : IEnumerable> + { + public IEnumerator> GetEnumerator() => throw new System.NotSupportedException(); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } + public class Box { public static implicit operator int(Box value) => 42; } + internal class Tests + { + private struct PrivateValue { public static implicit operator int(PrivateValue value) => 42; } + private class Hidden + { + public struct NestedValue { public static implicit operator int(NestedValue value) => 42; } + } + private static PrivateValue PrivateRows() => new(); + private static Hidden.NestedValue NestedRows() => new(); + private static Box GenericRows() => new(); + private static Values ConcreteRows() => new(); + private static Rows RecursiveRows() => new(); + [Test, MethodDataSource(nameof(RecursiveRows))] + public void Recursive(object value) { } + [Test, MethodDataSource(nameof(PrivateRows))] + public void Private(int value) { } + [Test, MethodDataSource(nameof(NestedRows))] + public void Nested(int value) { } + [Test, MethodDataSource(nameof(GenericRows))] + public void Generic(int value) { } + [Test, MethodDataSource(nameof(ConcreteRows))] + public void Concrete(int value) { } + [Test, MethodDataSource("Missing")] + public void Missing(int value) { } + [Test, MethodDataSource(typeof(List<>), "Missing")] + public void OpenGeneric(int value) { } + } + """; + var parseOptions = new CSharpParseOptions(LanguageVersion.Preview); + var compilation = CSharpCompilation.Create( + "ConcreteAndPrivateSources", + [CSharpSyntaxTree.ParseText(source, parseOptions)], + ReferencesHelper.References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + GeneratorDriver driver = CSharpGeneratorDriver.Create( + [new AotConverterGenerator().AsSourceGenerator()], parseOptions: parseOptions); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var output, out var diagnostics); + + await Assert.That(diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error)).IsEmpty(); + await Assert.That(output.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error)).IsEmpty(); + var generated = string.Join("\n", driver.GetRunResult().GeneratedTrees.Select(t => t.ToString())); + await Assert.That(generated).Contains("typeof(global::CollectionValue)"); + await Assert.That(generated).Contains("typeof(global::RecursiveValue)"); + await Assert.That(generated).DoesNotContain("PrivateValue"); + await Assert.That(generated).DoesNotContain("NestedValue"); + } + + [Test] + public async Task FindsMethodSourceOnlyConversions() + { + const string source = """ + using System.Collections.Generic; + using System.Threading.Tasks; + using TUnit.Core; + public struct ScalarValue { public static implicit operator int(ScalarValue value) => 42; } + public struct TupleValue { public static explicit operator (int, int)(TupleValue value) => (42, 43); } + public struct ArrayValue { public static implicit operator int(ArrayValue value) => 42; } + public struct AsyncValue { public static explicit operator int(AsyncValue value) => 42; } + public struct ClassValue { public static implicit operator int(ClassValue value) => 42; } + public struct ParameterValue { public static implicit operator int(ParameterValue value) => 42; } + public struct UnusedValue { public static implicit operator int(UnusedValue value) => 42; } + public class ProviderBase + { + public static ScalarValue Rows(int count) => new(); + public static UnusedValue Rows(string count) => new(); + } + public class Provider : ProviderBase + { + public static Task> Tuples() => null; + public static ArrayValue[] Array => null; + public static ValueTask> AsyncRows = default; + public static ClassValue ClassRows() => new(); + public static ParameterValue ParameterRows() => new(); + } + [MethodDataSource(typeof(Provider), nameof(Provider.ClassRows))] + public class Tests + { + public Tests(int value) { } + [Test, MethodDataSource(nameof(Provider.Rows), Arguments = new object[] { 1 })] + public void Scalar(int value) { } + [Test, MethodDataSource(typeof(Provider), nameof(Provider.Tuples))] + public void Tuples((int, int) value, int other) { } + [Test, MethodDataSource(typeof(Provider), nameof(Provider.Array))] + public void Array(int value) { } + [Test, MethodDataSource(typeof(Provider), nameof(Provider.AsyncRows))] + public void Async(int value) { } + [Test] + public void Parameter([MethodDataSource(typeof(Provider), nameof(Provider.ParameterRows))] int value) { } + } + """; + var parseOptions = new CSharpParseOptions(LanguageVersion.Preview); + var compilation = CSharpCompilation.Create( + "MethodSourceConverters", + [CSharpSyntaxTree.ParseText(source, parseOptions)], + ReferencesHelper.References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + GeneratorDriver driver = CSharpGeneratorDriver.Create( + [new AotConverterGenerator().AsSourceGenerator()], parseOptions: parseOptions); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var output, out var diagnostics); + + await Assert.That(diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error)).IsEmpty(); + await Assert.That(output.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error)).IsEmpty(); + var generated = string.Join("\n", driver.GetRunResult().GeneratedTrees.Select(t => t.ToString())); + foreach (var type in new[] { "ScalarValue", "TupleValue", "ArrayValue", "AsyncValue", "ClassValue", "ParameterValue" }) + { + await Assert.That(generated).Contains("typeof(global::" + type + ")"); + } + await Assert.That(generated).DoesNotContain("global::UnusedValue"); + await Verify(generated); + } + [Test] public async Task FindsConversionsInNestedAndPartialDeclarations() { diff --git a/tests/TUnit.Core.SourceGenerator.Tests/ClassArgumentParameterTypesTests.Test.verified.txt b/tests/TUnit.Core.SourceGenerator.Tests/ClassArgumentParameterTypesTests.Test.verified.txt index 7c76165291c..0979e1d5d5d 100644 --- a/tests/TUnit.Core.SourceGenerator.Tests/ClassArgumentParameterTypesTests.Test.verified.txt +++ b/tests/TUnit.Core.SourceGenerator.Tests/ClassArgumentParameterTypesTests.Test.verified.txt @@ -200,7 +200,7 @@ internal static class TUnit_TestProject_Bugs__7440_RequiredConstructorArgumentTe Name = "Value", IsStatic = false, IsNullable = false, - Getter = o => ((dynamic)o).Value, + Getter = global::TUnit.Core.PropertyMetadataFactory.CreateGenericGetter(typeof(global::TUnit.TestProject.Bugs._7440.RequiredConstructorArgumentTests<>), "Value"), ClassMetadata = null!, ContainingTypeMetadata = null! }, @@ -211,7 +211,7 @@ internal static class TUnit_TestProject_Bugs__7440_RequiredConstructorArgumentTe Name = "Number", IsStatic = false, IsNullable = false, - Getter = o => ((dynamic)o).Number, + Getter = global::TUnit.Core.PropertyMetadataFactory.CreateGenericGetter(typeof(global::TUnit.TestProject.Bugs._7440.RequiredConstructorArgumentTests<>), "Number"), ClassMetadata = null!, ContainingTypeMetadata = null! } @@ -933,7 +933,7 @@ internal static class TUnit_TestProject_Bugs__7440_SingleGenericRequiredFieldTes Name = "Property", IsStatic = false, IsNullable = false, - Getter = o => ((dynamic)o).Property, + Getter = global::TUnit.Core.PropertyMetadataFactory.CreateGenericGetter(typeof(global::TUnit.TestProject.Bugs._7440.SingleGenericRequiredFieldTests<>), "Property"), ClassMetadata = null!, ContainingTypeMetadata = null! } diff --git a/tests/TUnit.Core.SourceGenerator.Tests/GenericPropertyMetadataTests.GeneratesCachedAotCompatibleGetters.verified.txt b/tests/TUnit.Core.SourceGenerator.Tests/GenericPropertyMetadataTests.GeneratesCachedAotCompatibleGetters.verified.txt index 9e18eb144c4..d70c7c2b52d 100644 --- a/tests/TUnit.Core.SourceGenerator.Tests/GenericPropertyMetadataTests.GeneratesCachedAotCompatibleGetters.verified.txt +++ b/tests/TUnit.Core.SourceGenerator.Tests/GenericPropertyMetadataTests.GeneratesCachedAotCompatibleGetters.verified.txt @@ -60,7 +60,7 @@ internal static class TUnit_TestProject_Bugs__7499_GenericPropertyMetadataTests_ private static readonly global::TUnit.Core.MethodMetadata __mm_0 = global::TUnit.Core.MethodMetadataFactory.Create("MetadataGettersReadClosedInstance", __classType, typeof(global::System.Threading.Tasks.Task), __classMetadata); private static global::TUnit.TestProject.Bugs._7499.GenericPropertyMetadataTests __CreateInstance_0(global::System.Type[] typeArgs, object?[] args) { - return (global::TUnit.TestProject.Bugs._7499.GenericPropertyMetadataTests)global::System.Activator.CreateInstance(typeof(global::TUnit.TestProject.Bugs._7499.GenericPropertyMetadataTests), args)!; + return new global::TUnit.TestProject.Bugs._7499.GenericPropertyMetadataTests(global::TUnit.Core.Helpers.CastHelper.Cast(args[0])); } private static global::System.Threading.Tasks.ValueTask __Invoke_0(global::TUnit.TestProject.Bugs._7499.GenericPropertyMetadataTests instance, int methodIndex, object?[] args, global::System.Threading.CancellationToken cancellationToken) { @@ -106,7 +106,7 @@ internal static class TUnit_TestProject_Bugs__7499_GenericPropertyMetadataTests_ }; private static global::TUnit.TestProject.Bugs._7499.GenericPropertyMetadataTests __CreateInstance_1(global::System.Type[] typeArgs, object?[] args) { - return (global::TUnit.TestProject.Bugs._7499.GenericPropertyMetadataTests)global::System.Activator.CreateInstance(typeof(global::TUnit.TestProject.Bugs._7499.GenericPropertyMetadataTests), new object[] { 42 })!; + return new global::TUnit.TestProject.Bugs._7499.GenericPropertyMetadataTests(global::TUnit.Core.Helpers.CastHelper.Cast(args[0])); } private static global::System.Threading.Tasks.ValueTask __Invoke_1(global::TUnit.TestProject.Bugs._7499.GenericPropertyMetadataTests instance, int methodIndex, object?[] args, global::System.Threading.CancellationToken cancellationToken) { diff --git a/tests/TUnit.TestProject/Bugs/_7511/MethodSourceConversionTests.cs b/tests/TUnit.TestProject/Bugs/_7511/MethodSourceConversionTests.cs new file mode 100644 index 00000000000..fb8d6826a76 --- /dev/null +++ b/tests/TUnit.TestProject/Bugs/_7511/MethodSourceConversionTests.cs @@ -0,0 +1,190 @@ +namespace TUnit.TestProject.Bugs._7511; + +public sealed class SourceValue +{ + public static implicit operator int(SourceValue value) => 42; +} + +public sealed class ExplicitSourceValue +{ + public static explicit operator int(ExplicitSourceValue value) => 43; +} + +public sealed class TupleSourceValue +{ + public static implicit operator (int, int)(TupleSourceValue value) => (42, 43); +} + +public sealed class ExplicitTupleSourceValue +{ + public static explicit operator (int, int)(ExplicitTupleSourceValue value) => (44, 45); +} + +public sealed class ConcreteValues : List; + +public class MethodSourceConversionTests +{ + public static ConcreteValues Concrete() => [new()]; + public static SourceValue Scalar() => new(); + public static ExplicitSourceValue Explicit() => new(); + public static (TupleSourceValue, int) Tuple() => (new(), 46); + public static (ExplicitTupleSourceValue, int) ExplicitTuple() => (new(), 47); + public static SourceValue[] Array() => [new()]; + public static IEnumerable Enumerable() => [new()]; + public static Task Async() => Task.FromResult(new SourceValue()); + public static ValueTask ValueAsync() => new(new SourceValue()); + public static Task> AsyncEnumerable() => Task.FromResult>([new()]); + public static async IAsyncEnumerable AsyncRows() + { + await Task.Yield(); + yield return new(); + } + + [Test, MethodDataSource(nameof(Scalar))] + [MethodDataSource(nameof(Concrete))] + [MethodDataSource(nameof(Array))] + [MethodDataSource(nameof(Enumerable))] + [MethodDataSource(nameof(Async))] + [MethodDataSource(nameof(ValueAsync))] + [MethodDataSource(nameof(AsyncEnumerable))] + [MethodDataSource(nameof(AsyncRows))] + public async Task Converts(int value) => await Assert.That(value).IsEqualTo(42); + + // Explicit conversions are supported at runtime; analyzer policy currently requires implicit conversion. +#pragma warning disable TUnit0001 + [Test, MethodDataSource(nameof(Explicit))] +#pragma warning restore TUnit0001 + public async Task ConvertsExplicit(int value) => await Assert.That(value).IsEqualTo(43); + + [Test, MethodDataSource(nameof(Tuple))] + public async Task ConvertsTuple((int, int) value, int other) + { + await Assert.That(value).IsEqualTo((42, 43)); + await Assert.That(other).IsEqualTo(46); + } + +#pragma warning disable TUnit0001 + [Test, MethodDataSource(nameof(ExplicitTuple))] +#pragma warning restore TUnit0001 + public async Task ConvertsExplicitTuple((int, int) value, int other) + { + await Assert.That(value).IsEqualTo((44, 45)); + await Assert.That(other).IsEqualTo(47); + } +} + +public sealed class InheritedClassSourceValue +{ + public static implicit operator int(InheritedClassSourceValue value) => 48; +} + +[MethodDataSource(typeof(InheritedClassSourceBase), nameof(InheritedClassSourceBase.Rows))] +public abstract class InheritedClassSourceBase +{ + public static InheritedClassSourceValue Rows() => new(); +} + +public class InheritedClassSourceTests(int value) : InheritedClassSourceBase +{ + [Test] + public async Task ConvertsInheritedSource() => await Assert.That(value).IsEqualTo(48); +} + +public sealed class PropertySourceValue +{ + public static implicit operator int(PropertySourceValue value) => 49; +} + +public abstract class PropertySourceBase +{ + // The source is supplied by the derived test class; analyzer support is tracked in #7551. +#pragma warning disable TUnit0004 + [MethodDataSource("Rows")] +#pragma warning restore TUnit0004 + public int Value { get; set; } +} + +public class PropertySourceConversionTests : PropertySourceBase +{ + public static PropertySourceValue Rows() => new(); + + [Test] + public async Task ConvertsInheritedProperty() => await Assert.That(Value).IsEqualTo(49); +} + +public class DecimalSourceConversionTests +{ + [Test] + public async Task DecimalOverflowThrows() + { + await Assert.That(() => TUnit.Core.Helpers.CastHelper.Cast(decimal.MaxValue)) + .Throws(); + } + + public static (decimal, int)[] Rows() => [(1.9m, 2), (2.5m, 2), (3.5m, 4)]; + +#pragma warning disable TUnit0001 + [Test, MethodDataSource(nameof(Rows))] +#pragma warning restore TUnit0001 + public async Task ConvertsToSbyte(sbyte value, int expected) + { + await Assert.That((long)value).IsEqualTo(expected); + } + +#pragma warning disable TUnit0001 + [Test, MethodDataSource(nameof(Rows))] +#pragma warning restore TUnit0001 + public async Task ConvertsToByte(byte value, int expected) + { + await Assert.That((long)value).IsEqualTo(expected); + } + +#pragma warning disable TUnit0001 + [Test, MethodDataSource(nameof(Rows))] +#pragma warning restore TUnit0001 + public async Task ConvertsToShort(short value, int expected) + { + await Assert.That((long)value).IsEqualTo(expected); + } + +#pragma warning disable TUnit0001 + [Test, MethodDataSource(nameof(Rows))] +#pragma warning restore TUnit0001 + public async Task ConvertsToUshort(ushort value, int expected) + { + await Assert.That((long)value).IsEqualTo(expected); + } + +#pragma warning disable TUnit0001 + [Test, MethodDataSource(nameof(Rows))] +#pragma warning restore TUnit0001 + public async Task ConvertsToInt(int value, int expected) + { + await Assert.That((long)value).IsEqualTo(expected); + } + +#pragma warning disable TUnit0001 + [Test, MethodDataSource(nameof(Rows))] +#pragma warning restore TUnit0001 + public async Task ConvertsToUint(uint value, int expected) + { + await Assert.That((long)value).IsEqualTo(expected); + } + +#pragma warning disable TUnit0001 + [Test, MethodDataSource(nameof(Rows))] +#pragma warning restore TUnit0001 + public async Task ConvertsToLong(long value, int expected) + { + await Assert.That((long)value).IsEqualTo(expected); + } + +#pragma warning disable TUnit0001 + [Test, MethodDataSource(nameof(Rows))] +#pragma warning restore TUnit0001 + public async Task ConvertsToUlong(ulong value, int expected) + { + await Assert.That((long)value).IsEqualTo(expected); + } + +}