mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-02 05:47:21 +00:00
103 lines
5.8 KiB
C#
103 lines
5.8 KiB
C#
using System.Text;
|
|
using System.Text.Json.Serialization;
|
|
using System.Text.Json.Serialization.Metadata;
|
|
|
|
namespace AIStudio.Tests.Serialization;
|
|
|
|
/// <summary>
|
|
/// Describes the contract System.Text.Json uses for a type as text, so that two contracts can be compared and
|
|
/// a difference shows up readable in the message of a test.
|
|
/// </summary>
|
|
public static class JsonContractDescription
|
|
{
|
|
/// <summary>
|
|
/// Describes everything in a contract which decides what gets written and what a read accepts.
|
|
/// </summary>
|
|
/// <param name="typeInfo">The contract.</param>
|
|
/// <returns>One line for the type, and one line per property, polymorphism, and callback.</returns>
|
|
public static string Describe(JsonTypeInfo typeInfo)
|
|
{
|
|
//
|
|
// For a struct, reflection takes the default of the struct and sets every property, while source generation
|
|
// passes the values to the constructor of a positional record struct. Both give the same struct, as long as
|
|
// no parameter has a default value of its own, which JsonDefaultsTests checks. A class with required
|
|
// properties is alike: source generation sets them while it creates the object, reflection right after.
|
|
// Hence, the description leaves out how such a type gets created:
|
|
//
|
|
var isCreatedWithItsValues = typeInfo.Kind is JsonTypeInfoKind.Object && (typeInfo.Type.IsValueType || typeInfo.Properties.Any(property => property.IsRequired));
|
|
var creation = isCreatedWithItsValues
|
|
? "created with its values"
|
|
: $"converter {ConverterName(typeInfo.Converter)}, created {(typeInfo.CreateObject is null ? "otherwise" : "without arguments")}";
|
|
|
|
var text = new StringBuilder();
|
|
text.AppendLine($"{TypeName(typeInfo.Type)}: {typeInfo.Kind}, {creation}");
|
|
text.AppendLine($" handling: numbers {typeInfo.NumberHandling}, unmapped members {typeInfo.UnmappedMemberHandling}, object creation {typeInfo.PreferredPropertyObjectCreationHandling}");
|
|
text.AppendLine($" callbacks: serializing {typeInfo.OnSerializing is not null}, serialized {typeInfo.OnSerialized is not null}, deserializing {typeInfo.OnDeserializing is not null}, deserialized {typeInfo.OnDeserialized is not null}");
|
|
if (typeInfo.KeyType is { } keyType)
|
|
text.AppendLine($" key: {TypeName(keyType)}");
|
|
|
|
if (typeInfo.ElementType is { } elementType)
|
|
text.AppendLine($" element: {TypeName(elementType)}");
|
|
|
|
if (typeInfo.PolymorphismOptions is { } polymorphism)
|
|
{
|
|
text.AppendLine($" polymorphism: discriminator {polymorphism.TypeDiscriminatorPropertyName}, unrecognized ignored {polymorphism.IgnoreUnrecognizedTypeDiscriminators}, unknown types {polymorphism.UnknownDerivedTypeHandling}");
|
|
foreach (var derivedType in polymorphism.DerivedTypes)
|
|
text.AppendLine($" derived: {TypeName(derivedType.DerivedType)} as {derivedType.TypeDiscriminator}");
|
|
}
|
|
|
|
foreach (var property in typeInfo.Properties)
|
|
{
|
|
//
|
|
// An ignored property keeps its name in both contracts, but only reflection keeps its type:
|
|
//
|
|
if (IsIgnored(property))
|
|
{
|
|
text.AppendLine($" property {property.Name}: ignored");
|
|
continue;
|
|
}
|
|
|
|
var setting = isCreatedWithItsValues
|
|
?$"set {property.Set is not null || property.AssociatedParameter is not null}"
|
|
: $"set {property.Set is not null}, constructor parameter {property.AssociatedParameter?.Name ?? "none"}";
|
|
|
|
text.AppendLine($" property {property.Name}: {TypeName(property.PropertyType)}, order {property.Order}, get {property.Get is not null}, {setting}, required {property.IsRequired}, extension data {property.IsExtensionData}, conditional {property.ShouldSerialize is not null}, converter {ConverterName(property.CustomConverter)}, nullable get {property.IsGetNullable}, nullable set {property.IsSetNullable}, numbers {property.NumberHandling}, object creation {property.ObjectCreationHandling}");
|
|
}
|
|
|
|
return text.ToString();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Whether System.Text.Json neither writes nor reads a property, e.g., one marked with JsonIgnore.
|
|
/// </summary>
|
|
/// <param name="property">The property of a contract.</param>
|
|
public static bool IsIgnored(JsonPropertyInfo property) => property is { Get: null, Set: null, AssociatedParameter: null };
|
|
|
|
/// <summary>
|
|
/// Returns the name of a type the way C# writes it, e.g., Dictionary<String, Int32>.
|
|
/// </summary>
|
|
/// <param name="type">The type.</param>
|
|
public static string TypeName(Type type)
|
|
{
|
|
var arity = type.Name.IndexOf('`');
|
|
var name = arity < 0 ? type.Name : $"{type.Name[..arity]}<{string.Join(", ", type.GetGenericArguments().Select(TypeName))}>";
|
|
return type is { IsNested: true, IsGenericParameter: false } ?$"{TypeName(type.DeclaringType!)}.{name}" : name;
|
|
}
|
|
|
|
//
|
|
// Reflection reads the constructor parameters of a record through converters of its own: one for up to four
|
|
// parameters, and others whose names end with "WithReflection". Source generation reads them all with the
|
|
// converter for many parameters, which reads the same JSON. The same goes for immutable collections:
|
|
//
|
|
private static string ConverterName(JsonConverter? converter)
|
|
{
|
|
if (converter is null)
|
|
return "none";
|
|
|
|
var name = converter.GetType().Name;
|
|
if (name.Contains("ObjectWithParameterizedConstructorConverter", StringComparison.Ordinal))
|
|
return $"ObjectWithParameterizedConstructorConverter<{TypeName(converter.Type!)}>";
|
|
|
|
return TypeName(converter.GetType()).Replace("WithReflection", string.Empty, StringComparison.Ordinal);
|
|
}
|
|
} |