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
4 changes: 2 additions & 2 deletions src/TUnit.Core/DataGeneratorMetadataCreator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ private static ClassMetadata GetClassMetadataForType(Type type)
ReflectionInfo = p
}).ToArray() ?? [];

return new ClassMetadata
return PropertyInjection.ClassMetadataHelper.WithProperties(new ClassMetadata
{
Type = type,
TypeInfo = new ConcreteType(type),
Expand All @@ -338,7 +338,7 @@ private static ClassMetadata GetClassMetadataForType(Type type)
Properties = [],
Parameters = constructorParameters,
Parent = type.DeclaringType != null ? GetClassMetadataForType(type.DeclaringType) : null
};
});
});
}
}
91 changes: 88 additions & 3 deletions src/TUnit.Core/PropertyInjection/ClassMetadataHelper.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;

namespace TUnit.Core.PropertyInjection;
Expand All @@ -9,6 +10,8 @@ namespace TUnit.Core.PropertyInjection;
/// </summary>
internal static class ClassMetadataHelper
{
private static readonly ConcurrentDictionary<Type, PropertyInfo[]> Properties = new();

/// <summary>
/// Gets or creates ClassMetadata for the specified type.
/// </summary>
Expand All @@ -33,7 +36,7 @@ public static ClassMetadata GetOrCreateClassMetadata(
ReflectionInfo = p
}).ToArray() ?? [];

return new ClassMetadata
return WithProperties(new ClassMetadata
{
Type = type,
TypeInfo = new ConcreteType(type),
Expand All @@ -48,10 +51,92 @@ public static ClassMetadata GetOrCreateClassMetadata(
Properties = [],
Parameters = constructorParameters,
Parent = type.DeclaringType != null ? GetOrCreateClassMetadata(type.DeclaringType) : null
};
});
Comment thread
thomhurst marked this conversation as resolved.
Resolved
});
}

// Match generated metadata: properties declared on this type, including static properties.
// Called inside the class cache factory, so back-references never recurse through that cache.
#if NET8_0_OR_GREATER
[RequiresUnreferencedCode("Property metadata creation requires reflection")]
#endif
public static ClassMetadata WithProperties(ClassMetadata metadata)
{
if (!Properties.TryGetValue(metadata.Type, out var properties))
{
properties = metadata.Type.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)
.Where(p => p.Name != "EqualityContract").ToArray();
properties = Properties.GetOrAdd(metadata.Type, properties);
}
var result = metadata with
{
Properties = properties.Select(p => new PropertyMetadata
{
Name = p.Name,
Type = p.PropertyType,
ReflectionInfo = p,
IsStatic = (p.GetMethod ?? p.SetMethod)!.IsStatic,
IsNullable = IsNullable(p),
Getter = instance => p.GetValue(instance),
ClassMetadata = null!,
ContainingTypeMetadata = null!
}).ToArray()
};
foreach (var property in result.Properties)
{
property.ClassMetadata = result;
property.ContainingTypeMetadata = result;
}
return result;
}

// Read compiler annotations directly so the netstandard build has the same behavior
// without adding a dependency on NullabilityInfoContext (unavailable on that target).
private static bool IsNullable(PropertyInfo property)
{
if (property.PropertyType.IsValueType)
{
return Nullable.GetUnderlyingType(property.PropertyType) != null;
}

var annotation = GetNullableFlag(property.GetCustomAttributesData(), "System.Runtime.CompilerServices.NullableAttribute");
if (annotation != null)
{
return annotation == 2;
}

if (property.GetMethod != null)
{
annotation = GetNullableFlag(property.GetMethod.GetCustomAttributesData(), "System.Runtime.CompilerServices.NullableContextAttribute");
}
for (var type = property.DeclaringType; annotation == null && type != null; type = type.DeclaringType)
{
annotation = GetNullableFlag(type.GetCustomAttributesData(), "System.Runtime.CompilerServices.NullableContextAttribute");
}
return annotation == 2;
}

private static byte? GetNullableFlag(IList<CustomAttributeData> attributes, string attributeName)
{
foreach (var attribute in attributes)
{
if (attribute.AttributeType.FullName != attributeName || attribute.ConstructorArguments.Count != 1)
{
continue;
}
var value = attribute.ConstructorArguments[0].Value;
if (value is byte flag)
{
return flag;
}
if (value is IList<CustomAttributeTypedArgument> flags && flags.Count > 0 && flags[0].Value is byte first)
{
return first;
}
}
return null;
}

