Merge hands/union-golden: the recursive-union and arm-refusal goldens, written reproducibly by the C# reference
Soul holds at cultlib-gaps:verdict:cut-union-golden.s2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
b0f4057704
18 changed files with 607 additions and 2 deletions
|
|
@ -2337,9 +2337,12 @@ namespace GameCult.Caching
|
|||
throw new ArgumentException($"Expect({key.Value}) was given an instance this cache does not hold at that key.", nameof(current));
|
||||
}
|
||||
|
||||
private static string MintStoredAt(string? previous)
|
||||
// The wall clock behind every storedAt. Internal: a test that writes a golden store pins it, so the bytes reproduce.
|
||||
internal Func<DateTimeOffset> Clock { get; set; } = () => DateTimeOffset.UtcNow;
|
||||
|
||||
private string MintStoredAt(string? previous)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = Clock();
|
||||
if (previous != null &&
|
||||
DateTimeOffset.TryParse(previous, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var last) &&
|
||||
now <= last)
|
||||
|
|
|
|||
|
|
@ -8,5 +8,6 @@
|
|||
<PackageReference Include="MessagePack.Annotations" Version="3.1.7" />
|
||||
<PackageReference Include="R3" Version="1.3.0" />
|
||||
<PackageReference Include="ConcurrentHashSet " Version="1.3.0" />
|
||||
<InternalsVisibleTo Include="GameCult.Caching.Tests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
69
tests/GameCult.Caching.Tests/CultCacheClockTests.cs
Normal file
69
tests/GameCult.Caching.Tests/CultCacheClockTests.cs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
#nullable enable
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using GameCult.Caching;
|
||||
using GameCult.Caching.MessagePack;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace GameCult.Caching.Tests
|
||||
{
|
||||
// The clock behind every storedAt: the wall clock by default, and a pinned one when a golden store has to reproduce.
|
||||
public class CultCacheClockTests
|
||||
{
|
||||
private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(UnionParityVectorTests.UnionSurface) });
|
||||
private static readonly CultRecordKey Key = new("surface");
|
||||
|
||||
private string _directory = string.Empty;
|
||||
|
||||
[SetUp]
|
||||
public void CreateDirectory()
|
||||
{
|
||||
_directory = Path.Combine(Path.GetTempPath(), $"cultlib-clock-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_directory);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void DeleteDirectory()
|
||||
{
|
||||
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
|
||||
}
|
||||
|
||||
private CultCache Open() => CultCacheMessagePack.Create(Path.Combine(_directory, "clock.cc"), new CultCacheOpenOptions { Registry = Registry });
|
||||
|
||||
private static string Upsert(CultCache cache)
|
||||
{
|
||||
cache.UpsertAsync(typeof(UnionParityVectorTests.UnionSurface), new UnionParityVectorTests.UnionSurface(), Key).GetAwaiter().GetResult();
|
||||
return cache.GetStored(Key)!.StoredAt;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ADefaultCacheStampsTheWallClock()
|
||||
{
|
||||
using var cache = Open();
|
||||
var before = DateTimeOffset.UtcNow;
|
||||
var stamp = DateTimeOffset.Parse(Upsert(cache), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
|
||||
var after = DateTimeOffset.UtcNow;
|
||||
Assert.That(stamp, Is.InRange(before, after));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void APinnedClockIsTheStoredAt()
|
||||
{
|
||||
var pinned = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
using var cache = Open();
|
||||
cache.Clock = () => pinned;
|
||||
Assert.That(Upsert(cache), Is.EqualTo("2026-01-01T00:00:00.0000000+00:00"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void APinnedClockStillIncreasesStoredAtPerKey()
|
||||
{
|
||||
var pinned = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
using var cache = Open();
|
||||
cache.Clock = () => pinned;
|
||||
Upsert(cache);
|
||||
Assert.That(Upsert(cache), Is.EqualTo("2026-01-01T00:00:00.0000001+00:00"), "the same instant again is one tick later");
|
||||
}
|
||||
}
|
||||
}
|
||||
470
tests/GameCult.Caching.Tests/UnionParityVectorTests.cs
Normal file
470
tests/GameCult.Caching.Tests/UnionParityVectorTests.cs
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
#nullable enable
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using GameCult.Caching;
|
||||
using GameCult.Caching.MessagePack;
|
||||
using MessagePack;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace GameCult.Caching.Tests
|
||||
{
|
||||
// The recursive-union golden and the arm refusal goldens in tests/vectors/union-parity. The C# reference is the only
|
||||
// writer (CULTCACHE_WRITE_VECTORS=1); every other run reads the committed bytes and compares them byte for byte, so a
|
||||
// vector is never regenerated by an ordinary run. Other runtimes are held to these bytes (docs/cultcache-union-parity-map.md U11).
|
||||
public class UnionParityVectorTests
|
||||
{
|
||||
private const string WriteVariable = "CULTCACHE_WRITE_VECTORS";
|
||||
private const string Canary = "CANARY-union-golden-4c2a";
|
||||
|
||||
// The stored-at of the one record in union-surface.cc: the store's clock is pinned to it, so the store reproduces.
|
||||
private static readonly DateTimeOffset StoredAt = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(UnionSurface) });
|
||||
|
||||
private static bool Writing => Environment.GetEnvironmentVariable(WriteVariable) == "1";
|
||||
|
||||
// ---- the vector directory: written only under the write variable, otherwise read and compared ----
|
||||
|
||||
private static string VectorDirectory()
|
||||
{
|
||||
for (var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); dir != null; dir = dir.Parent)
|
||||
{
|
||||
var vectors = Path.Combine(dir.FullName, "tests", "vectors");
|
||||
if (Directory.Exists(vectors)) return Path.Combine(vectors, "union-parity");
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException($"tests/vectors not found above {TestContext.CurrentContext.TestDirectory}.");
|
||||
}
|
||||
|
||||
private static string VectorPath(string name) => Path.Combine(VectorDirectory(), name);
|
||||
|
||||
// Under the write variable the reference's bytes become the file; otherwise they must equal the committed file.
|
||||
private static void Check(string name, byte[] reference)
|
||||
{
|
||||
var path = VectorPath(name);
|
||||
if (Writing)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
File.WriteAllBytes(path, reference);
|
||||
return;
|
||||
}
|
||||
|
||||
Assert.That(File.Exists(path), Is.True, $"vector {name} is committed");
|
||||
Assert.That(Convert.ToHexString(reference), Is.EqualTo(Convert.ToHexString(File.ReadAllBytes(path))), $"vector {name}");
|
||||
}
|
||||
|
||||
private static byte[] Committed(string name)
|
||||
{
|
||||
var path = VectorPath(name);
|
||||
Assert.That(File.Exists(path), Is.True, $"vector {name} is committed");
|
||||
return File.ReadAllBytes(path);
|
||||
}
|
||||
|
||||
// ---- the fixture: a recursive union, a unit arm, a union inside a record inside a union, a list of unions ----
|
||||
|
||||
// Keys 0, 1, 2 and 5: 3 and 4 are retired, so the key map is not sequential.
|
||||
[Union(0, typeof(Box)), Union(1, typeof(Text)), Union(2, typeof(Use)), Union(5, typeof(Image))]
|
||||
public abstract class Node { }
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class Box : Node
|
||||
{
|
||||
[Key(0)] public string Id = string.Empty;
|
||||
[Key(1)] public List<Node> Children = new();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class Text : Node
|
||||
{
|
||||
[Key(0)] public string Id = string.Empty;
|
||||
[Key(1)] public string Content = string.Empty;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class Use : Node
|
||||
{
|
||||
[Key(0)] public string Id = string.Empty;
|
||||
[Key(1)] public Composition Composition = new();
|
||||
[Key(2)] public List<Node> Slots = new();
|
||||
}
|
||||
|
||||
// Slot 2 is a retired gap, written nil.
|
||||
[MessagePackObject]
|
||||
public sealed class Image : Node
|
||||
{
|
||||
[Key(0)] public string Id = string.Empty;
|
||||
[Key(1)] public string Uri = string.Empty;
|
||||
[Key(3)] public string Alt = string.Empty;
|
||||
}
|
||||
|
||||
// A record, not an arm, holding a union.
|
||||
[MessagePackObject]
|
||||
public sealed class Composition
|
||||
{
|
||||
[Key(0)] public string Name = string.Empty;
|
||||
[Key(1)] public Node? Body;
|
||||
}
|
||||
|
||||
[Union(0, typeof(Pending)), Union(1, typeof(Completed)), Union(2, typeof(Denied))]
|
||||
public abstract class Outcome { }
|
||||
|
||||
[MessagePackObject] public sealed class Pending : Outcome { [Key(0)] public string Message = string.Empty; }
|
||||
[MessagePackObject] public sealed class Completed : Outcome { [Key(0)] public int SourceVersion; }
|
||||
// A unit arm: written [2, []].
|
||||
[MessagePackObject] public sealed class Denied : Outcome { }
|
||||
|
||||
// Item names Tool and Part under keys 1 and 2; CraftedItem, the inner union both derive from, names them under 0 and 1.
|
||||
[Union(0, typeof(Gem)), Union(1, typeof(Tool)), Union(2, typeof(Part))]
|
||||
public abstract class Item { }
|
||||
|
||||
[Union(0, typeof(Tool)), Union(1, typeof(Part))]
|
||||
public abstract class CraftedItem : Item { }
|
||||
|
||||
[MessagePackObject] public sealed class Gem : Item { [Key(0)] public string Name = string.Empty; }
|
||||
[MessagePackObject] public sealed class Tool : CraftedItem { [Key(0)] public string Name = string.Empty; [Key(1)] public int Uses; }
|
||||
[MessagePackObject] public sealed class Part : CraftedItem { [Key(0)] public string Name = string.Empty; [Key(1)] public int Count; }
|
||||
|
||||
[CultDocument("tests.union_surface", "tests.union_surface.v1")]
|
||||
[MessagePackObject]
|
||||
public sealed class UnionSurface
|
||||
{
|
||||
[Key(0)] [CultName] public string Id = string.Empty;
|
||||
[Key(1)] public int Version;
|
||||
[Key(2)] public Node? Root;
|
||||
[Key(3)] public List<Outcome> Outcomes = new();
|
||||
[Key(4)] public Item? Part;
|
||||
[Key(5)] public CraftedItem? Crafted;
|
||||
[Key(6)] public Node? Missing;
|
||||
}
|
||||
|
||||
private static UnionSurface Constructed() => new()
|
||||
{
|
||||
Id = "surface",
|
||||
Version = 3,
|
||||
Root = new Box
|
||||
{
|
||||
Id = "root",
|
||||
Children =
|
||||
{
|
||||
new Text { Id = "t1", Content = "hello" },
|
||||
new Use
|
||||
{
|
||||
Id = "u1",
|
||||
Composition = new Composition
|
||||
{
|
||||
Name = "comp",
|
||||
Body = new Box { Id = "inner", Children = { new Text { Id = "deep", Content = "x" }, new Image { Id = "img", Uri = "uri://a", Alt = "alt" } } }
|
||||
},
|
||||
Slots = { new Text { Id = "s1", Content = "slot" }, null!, new Box { Id = "b2" } }
|
||||
},
|
||||
new Image { Id = "i2", Uri = "uri://b", Alt = "" }
|
||||
}
|
||||
},
|
||||
Outcomes = { new Pending { Message = "wait" }, new Completed { SourceVersion = 7 }, new Denied() },
|
||||
Part = new Tool { Name = "hammer", Uses = 12 },
|
||||
Crafted = new Part { Name = "gear", Count = 4 },
|
||||
Missing = null
|
||||
};
|
||||
|
||||
// A readable tree of a decoded value, for comparing what was read without re-encoding it.
|
||||
private static string Describe(object? value) => value switch
|
||||
{
|
||||
null => "nil",
|
||||
UnionSurface s => $"Surface({s.Id},{s.Version},{Describe(s.Root)},[{string.Join(";", s.Outcomes.Select(Describe))}],{Describe(s.Part)},{Describe(s.Crafted)},{Describe(s.Missing)})",
|
||||
Box b => $"Box({b.Id},[{string.Join(";", b.Children.Select(Describe))}])",
|
||||
Text t => $"Text({t.Id},{t.Content})",
|
||||
Use u => $"Use({u.Id},{Describe(u.Composition)},[{string.Join(";", u.Slots.Select(Describe))}])",
|
||||
Image i => $"Image({i.Id},{i.Uri},{i.Alt})",
|
||||
Composition c => $"Composition({c.Name},{Describe(c.Body)})",
|
||||
Pending p => $"Pending({p.Message})",
|
||||
Completed c => $"Completed({c.SourceVersion})",
|
||||
Denied => "Denied",
|
||||
Gem g => $"Gem({g.Name})",
|
||||
Tool t => $"Tool({t.Name},{t.Uses})",
|
||||
Part p => $"Part({p.Name},{p.Count})",
|
||||
_ => throw new InvalidOperationException(value.GetType().Name)
|
||||
};
|
||||
|
||||
// ---- the hand-written emitter: the expected bytes, written with MessagePackWriter, not captured from the code under test ----
|
||||
|
||||
private delegate void Emit(ref MessagePackWriter writer);
|
||||
|
||||
private static byte[] Raw(Emit emit)
|
||||
{
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
var writer = new MessagePackWriter(buffer);
|
||||
emit(ref writer);
|
||||
writer.Flush();
|
||||
return buffer.WrittenSpan.ToArray();
|
||||
}
|
||||
|
||||
private static readonly Emit Nil = (ref MessagePackWriter w) => w.WriteNil();
|
||||
private static readonly Emit ExtraSlot = (ref MessagePackWriter w) => w.Write("extra-slot");
|
||||
|
||||
private static Emit Str(string s) => (ref MessagePackWriter w) => w.Write(s);
|
||||
private static Emit Num(int n) => (ref MessagePackWriter w) => w.Write(n);
|
||||
|
||||
private static Emit Slots(params Emit[] slots) => (ref MessagePackWriter w) =>
|
||||
{
|
||||
w.WriteArrayHeader(slots.Length);
|
||||
foreach (var slot in slots) slot(ref w);
|
||||
};
|
||||
|
||||
private static Emit ArmOf(int key, params Emit[] slots) => (ref MessagePackWriter w) =>
|
||||
{
|
||||
w.WriteArrayHeader(2); w.Write(key);
|
||||
Slots(slots)(ref w);
|
||||
};
|
||||
|
||||
// The slots, with one slot past the last declared key when extra.
|
||||
private static Emit Plus(bool extra, params Emit[] slots) => Slots(extra ? slots.Append(ExtraSlot).ToArray() : slots);
|
||||
|
||||
private sealed record Extra(bool Record = false, bool Composition = false, bool RootArm = false, bool ListElement = false);
|
||||
|
||||
private static Emit BoxArm(string id, Emit children, bool extra = false) => ArmOf(0, extra
|
||||
? new[] { Str(id), children, ExtraSlot } : new[] { Str(id), children });
|
||||
|
||||
private static Emit TextArm(string id, string content, bool extra = false) => ArmOf(1, extra
|
||||
? new[] { Str(id), Str(content), ExtraSlot } : new[] { Str(id), Str(content) });
|
||||
|
||||
private static Emit ImageArm(string id, string uri, string alt) => ArmOf(5, Str(id), Str(uri), Nil, Str(alt));
|
||||
|
||||
// The constructed value's payload, with a part of the structure replaceable to build a refusal or an extra-slot case.
|
||||
private static byte[] Surface(Emit? root = null, Emit? outcomes = null, Emit? crafted = null, Extra? extra = null)
|
||||
{
|
||||
extra ??= new Extra();
|
||||
root ??= BoxArm("root", Slots(
|
||||
TextArm("t1", "hello"),
|
||||
ArmOf(2, Str("u1"),
|
||||
Plus(extra.Composition, Str("comp"), BoxArm("inner", Slots(TextArm("deep", "x"), ImageArm("img", "uri://a", "alt")))),
|
||||
Slots(TextArm("s1", "slot", extra.ListElement), Nil, BoxArm("b2", Slots()))),
|
||||
ImageArm("i2", "uri://b", "")), extra.RootArm);
|
||||
outcomes ??= Slots(ArmOf(0, Str("wait")), ArmOf(1, Num(7)), ArmOf(2));
|
||||
crafted ??= ArmOf(1, Str("gear"), Num(4));
|
||||
return Raw(Plus(extra.Record,
|
||||
Str("surface"), Num(3), root, outcomes,
|
||||
ArmOf(1, Str("hammer"), Num(12)),
|
||||
crafted, Nil));
|
||||
}
|
||||
|
||||
private static byte[] SerializeValue(UnionSurface value) => CultDocumentMessagePackSerialization.SerializeUntyped(value, typeof(UnionSurface), Registry);
|
||||
|
||||
private static UnionSurface Decode(byte[] payload) =>
|
||||
(UnionSurface)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(UnionSurface), payload, Registry);
|
||||
|
||||
// ---- the refusal and extra-slot vectors, each with its expected outcome as data ----
|
||||
|
||||
// file, outcome ("refused" | "read-only"), union (logical name), arm key (-1 for none), detail.
|
||||
private sealed record Case(string File, string Outcome, string Union, int Key, string Detail, byte[] Payload);
|
||||
|
||||
private static IReadOnlyList<Case> Cases() => new[]
|
||||
{
|
||||
new Case("unknown-arm-field.msgpack", "refused", "Node", 3, "unknown-arm",
|
||||
Surface(root: ArmOf(3, Str("r"), Str(Canary)))),
|
||||
new Case("unknown-arm-list.msgpack", "refused", "Outcome", 7, "unknown-arm",
|
||||
Surface(outcomes: Slots(ArmOf(0, Str("wait")), ArmOf(7, Str(Canary))))),
|
||||
new Case("unknown-arm-nested.msgpack", "refused", "Node", 9, "unknown-arm",
|
||||
Surface(root: BoxArm("root", Slots(BoxArm("l1", Slots(BoxArm("l2", Slots(ArmOf(9, Str("deep"), Str(Canary)))))))))),
|
||||
new Case("inner-union-outer-key.msgpack", "refused", "CraftedItem", 2, "unknown-arm",
|
||||
Surface(crafted: ArmOf(2, Str("gear"), Num(4)))),
|
||||
new Case("arm-arity.msgpack", "refused", "Node", 1, "arity",
|
||||
Surface(root: (ref MessagePackWriter w) => { w.WriteArrayHeader(1); w.Write(1); })),
|
||||
new Case("extra-slot-record.msgpack", "read-only", "UnionSurface", -1, "record",
|
||||
Surface(extra: new Extra(Record: true))),
|
||||
new Case("extra-slot-nested-record.msgpack", "read-only", "Composition", -1, "nested-record",
|
||||
Surface(extra: new Extra(Composition: true))),
|
||||
new Case("extra-slot-arm.msgpack", "read-only", "Node", 0, "arm",
|
||||
Surface(extra: new Extra(RootArm: true))),
|
||||
new Case("extra-slot-list-element.msgpack", "read-only", "Node", 1, "list-element",
|
||||
Surface(extra: new Extra(ListElement: true))),
|
||||
};
|
||||
|
||||
private static byte[] OutcomesFile() => Raw((ref MessagePackWriter w) =>
|
||||
{
|
||||
var cases = Cases();
|
||||
w.WriteArrayHeader(cases.Count);
|
||||
foreach (var c in cases)
|
||||
{
|
||||
w.WriteArrayHeader(5);
|
||||
w.Write(c.File); w.Write(c.Outcome); w.Write(c.Union);
|
||||
if (c.Key < 0) w.WriteNil(); else w.Write(c.Key);
|
||||
w.Write(c.Detail);
|
||||
}
|
||||
});
|
||||
|
||||
private static string UnionName(string logical) => logical switch
|
||||
{
|
||||
"Node" => typeof(Node).FullName!,
|
||||
"Outcome" => typeof(Outcome).FullName!,
|
||||
"CraftedItem" => typeof(CraftedItem).FullName!,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(logical), logical, null)
|
||||
};
|
||||
|
||||
private static string Chain(Exception exception)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
for (var cause = exception; cause != null; cause = cause.InnerException) parts.Add(cause.Message);
|
||||
return string.Join(" <- ", parts);
|
||||
}
|
||||
|
||||
// ---- tests ----
|
||||
|
||||
[Test]
|
||||
public void UnionSurfaceVectorMatches()
|
||||
{
|
||||
var value = Constructed();
|
||||
var written = SerializeValue(value);
|
||||
Assert.That(Convert.ToHexString(written), Is.EqualTo(Convert.ToHexString(Surface())), "the hand-written bytes are what the reference writes");
|
||||
Assert.That(Convert.ToHexString(MessagePackSerializer.Serialize(value, MessagePackSerializerOptions.Standard)), Is.EqualTo(Convert.ToHexString(written)));
|
||||
Check("union-surface.msgpack", written);
|
||||
|
||||
var committed = Committed("union-surface.msgpack");
|
||||
var decoded = Decode(committed);
|
||||
Assert.That(Describe(decoded), Is.EqualTo(Describe(value)));
|
||||
Assert.That(Convert.ToHexString(SerializeValue(decoded)), Is.EqualTo(Convert.ToHexString(committed)), "the committed payload re-encodes byte-identical");
|
||||
|
||||
var work = Path.Combine(Path.GetTempPath(), $"cultlib-unionparity-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(work);
|
||||
try
|
||||
{
|
||||
var scratch = Path.Combine(work, "union-surface.cc");
|
||||
using (var cache = CultCacheMessagePack.Create(scratch, new CultCacheOpenOptions { Registry = Registry, StoreFlushOnDispose = true }))
|
||||
{
|
||||
cache.Clock = () => StoredAt;
|
||||
cache.UpsertAsync(typeof(UnionSurface), value, new CultRecordKey("surface")).GetAwaiter().GetResult();
|
||||
cache.FlushAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
Check("union-surface.cc", File.ReadAllBytes(scratch));
|
||||
|
||||
// The committed store: one record whose payload is the committed payload, holding the constructed value, and rewritten unchanged.
|
||||
var store = Committed("union-surface.cc");
|
||||
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(store);
|
||||
Assert.That(snapshot.Records, Has.Length.EqualTo(1));
|
||||
Assert.That(Convert.ToHexString(snapshot.Records[0].Payload), Is.EqualTo(Convert.ToHexString(committed)));
|
||||
File.WriteAllBytes(scratch, store);
|
||||
using (var cache = CultCacheMessagePack.Create(scratch, new CultCacheOpenOptions { Registry = Registry }))
|
||||
{
|
||||
Assert.That(Describe(cache.Get<UnionSurface>(new CultRecordKey("surface"))), Is.EqualTo(Describe(value)));
|
||||
cache.BackingStores[0].PushAll();
|
||||
}
|
||||
|
||||
Assert.That(Convert.ToHexString(File.ReadAllBytes(scratch)), Is.EqualTo(Convert.ToHexString(store)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(work, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
// The value names every arm shape the vectors exist to hold other runtimes to.
|
||||
[Test]
|
||||
public void ConstructedValueExercisesEveryArmShape()
|
||||
{
|
||||
var value = Decode(Committed("union-surface.msgpack"));
|
||||
var root = (Box)value.Root!;
|
||||
var use = root.Children.OfType<Use>().Single();
|
||||
var inner = (Box)use.Composition.Body!;
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(value.Outcomes.OfType<Denied>().Count(), Is.EqualTo(1), "a unit arm");
|
||||
Assert.That(root.Children, Has.Count.GreaterThan(1), "a list of unions");
|
||||
Assert.That(use.Slots.Any(s => s is Box) && use.Slots.Any(s => s is null), Is.True, "a list of unions holding a nested union arm and nil");
|
||||
Assert.That(inner.Children.OfType<Image>().Count(), Is.EqualTo(1), "a union inside a record inside a union, four arms deep");
|
||||
Assert.That(value.Part, Is.TypeOf<Tool>());
|
||||
Assert.That(value.Crafted, Is.TypeOf<Part>(), "an inner union keyed apart from its outer");
|
||||
Assert.That(value.Missing, Is.Null);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void UnionRefusalVectorsRefused()
|
||||
{
|
||||
foreach (var c in Cases()) Check(c.File, c.Payload);
|
||||
Check("outcomes.msgpack", OutcomesFile());
|
||||
|
||||
// From here only the committed files speak: the outcomes file is the data the other runtimes read.
|
||||
var golden = Describe(Constructed());
|
||||
var reader = new MessagePackReader(Committed("outcomes.msgpack"));
|
||||
var count = reader.ReadArrayHeader();
|
||||
Assert.That(count, Is.EqualTo(Cases().Count));
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
Assert.That(reader.ReadArrayHeader(), Is.EqualTo(5));
|
||||
var file = reader.ReadString()!;
|
||||
var outcome = reader.ReadString()!;
|
||||
var union = reader.ReadString()!;
|
||||
int? key = reader.TryReadNil() ? null : reader.ReadInt32();
|
||||
var detail = reader.ReadString()!;
|
||||
var payload = Committed(file);
|
||||
|
||||
if (outcome == "refused")
|
||||
{
|
||||
UnionSurface? accepted = null;
|
||||
Exception? thrown = null;
|
||||
try { accepted = Decode(payload); } catch (Exception exception) { thrown = exception; }
|
||||
Assert.That(thrown, Is.Not.Null, $"{file} was accepted");
|
||||
var message = Chain(thrown!);
|
||||
Assert.That(message, Does.Not.Contain(Canary), $"{file}: a refusal never echoes the payload");
|
||||
if (detail == "arity")
|
||||
{
|
||||
Assert.That(message, Does.Contain("Invalid Union data"), file);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.That(message, Does.Contain(UnionName(union)).And.Contain($"key {key} "), file);
|
||||
Assert.That(accepted, Is.Null, file);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.That(outcome, Is.EqualTo("read-only"), file);
|
||||
// The record is read whole; what makes it read-only is the slot rule of the slot-tolerance cut, not asserted here.
|
||||
Assert.That(Describe(Decode(payload)), Is.EqualTo(golden), $"{file}: the extra slot is read past");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Runs whatever the switch reads when the test starts: it sets the switch itself, and writes only a probe file.
|
||||
[Test, NonParallelizable]
|
||||
public void VectorsNotRewrittenByDefault()
|
||||
{
|
||||
string Fingerprint() => string.Join("|", Directory.EnumerateFiles(VectorDirectory()).OrderBy(f => f, StringComparer.Ordinal)
|
||||
.Select(f => Path.GetFileName(f) + "=" + Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(f)))));
|
||||
|
||||
const string probe = "gate-probe.msgpack";
|
||||
var path = VectorPath(probe);
|
||||
var before = Fingerprint();
|
||||
var inherited = Environment.GetEnvironmentVariable(WriteVariable);
|
||||
try
|
||||
{
|
||||
foreach (var off in new string?[] { null, string.Empty, "0", "true", "11" })
|
||||
{
|
||||
Environment.SetEnvironmentVariable(WriteVariable, off);
|
||||
Assert.Throws<AssertionException>(() => Check(probe, new byte[] { 0x90 }), $"a missing vector fails the run, switch {off ?? "unset"}");
|
||||
Assert.That(File.Exists(path), Is.False, $"switch {off ?? "unset"} writes nothing");
|
||||
|
||||
File.WriteAllBytes(path, new byte[] { 0x91 });
|
||||
Assert.Throws<AssertionException>(() => Check(probe, new byte[] { 0x90 }), $"bytes that differ from the vector fail the run, switch {off ?? "unset"}");
|
||||
Assert.That(File.ReadAllBytes(path), Is.EqualTo(new byte[] { 0x91 }), $"switch {off ?? "unset"} rewrites nothing");
|
||||
File.Delete(path);
|
||||
}
|
||||
|
||||
Environment.SetEnvironmentVariable(WriteVariable, "1");
|
||||
Check(probe, new byte[] { 0x90 });
|
||||
Assert.That(File.ReadAllBytes(path), Is.EqualTo(new byte[] { 0x90 }), "1 is the one way to write a vector");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(WriteVariable, inherited);
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
|
||||
Assert.That(Fingerprint(), Is.EqualTo(before), "no committed vector changed");
|
||||
}
|
||||
}
|
||||
}
|
||||
2
tests/vectors/union-parity/.gitattributes
vendored
Normal file
2
tests/vectors/union-parity/.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
*.msgpack binary
|
||||
*.cc binary
|
||||
60
tests/vectors/union-parity/README.md
Normal file
60
tests/vectors/union-parity/README.md
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# union-parity vectors
|
||||
|
||||
Written only by the C# reference, `tests/GameCult.Caching.Tests/UnionParityVectorTests.cs`, with
|
||||
`CULTCACHE_WRITE_VECTORS=1`. Every other run reads these files and compares them byte for byte; a changed
|
||||
vector is a reviewed commit. Other runtimes decode them and are held to the same outcomes.
|
||||
|
||||
Document `tests.union_surface` (schema `tests.union_surface.v1`), a positional array of
|
||||
`[Id, Version, Root, Outcomes, Part, Crafted, Missing]`. A union value is `nil` or `[armKey, [armSlots...]]`.
|
||||
|
||||
| Union | Arm keys | Arm slots |
|
||||
|---|---|---|
|
||||
| Node | 0 Box, 1 Text, 2 Use, 5 Image (3 and 4 retired) | Box `[Id, Children: Node list]`; Text `[Id, Content]`; Use `[Id, Composition, Slots: Node list]`; Image `[Id, Uri, nil (retired 2), Alt]` |
|
||||
| Outcome | 0 Pending, 1 Completed, 2 Denied | Pending `[Message]`; Completed `[SourceVersion]`; Denied is a unit arm, `[2, []]` |
|
||||
| Item | 0 Gem, 1 Tool, 2 Part | Gem `[Name]`; Tool `[Name, Uses]`; Part `[Name, Count]` |
|
||||
| CraftedItem | 0 Tool, 1 Part | the same arms as Item under its own keys; a key only Item declares is unknown to it |
|
||||
|
||||
Composition is a record, not an arm: `[Name, Body: Node]`.
|
||||
|
||||
## Files
|
||||
|
||||
- `union-surface.msgpack`: the payload. Value: Id `surface`, Version 3; Root is a Box `root` holding Text `t1`, a Use
|
||||
`u1` and Image `i2`; the Use's Composition `comp` holds Box `inner` (Text `deep`, Image `img`), and its Slots are
|
||||
Text `s1`, nil, Box `b2` (empty); Outcomes are Pending, Completed, Denied; Part is Tool `hammer`; Crafted (a
|
||||
CraftedItem field) is Part `gear`; Missing is nil. A union inside a record inside a union, a unit arm, a list of
|
||||
unions with a nil element, an inner union keyed apart from its outer. Written by C#.
|
||||
- `union-surface.cc`: a single-file store written by the C# store holding that one record under key `surface`. Its
|
||||
payload equals `union-surface.msgpack`; rewriting the loaded store is byte-identical. The writer pins the store's
|
||||
clock, so the record's stored-at is `2026-01-01T00:00:00.0000000+00:00` and the whole file reproduces byte for byte.
|
||||
- `outcomes.msgpack`: the expected outcome of every file below, as data. An array of entries
|
||||
`[file, outcome, union, key, detail]`: `outcome` is `refused` or `read-only`; `union` is the union (or record)
|
||||
named; `key` is the arm key or nil; `detail` is as in the tables below.
|
||||
|
||||
## Refusals (outcome `refused`)
|
||||
|
||||
A refusal names the union and the arm key and never echoes the payload. Each payload is `union-surface.msgpack`
|
||||
with one part replaced.
|
||||
|
||||
| File | Replaced part | detail | Must name |
|
||||
|---|---|---|---|
|
||||
| `unknown-arm-field.msgpack` | Root is an arm with the retired key 3 | unknown-arm | Node, key 3 |
|
||||
| `unknown-arm-list.msgpack` | an Outcomes element has key 7 | unknown-arm | Outcome, key 7 |
|
||||
| `unknown-arm-nested.msgpack` | key 9 three Boxes deep in Children | unknown-arm | Node, key 9 |
|
||||
| `inner-union-outer-key.msgpack` | the Crafted field carries key 2, declared only by Item | unknown-arm | CraftedItem, key 2 |
|
||||
| `arm-arity.msgpack` | Root is written `[1]`, an arm with no slots array | arity | a refusal of the arm as malformed (C#: MessagePack's "Invalid Union data") |
|
||||
|
||||
## Extra trailing slot (outcome `read-only`)
|
||||
|
||||
Each payload is `union-surface.msgpack` with one slot appended past the reader's last declared slot of one
|
||||
record (ruling `cultlib-gaps:ruling:extra-trailing-slots`: the record is read, read-only at any depth, every write
|
||||
of it refused). A reader decodes the value of `union-surface.msgpack` from each; `detail` says where the slot is.
|
||||
|
||||
| File | detail | Record with the extra slot |
|
||||
|---|---|---|
|
||||
| `extra-slot-record.msgpack` | record | the document (slot 7) |
|
||||
| `extra-slot-nested-record.msgpack` | nested-record | Composition `comp` |
|
||||
| `extra-slot-arm.msgpack` | arm | the Root Box arm |
|
||||
| `extra-slot-list-element.msgpack` | list-element | Text `s1`, an element of Use.Slots |
|
||||
|
||||
The C# reference reads these today and its slot-tolerance cut is what makes them read-only; this cut asserts only
|
||||
that the value is read.
|
||||
BIN
tests/vectors/union-parity/arm-arity.msgpack
Normal file
BIN
tests/vectors/union-parity/arm-arity.msgpack
Normal file
Binary file not shown.
BIN
tests/vectors/union-parity/extra-slot-arm.msgpack
Normal file
BIN
tests/vectors/union-parity/extra-slot-arm.msgpack
Normal file
Binary file not shown.
BIN
tests/vectors/union-parity/extra-slot-list-element.msgpack
Normal file
BIN
tests/vectors/union-parity/extra-slot-list-element.msgpack
Normal file
Binary file not shown.
BIN
tests/vectors/union-parity/extra-slot-nested-record.msgpack
Normal file
BIN
tests/vectors/union-parity/extra-slot-nested-record.msgpack
Normal file
Binary file not shown.
BIN
tests/vectors/union-parity/extra-slot-record.msgpack
Normal file
BIN
tests/vectors/union-parity/extra-slot-record.msgpack
Normal file
Binary file not shown.
BIN
tests/vectors/union-parity/inner-union-outer-key.msgpack
Normal file
BIN
tests/vectors/union-parity/inner-union-outer-key.msgpack
Normal file
Binary file not shown.
BIN
tests/vectors/union-parity/outcomes.msgpack
Normal file
BIN
tests/vectors/union-parity/outcomes.msgpack
Normal file
Binary file not shown.
BIN
tests/vectors/union-parity/union-surface.cc
Normal file
BIN
tests/vectors/union-parity/union-surface.cc
Normal file
Binary file not shown.
BIN
tests/vectors/union-parity/union-surface.msgpack
Normal file
BIN
tests/vectors/union-parity/union-surface.msgpack
Normal file
Binary file not shown.
BIN
tests/vectors/union-parity/unknown-arm-field.msgpack
Normal file
BIN
tests/vectors/union-parity/unknown-arm-field.msgpack
Normal file
Binary file not shown.
BIN
tests/vectors/union-parity/unknown-arm-list.msgpack
Normal file
BIN
tests/vectors/union-parity/unknown-arm-list.msgpack
Normal file
Binary file not shown.
BIN
tests/vectors/union-parity/unknown-arm-nested.msgpack
Normal file
BIN
tests/vectors/union-parity/unknown-arm-nested.msgpack
Normal file
Binary file not shown.
Loading…
Reference in a new issue