Skip to content

Commit

Permalink
Implement xUnit serializers for our types
Browse files Browse the repository at this point in the history
  • Loading branch information
bash committed Jan 31, 2025
1 parent 62e101a commit 1ad6766
Show file tree
Hide file tree
Showing 20 changed files with 399 additions and 3 deletions.
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<PackageVersion Include="xunit.assert" Version="[2.9.3, 2.10)" />
<PackageVersion Include="xunit.extensibility.core" Version="[2.9.3, 2.10)" />
<PackageVersion Include="xunit.v3.assert" Version="[1.0.1, 2)" />
<PackageVersion Include="xunit.v3.extensibility.core" Version="[1.0.1, 2)" />
<PackageVersion Include="System.Linq.Async" Version="[5.0.0, 7)" />
</ItemGroup>
<ItemGroup Label="Build Dependencies">
Expand Down
3 changes: 2 additions & 1 deletion Funcky.Xunit.v3.Test/Funcky.Xunit.v3.Test.csproj
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<LangVersion>preview</LangVersion>
Expand All @@ -24,4 +24,5 @@
<Import Project="..\Analyzers.props" />
<Import Project="..\GlobalUsings.props" />
<Import Project="..\GlobalUsings.Test.props" />
<Import Project="..\Funcky.Xunit.v3\build\Funcky.Xunit.v3.targets" />
</Project>
38 changes: 38 additions & 0 deletions Funcky.Xunit.v3.Test/Serializers/EitherSerializerTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using Funcky.Xunit.Serializers;

namespace Funcky.Xunit.Test;

public sealed class EitherSerializerTest
{
private static readonly EitherSerializer Serializer = new();

[Theory]
[MemberData(nameof(EitherProvider))]
public void RoundtripsEitherValues(IEither either)
{
Assert.True(Serializer.IsSerializable(either.GetType(), either, out _));
var serialized = Serializer.Serialize(either);
var deserialized = Serializer.Deserialize(either.GetType(), serialized);
Assert.Equal(either, deserialized);
}

public static TheoryData<IEither> EitherProvider()
=> new((IEnumerable<IEither>)[
Either<string>.Return(10),
Either<string>.Return(20),
Either<string, int>.Left("foo"),
Either<string, int>.Left("bar"),
Either<string, int>.Left("bar"),
Either<NonSerializable, int>.Right(42),
Either<int, NonSerializable>.Left(42),
]);

[Fact]
public void EitherSideOfNonSerializableTypeIsNotSerializable()
{
Assert.False(Serializer.IsSerializable(typeof(Either<NonSerializable, int>), Either<NonSerializable, int>.Left(new NonSerializable()), out _));
Assert.False(Serializer.IsSerializable(typeof(Either<int, NonSerializable>), Either<int, NonSerializable>.Right(new NonSerializable()), out _));
}

public sealed class NonSerializable;
}
45 changes: 45 additions & 0 deletions Funcky.Xunit.v3.Test/Serializers/OptionSerializerTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using Funcky.Xunit.Serializers;

namespace Funcky.Xunit.Test;

public sealed class OptionSerializerTest
{
private static readonly OptionSerializer Serializer = new();

[Theory]
[MemberData(nameof(OptionProvider))]
public void RoundtripsOptionValues(Option<int> option)
{
var serialized = Serializer.Serialize(option);
var deserialized = Serializer.Deserialize(option.GetType(), serialized);
Assert.Equal(option, deserialized);
}

public static TheoryData<Option<int>> OptionProvider()
=> [
Option.Some(10),
Option.Some(20),
Option<int>.None,
];

[Fact]
public void OptionOfSerializableTypeIsSerializable()
{
Assert.True(Serializer.IsSerializable(typeof(Option<int>), Option.Some(10), out _));
Assert.True(Serializer.IsSerializable(typeof(Option<int>), Option<int>.None, out _));
}

[Fact]
public void OptionOfNonSerializableTypeIsNotSerializable()
{
Assert.False(Serializer.IsSerializable(typeof(Option<NonSerializable>), Option.Some(new NonSerializable()), out _));
}

[Fact]
public void NoneIsAlwaysSerializable()
{
Assert.True(Serializer.IsSerializable(typeof(Option<NonSerializable>), Option<NonSerializable>.None, out _));
}

public sealed class NonSerializable;
}
22 changes: 22 additions & 0 deletions Funcky.Xunit.v3.Test/Serializers/UnitSerializerTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using Funcky.Xunit.Serializers;

