The formatter wrote default(CultRecordRef<T>) as nil but read nil back as "", so re-upserting a loaded record changed its bytes (0xC0 became 0xA0). One in-memory unset value: CultRecordKey.Value is never null, so default(CultRecordRef<T>) equals a ref built from "". One wire form: an empty key writes nil, and nil or a legacy "" reads back as default. Load then save is byte-stable; a store holding "" converges to nil on its next save. Rust, TypeScript and Python have no reference type: a reference member is a string in the consumer's own payload, so there is no sibling codec to align. The store-composition contract now names nil as the one unset wire form. The new test loads a record with an unset ref through a fresh cache, re-upserts and flushes, and requires identical payload bytes; it fails on the old formatter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
226 lines
12 KiB
C#
226 lines
12 KiB
C#
#nullable enable
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using System.Reflection.Emit;
|
|
using System.Threading.Tasks;
|
|
using GameCult.Caching;
|
|
using GameCult.Caching.MessagePack;
|
|
using MessagePack;
|
|
using MessagePack.Formatters;
|
|
using NUnit.Framework;
|
|
|
|
namespace GameCult.Caching.Tests
|
|
{
|
|
public class SerializationOptionsTests
|
|
{
|
|
// Captured on CultLib 825b4f7, before per-assembly options.
|
|
private const string InteropNotePayloadHex =
|
|
"96B963756C7463616368652E696E7465726F705F6E6F74652E7631AB6E6F74653A637368617270A6637368617270BD6373686172702077726F746520612043756C744361636865206E6F7465D9245468652076312073746F726520666F726D61742069732074686520636F6E74726163742E92A6637368617270A7696E7465726F70";
|
|
|
|
[Test]
|
|
public void InteropNoteBytesUnchanged()
|
|
{
|
|
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(OptionsInteropNote) });
|
|
var note = new OptionsInteropNote
|
|
{
|
|
DocumentId = "note:csharp",
|
|
AuthorRuntimeId = "csharp",
|
|
Title = "csharp wrote a CultCache note",
|
|
Body = "The v1 store format is the contract.",
|
|
Tags = new[] { "csharp", "interop" }
|
|
};
|
|
|
|
var payload = CultDocumentMessagePackSerialization.SerializeUntyped(note, typeof(OptionsInteropNote), registry);
|
|
|
|
Assert.That(Convert.ToHexString(payload), Is.EqualTo(InteropNotePayloadHex));
|
|
}
|
|
|
|
[Test]
|
|
public async Task UnsetRefReloadsAndResavesByteIdentical()
|
|
{
|
|
var path = Path.Combine(Path.GetTempPath(), $"cultlib-unsetref-{Guid.NewGuid():N}.cc");
|
|
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(UnsetRefHolder) });
|
|
byte[] Payload() => CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).Records.Single().Payload;
|
|
try
|
|
{
|
|
using (var seed = new CultCache(registry))
|
|
{
|
|
seed.AddBackingStore(new SingleFileMessagePackBackingStore(path));
|
|
await seed.UpsertAsync(typeof(UnsetRefHolder), new UnsetRefHolder { Name = "holder", Set = new CultRecordRef<UnsetRefHolder>(new CultRecordKey("other")) },
|
|
new CultRecordKey("holder"));
|
|
await seed.FlushAsync();
|
|
}
|
|
|
|
var written = Payload();
|
|
CultRecordRef<UnsetRefHolder> unset;
|
|
using (var reopened = new CultCache(registry))
|
|
{
|
|
reopened.AddBackingStore(new SingleFileMessagePackBackingStore(path));
|
|
await reopened.PullAllBackingStoresAsync();
|
|
var loaded = reopened.Get<UnsetRefHolder>(new CultRecordKey("holder"))!;
|
|
unset = loaded.Unset;
|
|
await reopened.UpsertAsync(typeof(UnsetRefHolder), loaded, new CultRecordKey("holder"));
|
|
await reopened.FlushAsync();
|
|
}
|
|
|
|
Assert.That(Payload(), Is.EqualTo(written), "an unset reference loads and re-saves byte-identical");
|
|
Assert.That(unset, Is.EqualTo(default(CultRecordRef<UnsetRefHolder>)));
|
|
Assert.That(unset.Key, Is.EqualTo(new CultRecordKey("")), "default and \"\" are one empty key");
|
|
var legacy = MessagePackSerializer.Serialize("", CultDocumentMessagePackSerialization.Options);
|
|
var read = MessagePackSerializer.Deserialize<CultRecordRef<UnsetRefHolder>>(legacy, CultDocumentMessagePackSerialization.Options);
|
|
Assert.That(MessagePackSerializer.Serialize(read, CultDocumentMessagePackSerialization.Options), Is.EqualTo(new[] { MessagePackCode.Nil }),
|
|
"a \"\" reference reads as unset and writes nil");
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(path)) File.Delete(path);
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public async Task RefKeyedDictionaryRoundTrips()
|
|
{
|
|
var path = Path.Combine(Path.GetTempPath(), $"cultlib-refkeyed-{Guid.NewGuid():N}.cc");
|
|
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(RefKeyedHolder) });
|
|
var alpha = new CultRecordRef<RefKeyedHolder>(new CultRecordKey("alpha"));
|
|
var beta = new CultRecordRef<RefKeyedHolder>(new CultRecordKey("beta"));
|
|
try
|
|
{
|
|
using (var cache = new CultCache(registry))
|
|
{
|
|
cache.AddBackingStore(new SingleFileMessagePackBackingStore(path));
|
|
await cache.UpsertAsync(
|
|
new RefKeyedHolder { Name = "holder", Weights = new() { [alpha] = 0.25f, [beta] = 2f } },
|
|
new CultRecordHandle<RefKeyedHolder>(new CultRecordKey("holder")));
|
|
cache.FlushAllBackingStores();
|
|
}
|
|
|
|
using var reopened = new CultCache(registry);
|
|
reopened.AddBackingStore(new SingleFileMessagePackBackingStore(path));
|
|
await reopened.PullAllBackingStoresAsync();
|
|
var loaded = reopened.Get<RefKeyedHolder>(new CultRecordKey("holder"));
|
|
|
|
Assert.That(loaded, Is.Not.Null);
|
|
Assert.That(loaded!.Weights[new CultRecordRef<RefKeyedHolder>(new CultRecordKey("alpha"))], Is.EqualTo(0.25f));
|
|
Assert.That(loaded.Weights[new CultRecordRef<RefKeyedHolder>(new CultRecordKey("beta"))], Is.EqualTo(2f));
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(path)) File.Delete(path);
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void DeclaredResolverEncodesValueTypeAsPositionalArray()
|
|
{
|
|
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(PairHolder) });
|
|
var holder = new PairHolder { Name = "pair", Value = new Pair { A = 1.5f, B = -2f } };
|
|
|
|
var payload = CultDocumentMessagePackSerialization.SerializeUntyped(holder, typeof(PairHolder), registry);
|
|
|
|
var reader = new MessagePackReader(payload);
|
|
Assert.That(reader.ReadArrayHeader(), Is.EqualTo(2));
|
|
reader.Skip();
|
|
Assert.That(reader.ReadArrayHeader(), Is.EqualTo(2));
|
|
Assert.That(reader.ReadSingle(), Is.EqualTo(1.5f));
|
|
Assert.That(reader.ReadSingle(), Is.EqualTo(-2f));
|
|
var decoded = (PairHolder)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(PairHolder), payload, registry);
|
|
Assert.That(decoded.Value, Is.EqualTo(holder.Value));
|
|
}
|
|
|
|
[Test]
|
|
public void UnknownAssemblyGetsBaseOptions()
|
|
{
|
|
Assert.That(CultDocumentMessagePackSerialization.OptionsFor(typeof(object).Assembly),
|
|
Is.SameAs(CultDocumentMessagePackSerialization.Options));
|
|
Assert.That(CultDocumentMessagePackSerialization.Options.Resolver.GetFormatter<Pair>(), Is.Null);
|
|
Assert.That(CultDocumentMessagePackSerialization.OptionsFor(typeof(Pair).Assembly),
|
|
Is.SameAs(CultDocumentMessagePackSerialization.OptionsFor(typeof(Pair).Assembly)));
|
|
Assert.That(CultDocumentMessagePackSerialization.OptionsFor(typeof(Pair).Assembly).Resolver.GetFormatter<Pair>(), Is.Not.Null);
|
|
}
|
|
|
|
// GenericHolder<Pair> lives in an assembly that declares a resolver writing Pair as [B, A]; Pair's own assembly writes [A, B].
|
|
[Test]
|
|
public void GenericDocumentUsesItsOwnAssemblyResolvers()
|
|
{
|
|
var assembly = AssemblyBuilder.DefineDynamicAssembly(new AssemblyName("GenericHolders"), AssemblyBuilderAccess.Run, new[]
|
|
{
|
|
new CustomAttributeBuilder(typeof(CultCacheFormatterResolverAttribute).GetConstructor(new[] { typeof(Type) })!, new object[] { typeof(ReversedPairResolver) })
|
|
});
|
|
var builder = assembly.DefineDynamicModule("GenericHolders").DefineType("GenericHolder`1", TypeAttributes.Public | TypeAttributes.Sealed);
|
|
var parameter = builder.DefineGenericParameters("T")[0];
|
|
builder.SetCustomAttribute(new CustomAttributeBuilder(
|
|
typeof(CultDocumentAttribute).GetConstructor(new[] { typeof(string), typeof(string) })!, new object[] { "tests.generic_holder", "tests.generic_holder.v1" }));
|
|
builder.SetCustomAttribute(new CustomAttributeBuilder(typeof(MessagePackObjectAttribute).GetConstructor(new[] { typeof(bool) })!, new object[] { false }));
|
|
builder.DefineField("Value", parameter, FieldAttributes.Public)
|
|
.SetCustomAttribute(new CustomAttributeBuilder(typeof(KeyAttribute).GetConstructor(new[] { typeof(int) })!, new object[] { 0 }));
|
|
var type = builder.CreateType()!.MakeGenericType(typeof(Pair));
|
|
var registry = CultDocumentRegistry.ForTypes(new[] { type });
|
|
var holder = Activator.CreateInstance(type)!;
|
|
type.GetField("Value")!.SetValue(holder, new Pair { A = 1.5f, B = -2f });
|
|
|
|
var payload = CultDocumentMessagePackSerialization.SerializeUntyped(holder, type, registry);
|
|
|
|
Assert.That(type.Assembly, Is.Not.SameAs(typeof(Pair).Assembly));
|
|
var reader = new MessagePackReader(payload);
|
|
Assert.That(reader.ReadArrayHeader(), Is.EqualTo(1));
|
|
Assert.That(reader.ReadArrayHeader(), Is.EqualTo(2));
|
|
Assert.That(reader.ReadSingle(), Is.EqualTo(-2f), "Pair's own assembly resolver was used instead of the document's");
|
|
Assert.That(reader.ReadSingle(), Is.EqualTo(1.5f));
|
|
var decoded = CultDocumentMessagePackSerialization.DeserializeUntyped(type, payload, registry);
|
|
Assert.That(type.GetField("Value")!.GetValue(decoded), Is.EqualTo(new Pair { A = 1.5f, B = -2f }));
|
|
}
|
|
|
|
public sealed class ReversedPairResolver : IFormatterResolver
|
|
{
|
|
public static readonly IFormatterResolver Instance = new ReversedPairResolver();
|
|
public IMessagePackFormatter<T>? GetFormatter<T>() => typeof(T) == typeof(Pair) ? (IMessagePackFormatter<T>)(object)new ReversedPairFormatter() : null;
|
|
}
|
|
|
|
public sealed class ReversedPairFormatter : IMessagePackFormatter<Pair>
|
|
{
|
|
public void Serialize(ref MessagePackWriter writer, Pair value, MessagePackSerializerOptions options) { writer.WriteArrayHeader(2); writer.Write(value.B); writer.Write(value.A); }
|
|
public Pair Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) { reader.ReadArrayHeader(); var b = reader.ReadSingle(); return new Pair { A = reader.ReadSingle(), B = b }; }
|
|
}
|
|
|
|
[CultDocument("tests.pair_holder", "tests.pair_holder.v1")]
|
|
[MessagePackObject]
|
|
public sealed class PairHolder
|
|
{
|
|
[Key(0)] public string Name = string.Empty;
|
|
[Key(1)] public Pair Value;
|
|
}
|
|
|
|
[CultDocument("cultcache.interop-note", "cultcache.interop_note.v1")]
|
|
[MessagePackObject]
|
|
public sealed class OptionsInteropNote
|
|
{
|
|
[Key(0)] public string SchemaVersion { get; set; } = "cultcache.interop_note.v1";
|
|
[Key(1)] [CultName] public string DocumentId { get; set; } = string.Empty;
|
|
[Key(2)] public string AuthorRuntimeId { get; set; } = string.Empty;
|
|
[Key(3)] public string Title { get; set; } = string.Empty;
|
|
[Key(4)] public string Body { get; set; } = string.Empty;
|
|
[Key(5)] public string[] Tags { get; set; } = Array.Empty<string>();
|
|
}
|
|
|
|
[CultDocument("tests.ref_keyed_holder", "tests.ref_keyed_holder.v1")]
|
|
[MessagePackObject]
|
|
public sealed class RefKeyedHolder
|
|
{
|
|
[Key(0)] [CultName] public string Name = string.Empty;
|
|
[Key(1)] public Dictionary<CultRecordRef<RefKeyedHolder>, float> Weights = new();
|
|
}
|
|
|
|
[CultDocument("tests.unset_ref_holder", "tests.unset_ref_holder.v1")]
|
|
[MessagePackObject]
|
|
public sealed class UnsetRefHolder
|
|
{
|
|
[Key(0)] [CultName] public string Name = string.Empty;
|
|
[Key(1)] public CultRecordRef<UnsetRefHolder> Unset;
|
|
[Key(2)] public CultRecordRef<UnsetRefHolder> Set;
|
|
}
|
|
}
|
|
}
|