From c3118ab6ab9185b8c6c0dd60c8f3a9428ba3d0f1 Mon Sep 17 00:00:00 2001 From: NoTh0ughts <89064032445@mail.ru> Date: Mon, 27 Jul 2026 17:45:01 +0300 Subject: [PATCH] Allow type formatters to match by interface, base class, and generic type definition Previously, AddTypeFormatter/TypeFormatter only matched a value if its exact runtime type was registered, which made it impossible to reuse a formatter for an interface, a base class, or an open generic type definition (e.g. List<>). Resolution now falls back, in order, to the generic type definition, the implemented interfaces (and their generic definitions), and the base classes (and their generic definitions), with resolved lookups cached per runtime type. A new AddTypeFormatter(Type, ITypeFormatter) overload lets a formatter be registered for an open generic type definition, which can't be expressed as a type argument to AddTypeFormatter. Fixes #644 --- src/Allure.Net.Commons/AllureLifecycle.cs | 36 +++- .../Functions/FormatFunctions.cs | 4 + .../HierarchicalTypeFormatterLookup.cs | 132 ++++++++++++ .../AllureFacadeTests/ParameterTests.cs | 191 +++++++++++++++++- 4 files changed, 357 insertions(+), 6 deletions(-) create mode 100644 src/Allure.Net.Commons/HierarchicalTypeFormatterLookup.cs 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() {