namespace Funcky.Xunit.Test;

public sealed class UnitSerializerTest
{
private static readonly UnitSerializer Serializer = new();

[Fact]
public void RoundtripsUnit()
{
var serialized = Serializer.Serialize(Unit.Value);
var deserialized = Serializer.Deserialize(typeof(Unit), serialized);
Assert.Equal(Unit.Value, deserialized);
}

[Fact]
public void UnitIsSerializable()
{
Assert.True(Serializer.IsSerializable(typeof(Unit), Unit.Value, out _));
}
}
7 changes: 7 additions & 0 deletions Funcky.Xunit.v3/Funcky.Xunit.v3.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,19 @@
<PropertyGroup Condition="'$(TargetFramework)' == 'net6.0'">
<DefineConstants>$(DefineConstants);STACK_TRACE_HIDDEN_SUPPORTED</DefineConstants>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Funcky.Xunit.v3.Test" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\Funcky.Xunit\**\*.cs" />
</ItemGroup>
<ItemGroup>
<None Include="build\$(PackageId).targets" Pack="true" PackagePath="build\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="PolySharp" PrivateAssets="all" />
<PackageReference Include="xunit.v3.assert" />
<PackageReference Include="xunit.v3.extensibility.core" />
<PackageReference Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" PrivateAssets="all" Condition="'$(TargetFramework)' == 'net6.0'" />
</ItemGroup>
<ItemGroup>
Expand Down
6 changes: 6 additions & 0 deletions Funcky.Xunit.v3/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
#nullable enable
Funcky.Xunit.RegisterEitherSerializerAttribute
Funcky.Xunit.RegisterEitherSerializerAttribute.RegisterEitherSerializerAttribute() -> void
Funcky.Xunit.RegisterOptionSerializerAttribute
Funcky.Xunit.RegisterOptionSerializerAttribute.RegisterOptionSerializerAttribute() -> void
Funcky.Xunit.RegisterUnitSerializerAttribute
Funcky.Xunit.RegisterUnitSerializerAttribute.RegisterUnitSerializerAttribute() -> void
static Funcky.FunctionalAssert.Some<TItem>(TItem expectedValue, Funcky.Monads.Option<TItem> option) -> void
static Funcky.FunctionalAssert.Some<TItem>(Funcky.Monads.Option<TItem> option) -> TItem
static Funcky.FunctionalAssert.Right<TLeft, TRight>(TRight expectedRight, Funcky.Monads.Either<TLeft, TRight> either) -> void
Expand Down
14 changes: 14 additions & 0 deletions Funcky.Xunit.v3/RegisterEitherSerializerAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System.ComponentModel;
using Funcky.Xunit.Serializers;
using Xunit.Sdk;

namespace Funcky.Xunit;

[AttributeUsage(AttributeTargets.Assembly)]
[EditorBrowsable(EditorBrowsableState.Advanced)]
public sealed class RegisterEitherSerializerAttribute : Attribute, IRegisterXunitSerializerAttribute
{
Type IRegisterXunitSerializerAttribute.SerializerType => typeof(EitherSerializer);

Type[] IRegisterXunitSerializerAttribute.SupportedTypesForSerialization => [typeof(IEither)];
}
14 changes: 14 additions & 0 deletions Funcky.Xunit.v3/RegisterOptionSerializerAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System.ComponentModel;
using Funcky.Xunit.Serializers;
using Xunit.Sdk;

namespace Funcky.Xunit;

[AttributeUsage(AttributeTargets.Assembly)]
[EditorBrowsable(EditorBrowsableState.Advanced)]
public sealed class RegisterOptionSerializerAttribute : Attribute, IRegisterXunitSerializerAttribute
{
Type IRegisterXunitSerializerAttribute.SerializerType => typeof(OptionSerializer);

Type[] IRegisterXunitSerializerAttribute.SupportedTypesForSerialization => [typeof(IOption)];
}
14 changes: 14 additions & 0 deletions Funcky.Xunit.v3/RegisterUnitSerializerAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System.ComponentModel;
using Funcky.Xunit.Serializers;
using Xunit.Sdk;

namespace Funcky.Xunit;

