diff --git a/src/Allure.Net.Commons/AllureLifecycle.cs b/src/Allure.Net.Commons/AllureLifecycle.cs index 697d6f9a..9ad94160 100644 --- a/src/Allure.Net.Commons/AllureLifecycle.cs +++ b/src/Allure.Net.Commons/AllureLifecycle.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Collections.ObjectModel; using System.IO; using System.Linq; using System.Threading; @@ -33,15 +32,18 @@ namespace Allure.Net.Commons; public class AllureLifecycle { private readonly Dictionary typeFormatters = new(); + private readonly HierarchicalTypeFormatterLookup typeFormatterLookup; private static readonly Lazy instance = new(Initialize); /// /// The list of the currently registered formatters used by Allure to - /// convert test and step arguments to strings. + /// convert test and step arguments to strings. Looking up a type that + /// has no formatter registered for it directly falls back to its + /// generic type definition, the interfaces it implements, and its base + /// classes, in that order. /// - public IReadOnlyDictionary TypeFormatters => - new ReadOnlyDictionary(typeFormatters); + public IReadOnlyDictionary TypeFormatters => typeFormatterLookup; readonly AsyncLocal context = new(); @@ -106,6 +108,7 @@ Func testPlanFactory AllureConfiguration = AllureConfiguration.ReadFromJObject(config); writer = writerFactory(AllureConfiguration); lazyTestPlan = new(testPlanFactory); + typeFormatterLookup = new(typeFormatters); } /// @@ -119,6 +122,7 @@ public AllureLifecycle(AllureConfiguration config, IAllureResultsWriter writer) this.AllureConfiguration = config; this.writer = writer; this.lazyTestPlan = new(AllureTestPlan.FromEnvironment); + this.typeFormatterLookup = new(this.typeFormatters); } /// @@ -138,6 +142,7 @@ Dictionary typeFormatters this.writer = writer; this.lazyTestPlan = new(AllureTestPlan.FromEnvironment); this.typeFormatters = typeFormatters; + this.typeFormatterLookup = new(this.typeFormatters); } /// @@ -176,8 +181,29 @@ Dictionary typeFormatters public void AddTypeFormatter(TypeFormatter typeFormatter) => AddTypeFormatterImpl(typeof(T), typeFormatter); - private void AddTypeFormatterImpl(Type type, ITypeFormatter formatter) => + /// + /// Registers a type formatter to be used when converting a test's or + /// step's argument to the string that will be included in the Allure + /// report. Unlike , + /// this overload accepts an open generic type definition (e.g. + /// typeof(List<>)), which cannot be expressed as a type + /// argument. + /// + /// + /// The type that the formatter converts. An argument is matched against + /// this type exactly, or, failing that, against its generic type + /// definition, the interfaces it implements, and its base classes, in + /// that order. + /// + /// The formatter instance. + public void AddTypeFormatter(Type type, ITypeFormatter formatter) => + AddTypeFormatterImpl(type, formatter); + + private void AddTypeFormatterImpl(Type type, ITypeFormatter formatter) + { typeFormatters[type] = formatter; + typeFormatterLookup.Invalidate(); + } /// /// Binds the provided value as the current Allure context and executes diff --git a/src/Allure.Net.Commons/Functions/FormatFunctions.cs b/src/Allure.Net.Commons/Functions/FormatFunctions.cs index 69d90d7a..c560f7f0 100644 --- a/src/Allure.Net.Commons/Functions/FormatFunctions.cs +++ b/src/Allure.Net.Commons/Functions/FormatFunctions.cs @@ -48,6 +48,10 @@ public static string Format(object? value) /// if JSON serialization failed. /// The serializer skips fields that contain loop references /// and fields that could not be serialized + /// Passing matches a + /// formatter by the value's exact type or, failing that, by its + /// generic type definition, the interfaces it implements, or its + /// base classes. A plain dictionary matches by exact type only. /// public static string Format( object? value, diff --git a/src/Allure.Net.Commons/HierarchicalTypeFormatterLookup.cs b/src/Allure.Net.Commons/HierarchicalTypeFormatterLookup.cs new file mode 100644 index 00000000..039cf2ab --- /dev/null +++ b/src/Allure.Net.Commons/HierarchicalTypeFormatterLookup.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; + +#nullable enable + +namespace Allure.Net.Commons; + +/// +/// Wraps a dictionary of explicitly registered type formatters and resolves +/// a formatter for a given runtime type by walking its type hierarchy when +/// no formatter is registered for the exact type. +/// +/// +/// Resolution order: +/// +/// The exact type. +/// The type's generic type definition, if it is a closed generic type. +/// Each interface the type implements, in the order returned by . +/// The generic type definition of each such interface. +/// Each base class, from the closest to the furthest. +/// The generic type definition of each such base class. +/// +/// Resolved results are cached per runtime type. The cache is invalidated +/// whenever a new formatter is registered. +/// +internal sealed class HierarchicalTypeFormatterLookup : IReadOnlyDictionary +{ + private readonly Dictionary registered; + private readonly ConcurrentDictionary resolveCache = new(); + + public HierarchicalTypeFormatterLookup(Dictionary registered) + { + this.registered = registered; + } + + /// + /// Drops all cached resolution results. Must be called whenever the + /// underlying registered-formatters dictionary is mutated. + /// + public void Invalidate() => resolveCache.Clear(); + + public bool TryGetValue(Type key, out ITypeFormatter value) + { + var resolved = resolveCache.GetOrAdd(key, Resolve); + value = resolved!; + return resolved is not null; + } + + private ITypeFormatter? Resolve(Type type) + { + if (registered.TryGetValue(type, out var exact)) + { + return exact; + } + + if (type.IsGenericType && + registered.TryGetValue(type.GetGenericTypeDefinition(), out var genericDefinition)) + { + return genericDefinition; + } + + var interfaces = type.GetInterfaces(); + + foreach (var i in interfaces) + { + if (registered.TryGetValue(i, out var iface)) + { + return iface; + } + } + + foreach (var i in interfaces) + { + if (i.IsGenericType && + registered.TryGetValue(i.GetGenericTypeDefinition(), out var ifaceGenericDefinition)) + { + return ifaceGenericDefinition; + } + } + + for (var b = type.BaseType; b is not null; b = b.BaseType) + { + if (registered.TryGetValue(b, out var baseExact)) + { + return baseExact; + } + } + + for (var b = type.BaseType; b is not null; b = b.BaseType) + { + if (b.IsGenericType && + registered.TryGetValue(b.GetGenericTypeDefinition(), out var baseGenericDefinition)) + { + return baseGenericDefinition; + } + } + + return null; + } + + /// + /// The explicitly registered types. Unlike , + /// this doesn't include every type that could be resolved through them + /// (e.g., subclasses or implementors). + /// + public IEnumerable Keys => registered.Keys; + + /// + /// The formatters registered for the types in , in + /// the same order. + /// + public IEnumerable Values => registered.Values; + + /// + /// The number of explicitly registered types. See . + /// + public int Count => registered.Count; + + public ITypeFormatter this[Type key] => + TryGetValue(key, out var value) + ? value + : throw new KeyNotFoundException($"No type formatter is registered for {key}."); + + public bool ContainsKey(Type key) => TryGetValue(key, out _); + + public IEnumerator> GetEnumerator() => + registered.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/tests/Allure.Net.Commons.Tests/UserAPITests/AllureFacadeTests/ParameterTests.cs b/tests/Allure.Net.Commons.Tests/UserAPITests/AllureFacadeTests/ParameterTests.cs index b08cedf3..8c3c9107 100644 --- a/tests/Allure.Net.Commons.Tests/UserAPITests/AllureFacadeTests/ParameterTests.cs +++ b/tests/Allure.Net.Commons.Tests/UserAPITests/AllureFacadeTests/ParameterTests.cs @@ -1,4 +1,6 @@ -using NUnit.Framework; +using System; +using System.Collections.Generic; +using NUnit.Framework; namespace Allure.Net.Commons.Tests.UserApiTests.AllureFacadeTests; @@ -11,6 +13,193 @@ public override string Format(TypeFormatterTarget value) => "serialized target"; } + interface ITargetInterface { } + + class ImplementsInterface : ITargetInterface { } + + class ClosedGenericTarget : List { } + + class DerivedFromGenericBase : GenericBase { } + + class GenericBase { } + + class FixedResultFormatter : ITypeFormatter + { + private readonly string result; + + public FixedResultFormatter(string result) => this.result = result; + + public string Format(object value) => this.result; + } + + [Test] + public void TypeFormatterMatchesImplementedInterface() + { + this.lifecycle.AddTypeFormatter( + typeof(ITargetInterface), + new FixedResultFormatter("serialized via interface") + ); + this.lifecycle.StartTestCase(new() { uuid = "uuid" }); + + AllureApi.AddTestParameter("name", new ImplementsInterface()); + + this.AssertParameters( + new Parameter() { name = "name", value = "serialized via interface" } + ); + } + + [Test] + public void TypeFormatterMatchesGenericInterfaceDefinition() + { + this.lifecycle.AddTypeFormatter( + typeof(IEnumerable<>), + new FixedResultFormatter("serialized via generic interface") + ); + this.lifecycle.StartTestCase(new() { uuid = "uuid" }); + + AllureApi.AddTestParameter("name", new List { 1, 2, 3 }); + + this.AssertParameters( + new Parameter() { name = "name", value = "serialized via generic interface" } + ); + } + + [Test] + public void TypeFormatterMatchesBaseClass() + { + this.lifecycle.AddTypeFormatter( + new TypeFormatterStub2() + ); + this.lifecycle.StartTestCase(new() { uuid = "uuid" }); + + AllureApi.AddTestParameter("name", new DerivedFromTarget()); + + this.AssertParameters( + new Parameter() { name = "name", value = "serialized base" } + ); + } + + class DerivedFromTarget : TypeFormatterTarget { } + class TypeFormatterStub2 : TypeFormatter + { + public override string Format(TypeFormatterTarget value) => "serialized base"; + } + + [Test] + public void TypeFormatterMatchesGenericBaseClassDefinition() + { + this.lifecycle.AddTypeFormatter( + typeof(GenericBase<>), + new FixedResultFormatter("serialized via generic base") + ); + this.lifecycle.StartTestCase(new() { uuid = "uuid" }); + + AllureApi.AddTestParameter("name", new DerivedFromGenericBase()); + + this.AssertParameters( + new Parameter() { name = "name", value = "serialized via generic base" } + ); + } + + [Test] + public void TypeFormatterMatchesOpenGenericTypeDefinition() + { + this.lifecycle.AddTypeFormatter( + typeof(List<>), + new FixedResultFormatter("serialized via open generic") + ); + this.lifecycle.StartTestCase(new() { uuid = "uuid" }); + + AllureApi.AddTestParameter("name", new List { 1, 2, 3 }); + + this.AssertParameters( + new Parameter() { name = "name", value = "serialized via open generic" } + ); + } + + [Test] + public void ExactTypeFormatterTakesPrecedenceOverInterfaceFormatter() + { + this.lifecycle.AddTypeFormatter( + typeof(ITargetInterface), + new FixedResultFormatter("serialized via interface") + ); + this.lifecycle.AddTypeFormatter( + typeof(ImplementsInterface), + new FixedResultFormatter("serialized via exact type") + ); + this.lifecycle.StartTestCase(new() { uuid = "uuid" }); + + AllureApi.AddTestParameter("name", new ImplementsInterface()); + + this.AssertParameters( + new Parameter() { name = "name", value = "serialized via exact type" } + ); + } + + [Test] + public void FormatterResolutionCacheIsInvalidatedByNewRegistrations() + { + this.lifecycle.AddTypeFormatter( + typeof(ITargetInterface), + new FixedResultFormatter("serialized via interface") + ); + // Trigger resolution and caching for ImplementsInterface before the + // more specific formatter is registered. + _ = this.lifecycle.TypeFormatters.TryGetValue( + typeof(ImplementsInterface), out _ + ); + + this.lifecycle.AddTypeFormatter( + typeof(ImplementsInterface), + new FixedResultFormatter("serialized via exact type") + ); + this.lifecycle.StartTestCase(new() { uuid = "uuid" }); + + AllureApi.AddTestParameter("name", new ImplementsInterface()); + + this.AssertParameters( + new Parameter() { name = "name", value = "serialized via exact type" } + ); + } + + [Test] + public void ContainsKeyAndIndexerAgreeWithTryGetValueForResolvedTypes() + { + this.lifecycle.AddTypeFormatter( + typeof(ITargetInterface), + new FixedResultFormatter("serialized via interface") + ); + + Assert.Multiple(() => + { + Assert.That( + this.lifecycle.TypeFormatters.ContainsKey(typeof(ImplementsInterface)), + Is.True + ); + Assert.That( + this.lifecycle.TypeFormatters[typeof(ImplementsInterface)].Format(null), + Is.EqualTo("serialized via interface") + ); + }); + } + + [Test] + public void TypesWithoutMatchingFormatterFallBackToDefaultSerialization() + { + this.lifecycle.AddTypeFormatter( + typeof(ITargetInterface), + new FixedResultFormatter("serialized via interface") + ); + this.lifecycle.StartTestCase(new() { uuid = "uuid" }); + + AllureApi.AddTestParameter("name", "plain value"); + + this.AssertParameters( + new Parameter() { name = "name", value = "\"plain value\"" } + ); + } + [Test] public void NameValueOnly() {