diff --git a/tests/GameCult.Caching.Tests/AssemblyInfo.cs b/tests/GameCult.Caching.Tests/AssemblyInfo.cs index 708f0a09..85ae1449 100644 --- a/tests/GameCult.Caching.Tests/AssemblyInfo.cs +++ b/tests/GameCult.Caching.Tests/AssemblyInfo.cs @@ -10,6 +10,7 @@ using MessagePack.Formatters; [assembly: CultCacheFormatterResolver(typeof(UnionArmGuardTests.OrderResolver))] [assembly: CultCacheFormatterResolver(typeof(UnionArmGuardTests.ListClaimResolver))] [assembly: CultCacheFormatterResolver(typeof(UnionArmGuardTests.FbLaterClaimResolver))] +[assembly: CultCacheFormatterResolver(typeof(SlotReadOnlyTests.ResolvedBagResolver))] namespace GameCult.Caching.Tests { diff --git a/tests/GameCult.Caching.Tests/SlotReadOnlyTests.cs b/tests/GameCult.Caching.Tests/SlotReadOnlyTests.cs index daa4beba..3f3bcd15 100644 --- a/tests/GameCult.Caching.Tests/SlotReadOnlyTests.cs +++ b/tests/GameCult.Caching.Tests/SlotReadOnlyTests.cs @@ -160,10 +160,10 @@ namespace GameCult.Caching.Tests { var reader = new MessagePackReader(VectorFiles.Committed("union-parity", "slot-outcomes.msgpack")); var rows = reader.ReadArrayHeader(); - Assert.That(rows, Is.EqualTo(7)); + Assert.That(rows, Is.EqualTo(8)); for (var i = 0; i < rows; i++) { - Assert.That(reader.ReadArrayHeader(), Is.EqualTo(8)); + Assert.That(reader.ReadArrayHeader(), Is.EqualTo(9)); var store = reader.ReadString()!; var key = new CultRecordKey(reader.ReadString()!); var writable = reader.ReadBoolean(); @@ -172,6 +172,7 @@ namespace GameCult.Caching.Tests var counts = new int?[3]; for (var c = 0; c < 3; c++) counts[c] = reader.TryReadNil() ? null : reader.ReadInt32(); var (slots, declared, occurrences) = (counts[0], counts[1], counts[2]); + var flattenRefusedBy = reader.TryReadNil() ? null : reader.ReadString(); var row = $"{store} {key.Value}"; var path = Path.Combine(_directory, $"{i}-{store}"); @@ -192,6 +193,13 @@ namespace GameCult.Caching.Tests Assert.That(cache.IsWritable(key), Is.EqualTo(writable), row); Assert.That(cache.IsWritable(Unheld), Is.True, "a key no store holds is writable"); + if (flattenRefusedBy != null) + { + var refusal = Assert.Throws(() => cache.Commit(batch => batch.Flatten(key)), $"{row}: flatten")!; + Assert.That(refusal.RecordKey, Is.EqualTo(flattenRefusedBy), $"{row}: the refusal is the base's own"); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), $"{row}: a refused flatten leaves the store as it was"); + } + if (writable) { Assert.That(cache.Commit(batch => batch.Remove(key)), Is.True, row); @@ -305,7 +313,6 @@ namespace GameCult.Caching.Tests reader.AddBackingStore(Store()); var unread = Stored(reader, Marked).UnreadSlots; Assert.That(unread, Is.Not.Null, "the store kind decodes through the marking path"); - Assert.That(unread!.Explained, Is.True, "depth 0 and reported by the catalog comparison"); Assert.That(unread.TypeName, Is.EqualTo(nameof(NarrowDoc))); Assert.That(unread.SlotCount, Is.EqualTo(3)); Assert.That(unread.DeclaredCount, Is.EqualTo(2)); @@ -369,6 +376,37 @@ namespace GameCult.Caching.Tests } } + // A [MessagePackObject] type an assembly resolver formats as [count, items...], with no [MessagePackFormatter] attribute: the + // resolver owns its layout. + [MessagePackObject] + public sealed class ResolvedBag + { + [Key(0)] public List Items = new(); + } + + public sealed class ResolvedBagResolver : IFormatterResolver + { + public static readonly IFormatterResolver Instance = new ResolvedBagResolver(); + private static readonly IMessagePackFormatter Formatter = new ResolvedBagFormatter(); + + public IMessagePackFormatter? GetFormatter() => typeof(T) == typeof(ResolvedBag) ? (IMessagePackFormatter)Formatter : null; + + private sealed class ResolvedBagFormatter : IMessagePackFormatter + { + private static readonly BagFormatter Bag = new(); + + public void Serialize(ref MessagePackWriter writer, ResolvedBag? value, MessagePackSerializerOptions options) => + Bag.Serialize(ref writer, value == null ? null : new Bag { Items = value.Items }, options); + + public ResolvedBag? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) => + Bag.Deserialize(ref reader, options) is { } bag ? new ResolvedBag { Items = bag.Items } : null; + } + } + + // A [MessagePackObject] collection: MessagePack writes it as its items, not by [Key]. + [MessagePackObject] + public class TagList : List { } + [CultDocument("tests.slot_measured", "tests.slot_measured.v1")] [MessagePackObject] public sealed class MeasuredDoc @@ -378,6 +416,8 @@ namespace GameCult.Caching.Tests [Key(2)] public Counted? Counted; [Key(3)] public Uncounted? Uncounted; [Key(4)] public Bag? Bag; + [Key(5)] public ResolvedBag? Resolved; + [Key(6)] public TagList? Tags; } private static readonly CultDocumentRegistry MeasuredRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(MeasuredDoc) }); @@ -388,6 +428,8 @@ namespace GameCult.Caching.Tests "constructed" => new MeasuredDoc { Name = member, Counted = new Counted { Count = 2 } }, "unmeasurable" => new MeasuredDoc { Name = member, Uncounted = new Uncounted(2) }, "own-formatter" => new MeasuredDoc { Name = member, Bag = new Bag { Items = { "x", "y", "z" } } }, + "resolver-formatted" => new MeasuredDoc { Name = member, Resolved = new ResolvedBag { Items = { "x", "y", "z" } } }, + "collection" => new MeasuredDoc { Name = member, Tags = new TagList { "a", "b", "c" } }, _ => throw new ArgumentOutOfRangeException(nameof(member), member, null) }; @@ -395,6 +437,8 @@ namespace GameCult.Caching.Tests [TestCase("constructed", TestName = "ATypeMeasuredThroughItsConstructorDoesNotMark")] [TestCase("unmeasurable", TestName = "AnUnmeasurableTypeIsNotGuarded")] [TestCase("own-formatter", TestName = "ATypeWithItsOwnFormatterIsNotMeasuredByKeys")] + [TestCase("resolver-formatted", TestName = "ATypeAResolverFormatsIsNotMeasuredByKeys")] + [TestCase("collection", TestName = "AMessagePackObjectCollectionIsNotMeasuredByKeys")] public void ARecordWrittenByItsOwnTypeDoesNotMark(string member) { var path = Path.Combine(_directory, "measured.cc"); diff --git a/tests/GameCult.Caching.Tests/UnionParityVectorTests.cs b/tests/GameCult.Caching.Tests/UnionParityVectorTests.cs index 30e8f744..067a6cef 100644 --- a/tests/GameCult.Caching.Tests/UnionParityVectorTests.cs +++ b/tests/GameCult.Caching.Tests/UnionParityVectorTests.cs @@ -378,14 +378,16 @@ namespace GameCult.Caching.Tests _ => throw new ArgumentOutOfRangeException(nameof(store), store, null) }; - // One row of slot-outcomes.msgpack: what the store's reader holds at the key. The mark fields are null for an unmarked record. - private sealed record SlotOutcome(string Store, string Key, bool Writable, bool? Explained, string? Type, int? Slots, int? Declared, int? Occurrences); + // One row of slot-outcomes.msgpack: what the store's reader holds at the key. The mark fields are null for an unmarked record; + // FlattenRefusedBy is the key whose refusal refuses a Flatten of the record, null when the record is no variant or flattens. + private sealed record SlotOutcome(string Store, string Key, bool Writable, bool? Explained, string? Type, int? Slots, int? Declared, int? Occurrences, string? FlattenRefusedBy = null); private static readonly SlotOutcome[] SlotOutcomes = { new("extra-slot-store.cc", "marked", false, false, nameof(Composition), 3, 2, 1), new("extra-slot-store.cc", "clean", true, null, null, null, null, null), new("extra-slot-store.cc", "unit-arm", false, false, nameof(Denied2), 1, 0, 1), + new("extra-slot-store.cc", "marked-variant", true, null, null, null, null, null, "marked"), new("extra-slot-consent.cc", "consented", true, true, nameof(SurfaceDeclaring), 8, 7, 1), new("extra-slot-consent.cc", "over-consent", false, false, nameof(SurfaceDeclaring), 9, 7, 1), new("extra-slot-consent.cc", "nested", false, false, nameof(SurfaceDeclaring), 8, 7, 2), @@ -397,12 +399,13 @@ namespace GameCult.Caching.Tests w.WriteArrayHeader(SlotOutcomes.Length); foreach (var row in SlotOutcomes) { - w.WriteArrayHeader(8); + w.WriteArrayHeader(9); w.Write(row.Store); w.Write(row.Key); w.Write(row.Writable); if (row.Explained is { } explained) w.Write(explained); else w.WriteNil(); if (row.Type is { } type) w.Write(type); else w.WriteNil(); foreach (var count in new[] { row.Slots, row.Declared, row.Occurrences }) if (count is { } value) w.Write(value); else w.WriteNil(); + if (row.FlattenRefusedBy is { } refusedBy) w.Write(refusedBy); else w.WriteNil(); } }); @@ -482,8 +485,10 @@ namespace GameCult.Caching.Tests } // A single-file store of the records, each written as its type's value with the store's clock pinned as union-surface.cc's, then - // its payload replaced by bytes no current writer produces; edit changes the snapshot further before it is serialized. - private static byte[] SlotStore(CultDocumentRegistry registry, (string Key, object Value, byte[] Payload)[] records, Action? edit = null) + // its payload replaced by bytes no current writer produces; each variant is written over its base (overriding the name to its + // key) before the payloads are replaced; edit changes the snapshot further before it is serialized. + private static byte[] SlotStore(CultDocumentRegistry registry, (string Key, object Value, byte[] Payload)[] records, Action? edit = null, + params (string Key, string Base)[] variants) { var work = Path.Combine(Path.GetTempPath(), $"cultlib-unionparity-{Guid.NewGuid():N}"); Directory.CreateDirectory(work); @@ -495,6 +500,9 @@ namespace GameCult.Caching.Tests cache.Clock = () => StoredAt; foreach (var (key, value, _) in records) cache.UpsertAsync(value.GetType(), value, new CultRecordKey(key)).GetAwaiter().GetResult(); + foreach (var (key, baseKey) in variants) + Assert.That(cache.Commit(batch => batch.UpsertVariant(new CultRecordKey(key), new CultRecordKey(baseKey), + new[] { cache.Override(typeof(UnionSurface), nameof(UnionSurface.Id), key) })), Is.True); cache.FlushAsync().GetAwaiter().GetResult(); } @@ -511,7 +519,8 @@ namespace GameCult.Caching.Tests } // The store the slot-readonly tests open and the other runtimes read: "marked" holds the extra-slot-nested-record payload, - // "clean" the ordinary value, and "unit-arm" a tests.slot_unit document ["unit-arm", [2, ["extra-slot"]]]. + // "clean" the ordinary value, "unit-arm" a tests.slot_unit document ["unit-arm", [2, ["extra-slot"]]], and "marked-variant" a + // variant of "marked" overriding only its Id: writable, but a flatten of it is refused for its base. [Test] public void ExtraSlotStoreVectorHoldsAMarkedACleanAndAUnitArmRecord() { @@ -520,7 +529,7 @@ namespace GameCult.Caching.Tests ("marked", Constructed(), Committed("extra-slot-nested-record.msgpack")), ("clean", Constructed(), Surface()), ("unit-arm", new UnitDoc { Name = "unit-arm", Outcome = new Denied2() }, Raw(Slots(Str("unit-arm"), ArmOf(2, ExtraSlot)))), - })); + }, null, ("marked-variant", "marked"))); } // The consent store: every record under the wide id, whose catalog entry is UnionSurface's 7 members plus slot 7. Its reader diff --git a/tests/vectors/union-parity/extra-slot-store.cc b/tests/vectors/union-parity/extra-slot-store.cc index 52447924..c5bc6cfb 100644 Binary files a/tests/vectors/union-parity/extra-slot-store.cc and b/tests/vectors/union-parity/extra-slot-store.cc differ diff --git a/tests/vectors/union-parity/slot-outcomes.msgpack b/tests/vectors/union-parity/slot-outcomes.msgpack index eed32904..cd9a9ef8 100644 Binary files a/tests/vectors/union-parity/slot-outcomes.msgpack and b/tests/vectors/union-parity/slot-outcomes.msgpack differ