[AttributeUsage(AttributeTargets.Assembly)]
[EditorBrowsable(EditorBrowsableState.Advanced)]
public sealed class RegisterUnitSerializerAttribute : Attribute, IRegisterXunitSerializerAttribute
{
Type IRegisterXunitSerializerAttribute.SerializerType => typeof(UnitSerializer);

Type[] IRegisterXunitSerializerAttribute.SupportedTypesForSerialization => [typeof(Unit)];
}
86 changes: 86 additions & 0 deletions Funcky.Xunit.v3/Serializers/EitherSerializer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using Xunit.Sdk;
using static Funcky.Discard;

namespace Funcky.Xunit.Serializers;

internal sealed class EitherSerializer : IXunitSerializer
{
private static readonly MethodInfo GenericDeserialize
= typeof(EitherSerializer).GetMethod(nameof(Deserialize), BindingFlags.Static | BindingFlags.NonPublic)
?? throw new MissingMethodException("Generic deserialize method not found");

private static readonly MethodInfo GenericIsSerializable
= typeof(EitherSerializer).GetMethod(nameof(IsSerializable), BindingFlags.Static | BindingFlags.NonPublic)
?? throw new MissingMethodException("Generic IsSerializable method not found");

private static readonly MethodInfo GenericSerialize
= typeof(EitherSerializer).GetMethod(nameof(Serialize), BindingFlags.Static | BindingFlags.NonPublic)
?? throw new MissingMethodException("Generic serialize method not found");

public object Deserialize(Type type, string serializedValue)
{
var (leftType, rightType) = GetLeftAndRightTypeOrThrow(type);
return GenericDeserialize.MakeGenericMethod(leftType, rightType).Invoke(null, [serializedValue])!;
}

public bool IsSerializable(Type type, object? value, [NotNullWhen(false)] out string? failureReason)
{
failureReason = string.Empty;
return GetLeftAndRightType(type) is [var (leftType, rightType)]
&& (bool)GenericIsSerializable.MakeGenericMethod(leftType, rightType).Invoke(null, [leftType, rightType, value])!;
}

public string Serialize(object value)
{
var (leftType, rightType) = GetLeftAndRightTypeOrThrow(value.GetType());
return (string)GenericSerialize.MakeGenericMethod(leftType, rightType).Invoke(null, [value])!;
}

private static (Type Left, Type Right) GetLeftAndRightTypeOrThrow(Type eitherType)
=> GetLeftAndRightType(eitherType).GetOrElse(() => throw new InvalidOperationException($"{eitherType} is not an Either<L, R>"));

private static Option<(Type Left, Type Right)> GetLeftAndRightType(Type eitherType)
=> eitherType.IsGenericType && eitherType.GetGenericTypeDefinition() == typeof(Either<,>)
? (eitherType.GenericTypeArguments[0], eitherType.GenericTypeArguments[1])
: Option<(Type, Type)>.None;

private static object Deserialize<TLeft, TRight>(string serializedValue)
where TLeft : notnull
where TRight : notnull
=> __ switch
{
_ when serializedValue.StripPrefix(Tag.Left) is [var rest]
=> Either<TLeft, TRight>.Left(SerializationHelper.Instance.Deserialize<TLeft>(rest)!),
_ when serializedValue.StripPrefix(Tag.Right) is [var rest]
=> Either<TLeft, TRight>.Right(SerializationHelper.Instance.Deserialize<TRight>(rest)!),
_ => throw new FormatException($"'{serializedValue}' is not a valid either value"),
};

private static bool IsSerializable<TLeft, TRight>(Type leftType, Type rightType, object? value)
where TLeft : notnull
where TRight : notnull
{
var either = (Either<TLeft, TRight>)(value ?? throw new InvalidOperationException("Either cannot be null"));
return either.Match(
left: left => SerializationHelper.Instance.IsSerializable(left, leftType),
right: right => SerializationHelper.Instance.IsSerializable(right, rightType));
}

private static string Serialize<TLeft, TRight>(object obj)
where TLeft : notnull
where TRight : notnull
{
var either = (Either<TLeft, TRight>)obj;
return either.Match(
left: left => $"{Tag.Left}{SerializationHelper.Instance.Serialize(left)}",
right: right => $"{Tag.Right}{SerializationHelper.Instance.Serialize(right)}");
}

private static class Tag
{
public const string Left = "L:";
public const string Right = "R:";
}
}
79 changes: 79 additions & 0 deletions Funcky.Xunit.v3/Serializers/OptionSerializer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using Xunit.Sdk;
using static Funcky.Discard;

