Slot tests, red: a resolver-formatted type and a [MessagePackObject] collection are not measured; flatten-chain vector row

ResolvedBag (an assembly CultCacheFormatterResolver writes [count, items...]) and TagList
(List<string> with [MessagePackObject]) are falsely marked by the attribute-based guard (Soul s2,
guard-asks-attribute-not-formatter). extra-slot-store.cc gains marked-variant, a variant of
marked; slot-outcomes.msgpack rows gain a ninth column, the key whose refusal refuses a Flatten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Libby 2026-11-12 01:13:44 +00:00
commit 0dfc437bfc
5 changed files with 64 additions and 10 deletions

View file

@ -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
{

View file

@ -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<CultSchemaConflictException>(() => 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<string> Items = new();
}
public sealed class ResolvedBagResolver : IFormatterResolver
{
public static readonly IFormatterResolver Instance = new ResolvedBagResolver();
private static readonly IMessagePackFormatter<ResolvedBag?> Formatter = new ResolvedBagFormatter();
public IMessagePackFormatter<T>? GetFormatter<T>() => typeof(T) == typeof(ResolvedBag) ? (IMessagePackFormatter<T>)Formatter : null;
private sealed class ResolvedBagFormatter : IMessagePackFormatter<ResolvedBag?>
{
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<string> { }
[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");

View file

@ -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<CultPersistedStoreSnapshot>? 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<CultPersistedStoreSnapshot>? 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