/// <summary>
/// The public constructor whose parameters describe a class's <see cref="ClassMetadata.Parameters"/>: the one
/// marked <c>[TestConstructor]</c>, which the test instance is built with, otherwise the first one. Every
Expand Down
4 changes: 2 additions & 2 deletions src/TUnit.Engine/Building/ReflectionMetadataBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ private static ClassMetadata CreateClassMetadata([DynamicallyAccessedMembers(Dyn
? []
: ParameterMetadataFactory.Build(constructor.GetParameters(), nameFallback: null, computeIsNullable: true);

return new ClassMetadata
return TUnit.Core.PropertyInjection.ClassMetadataHelper.WithProperties(new ClassMetadata
{
Name = TypeNameFormatter.GetNameWithoutGenericArity(type),
Type = type,
Expand All @@ -74,7 +74,7 @@ private static ClassMetadata CreateClassMetadata([DynamicallyAccessedMembers(Dyn
Parameters = constructorParameters,
Properties = [],
Parent = type.DeclaringType != null ? CreateClassMetadata(type.DeclaringType) : null
};
});
});
}
}
4 changes: 2 additions & 2 deletions src/TUnit.Engine/Discovery/ReflectionHookDiscoveryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -895,7 +895,7 @@ public int CompareTo(HookMethodSortKey other)

private static ClassMetadata CreateClassMetadata(Type type)
{
return ClassMetadata.GetOrAdd(type.FullName ?? type.Name, () => new ClassMetadata
return ClassMetadata.GetOrAdd(type.FullName ?? type.Name, () => TUnit.Core.PropertyInjection.ClassMetadataHelper.WithProperties(new ClassMetadata
{
Name = TypeNameFormatter.GetNameWithoutGenericArity(type),
Type = type,
Expand All @@ -908,7 +908,7 @@ private static ClassMetadata CreateClassMetadata(Type type)
Parameters = [],
Properties = [],
Parent = type.DeclaringType != null ? CreateClassMetadata(type.DeclaringType) : null
});
}));
}

private static Func<object, TestContext, CancellationToken, ValueTask> CreateInstanceHookDelegate(Type type, MethodInfo method)
Expand Down
4 changes: 2 additions & 2 deletions src/TUnit.Engine/Discovery/ReflectionInstanceFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ private static DataGeneratorMetadata CreatePropertyMetadata(PropertyInfo propert

private static ClassMetadata CreateClassMetadata(Type type)
{
return ClassMetadata.GetOrAdd(type.FullName ?? type.Name, () => new ClassMetadata
return ClassMetadata.GetOrAdd(type.FullName ?? type.Name, () => TUnit.Core.PropertyInjection.ClassMetadataHelper.WithProperties(new ClassMetadata
{
Type = type,
TypeInfo = new ConcreteType(type),
Expand All @@ -180,7 +180,7 @@ private static ClassMetadata CreateClassMetadata(Type type)
Properties = [],
Parameters = [],
Parent = type.DeclaringType != null ? CreateClassMetadata(type.DeclaringType) : null
});
}));
}

private static bool IsFunc(Type type)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,10 @@ public async Task MetadataGettersReadClosedInstance()
await Assert.That(valueGetter(this)).IsEqualTo(Value);
await Assert.That(labelGetter(this)).IsEqualTo("label");
await Assert.That(optionalGetter(this)).IsEqualTo(Optional);

var properties = TestContext.Current!.Metadata.TestDetails.MethodMetadata.Class.Properties;
await Assert.That(properties.Single(p => p.Name == nameof(Value)).Getter(this)).IsEqualTo(Value);
await Assert.That(properties.Single(p => p.Name == nameof(Label)).Getter(this)).IsEqualTo(Label);
await Assert.That(properties.Single(p => p.Name == nameof(Optional)).Getter(this)).IsEqualTo(Optional);
}
}
40 changes: 40 additions & 0 deletions tests/TUnit.TestProject/Bugs/_7522/PropertyMetadataTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using TUnit.TestProject.Attributes;

namespace TUnit.TestProject.Bugs._7522;