namespace Funcky.Xunit.Serializers;

internal sealed class OptionSerializer : IXunitSerializer
{
private static readonly MethodInfo GenericDeserialize
= typeof(OptionSerializer).GetMethod(nameof(Deserialize), BindingFlags.Static | BindingFlags.NonPublic)
?? throw new MissingMethodException("Generic deserialize method not found");

private static readonly MethodInfo GenericIsSerializable
= typeof(OptionSerializer).GetMethod(nameof(IsSerializable), BindingFlags.Static | BindingFlags.NonPublic)
?? throw new MissingMethodException("Generic IsSerializable method not found");

private static readonly MethodInfo GenericSerialize
= typeof(OptionSerializer).GetMethod(nameof(Serialize), BindingFlags.Static | BindingFlags.NonPublic)
?? throw new MissingMethodException("Generic serialize method not found");

public object Deserialize(Type type, string serializedValue)
{
var itemType = GetItemTypeOrThrow(type);
return GenericDeserialize.MakeGenericMethod(itemType).Invoke(null, [serializedValue])!;
}

public bool IsSerializable(Type type, object? value, [NotNullWhen(false)] out string? failureReason)
{
failureReason = string.Empty;
return GetItemType(type) is [var itemType]
&& (bool)GenericIsSerializable.MakeGenericMethod(itemType).Invoke(null, [itemType, value])!;
}

public string Serialize(object value)
{
var itemType = GetItemTypeOrThrow(value.GetType());
return (string)GenericSerialize.MakeGenericMethod(itemType).Invoke(null, [value])!;
}

private static Type GetItemTypeOrThrow(Type eitherType)
=> GetItemType(eitherType).GetOrElse(() => throw new InvalidOperationException($"{eitherType} is not an Option<T>"));

private static Option<Type> GetItemType(Type optionType)
=> optionType.IsGenericType && optionType.GetGenericTypeDefinition() == typeof(Option<>)
? optionType.GenericTypeArguments.First()
: Option<Type>.None;

private static object Deserialize<TItem>(string serializedValue)
where TItem : notnull
=> __ switch
{
_ when serializedValue == Tag.None => Option<TItem>.None,
_ when serializedValue.StripPrefix(Tag.Some) is [var rest] => Option.Some(SerializationHelper.Instance.Deserialize<TItem>(rest)!),
_ => throw new FormatException($"'{serializedValue}' is not a valid option value"),
};

private static bool IsSerializable<TItem>(Type itemType, object? value)
where TItem : notnull
{
var option = (Option<TItem>)(value ?? throw new InvalidOperationException("TODO"));
return option.Match(none: true, some: item => SerializationHelper.Instance.IsSerializable(item, itemType));
}

private static string Serialize<TItem>(object obj)
where TItem : notnull
{
var option = (Option<TItem>)obj;
return option.Match(
none: () => Tag.None,
some: item => $"{Tag.Some}{SerializationHelper.Instance.Serialize(item)}");
}

private static class Tag
{
public const string None = "N";
public const string Some = "S:";
}
}
9 changes: 9 additions & 0 deletions Funcky.Xunit.v3/Serializers/StringExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Funcky.Xunit.Serializers;

internal static class StringExtensions
{
public static Option<string> StripPrefix(this string s, string prefix)
=> s.StartsWith(prefix)
? s[prefix.Length..]
: Option<string>.None;
}
17 changes: 17 additions & 0 deletions Funcky.Xunit.v3/Serializers/UnitSerializer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System.Diagnostics.CodeAnalysis;
using Xunit.Sdk;

namespace Funcky.Xunit.Serializers;

internal sealed class UnitSerializer : IXunitSerializer
{
public object Deserialize(Type type, string serializedValue) => Unit.Value;

public bool IsSerializable(Type type, object? value, [NotNullWhen(false)] out string? failureReason)
{
failureReason = string.Empty;
return type == typeof(Unit);
}

public string Serialize(object value) => string.Empty;
}
Loading

0 comments on commit 1ad6766

Please sign in to comment.