[EngineTest(ExpectedResult.Pass)]
public class PropertyMetadataTests
{
public string Value => "value";
public static int StaticValue => 42;
public int? Optional => null;
public string? OptionalText => null;
public List<string?> Items => [];
private string Hidden => "hidden";

[Test]
public async Task PublicPropertiesHaveUsableMetadata()
{
var metadata = TestContext.Current!.Metadata.TestDetails.MethodMetadata.Class;
await Assert.That(metadata.Properties.Length).IsEqualTo(5);
foreach (var property in metadata.Properties)
{
await Assert.That(ReferenceEquals(property.ClassMetadata, metadata)).IsTrue();
await Assert.That(ReferenceEquals(property.ContainingTypeMetadata, metadata)).IsTrue();
await Assert.That(property.ReflectionInfo.Name).IsEqualTo(property.Name);
}
var value = metadata.Properties.Single(p => p.Name == nameof(Value));
await Assert.That(value.Type).IsEqualTo(typeof(string));
await Assert.That(value.Getter(this)).IsEqualTo("value");
await Assert.That(value.IsStatic).IsFalse();
await Assert.That(value.IsNullable).IsFalse();
await Assert.That(metadata.Properties.Single(p => p.Name == nameof(OptionalText)).IsNullable).IsTrue();
await Assert.That(metadata.Properties.Single(p => p.Name == nameof(Items)).IsNullable).IsFalse();
var staticValue = metadata.Properties.Single(p => p.Name == nameof(StaticValue));
await Assert.That(staticValue.IsStatic).IsTrue();
await Assert.That(staticValue.Getter(null)).IsEqualTo(42);
var optional = metadata.Properties.Single(p => p.Name == nameof(Optional));
await Assert.That(optional.IsNullable).IsTrue();
await Assert.That(optional.Getter(this)).IsNull();
}
}
66 changes: 66 additions & 0 deletions tests/TUnit.UnitTests/ClassMetadataHelperTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
using TUnit.Core.PropertyInjection;

namespace TUnit.UnitTests;

public class ClassMetadataHelperTests
{
[Test]
public async Task PropertiesAreDeclaredPublicAndReferBackToTheirClass()
{
var metadata = ClassMetadataHelper.GetOrCreateClassMetadata(typeof(Derived));
await Assert.That(metadata.Properties.Length).IsEqualTo(2);
var instance = new Derived();
var value = metadata.Properties.Single(p => p.Name == nameof(Derived.Value));
await Assert.That(value.Getter(instance)).IsEqualTo("derived");
await Assert.That(value.ReflectionInfo.DeclaringType).IsEqualTo(typeof(Derived));
await Assert.That(ReferenceEquals(value.ClassMetadata, metadata)).IsTrue();
await Assert.That(ReferenceEquals(value.ContainingTypeMetadata, metadata)).IsTrue();
var count = metadata.Properties.Single(p => p.Name == nameof(Derived.Count));
await Assert.That(count.IsStatic).IsTrue();
await Assert.That(count.Getter(null)).IsEqualTo(42);
await Assert.That(ReferenceEquals(metadata, ClassMetadataHelper.GetOrCreateClassMetadata(typeof(Derived)))).IsTrue();
}

[Test]
public async Task InjectionFirstPopulatesTheSharedClassCache()
{
var data = DataGeneratorMetadataCreator.CreateForPropertyInjection(
typeof(Injected).GetProperty(nameof(Injected.Value))!, typeof(Injected), null,
new ArgumentsAttribute("injected"), "metadata-regression");
var property = (PropertyMetadata)data.MembersToGenerate.Single();
var metadata = property.ClassMetadata;
await Assert.That(metadata.Properties.Length).IsEqualTo(1);
await Assert.That(metadata.Properties[0].Getter(new Injected())).IsEqualTo("injected");
await Assert.That(ReferenceEquals(metadata.Properties[0].ClassMetadata, metadata)).IsTrue();
await Assert.That(ReferenceEquals(metadata, ClassMetadataHelper.GetOrCreateClassMetadata(typeof(Injected)))).IsTrue();
}

[Test]
public async Task NullableContextAppliesWhenThePropertyHasNoExplicitAnnotation()
{
var metadata = ClassMetadataHelper.GetOrCreateClassMetadata(typeof(NullableContextFixture));
await Assert.That(metadata.Properties.Single().IsNullable).IsTrue();
}

public class NullableContextFixture
{
public string? Value => null;
}

public class Injected
{
public string Value => "injected";
}

public class Base
{
public string Inherited => "inherited";
}

public class Derived : Base
{
public string Value => "derived";
public static int Count => 42;
private string Hidden => "hidden";
}
}
Loading