Element ids: a keyed map counts as enumerating objects; family check tests assert the exact refusal

EnumeratesObjects took ElementTypeOf first, which for a dictionary is the
KeyValuePair (a leaf), so a keyed Dictionary subclass never reached the path; the
map's value type is asked first. The unreachable null-standard guard in
WrittenAsWalked is removed. Tests pin the direction and wording of the refusal, the
attribute-formatter collection case and the unwrapped inner exception.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-15 03:09:39 +02:00
commit 48d3f667e6
3 changed files with 38 additions and 6 deletions

View file

@ -149,8 +149,6 @@ public static class CultDocumentMessagePackSerialization
// collection resolver; a keyed class that is also an ICollection<T> is written as a collection and its [Key] layout dropped.
private static bool WrittenAsWalked(Type type, MethodInfo generic, object? standard)
{
if (standard == null)
return true;
var families = CultElementIds.WalksAsOwner(type)
? new IFormatterResolver[] { DynamicUnionResolver.Instance, DynamicObjectResolver.Instance }
: new IFormatterResolver[] { DynamicGenericResolver.Instance, ImmutableCollectionResolver.Instance, BuiltinResolver.Instance };

View file

@ -607,7 +607,7 @@ namespace GameCult.Caching
internal static bool WalksAsOwner(Type type) => Classify(Nullable.GetUnderlyingType(type) ?? type).Kind == Kind.Owner;
private static bool EnumeratesObjects(Type type) =>
(ElementTypeOf(type) ?? MapTypes(type)?.Value) is { } inner && Classify(inner).Kind != Kind.Leaf;
(MapTypes(type)?.Value ?? ElementTypeOf(type)) is { } inner && Classify(inner).Kind != Kind.Leaf;
private static int? KeyOf(MemberInfo member) => member.GetCustomAttribute<KeyAttribute>(true)?.IntKey;

View file

@ -368,7 +368,11 @@ namespace GameCult.Caching.Tests
var coll = new KCollDoc { Id = "k1" };
coll.Items.Add(new FbArm { Id = "00000000000c" });
var thrown = Assert.Throws<InvalidOperationException>(() => CultDocumentMessagePackSerialization.SerializeUntyped(coll, typeof(KCollDoc), registry))!;
Assert.That(thrown.Message, Does.Contain(nameof(KColl)).And.Contain("as a collection"));
Assert.That(thrown.Message, Does.Contain($"writes {nameof(KColl)} as a collection, but the element-id walk reads it by its [Key] layout"));
// A keyed map is read by key too, so a keyed Dictionary subclass is on the path and refused the same way.
var map = Assert.Throws<InvalidOperationException>(() => CultDocumentMessagePackSerialization.OptionsForDocument(typeof(KDictDoc)))!;
Assert.That(map.Message, Does.Contain($"writes {nameof(KDict)} as a collection"));
var bag = new KBagDoc { Id = "b1", Bag = new KBagE { Items = { new FbArm { Id = "00000000000d" } } } };
var bytes = CultDocumentMessagePackSerialization.SerializeUntyped(bag, typeof(KBagDoc), registry);
@ -399,6 +403,14 @@ namespace GameCult.Caching.Tests
}
}
// The walk reads a List subclass as a collection; a type MessagePack writes by an attribute formatter instead is a mismatch.
[Test]
public void ACollectionMessagePackWritesByAnotherFormatterIsRefused()
{
var thrown = Assert.Throws<InvalidOperationException>(() => CultDocumentMessagePackSerialization.OptionsForDocument(typeof(G<FormattedRoles>)))!;
Assert.That(thrown.Message, Does.Contain($"writes {nameof(FormattedRoles)} by its [Key] layout, but the element-id walk reads it as a collection"));
}
// A resolver that throws while asked about an id-path type is a typed refusal, whether it is the AllowPrivate formatter
// the claim check asks about (the claim stands) or a resolver ahead (it fails to build).
[Test]
@ -407,11 +419,12 @@ namespace GameCult.Caching.Tests
var first = Assert.Throws<InvalidOperationException>(() => CultDocumentMessagePackSerialization.OptionsForDocument(typeof(DkDoc)))!;
Assert.That(first.Message, Does.Contain(nameof(ListClaimResolver)).And.Contain(nameof(DkEl)));
var second = Assert.Throws<InvalidOperationException>(() => CultDocumentMessagePackSerialization.OptionsForDocument(typeof(DkDoc2)))!;
Assert.That(second.Message, Does.Contain(nameof(ListClaimResolver)).And.Contain(nameof(DkEl2)).And.Contain("failed to build"));
Assert.That(second.Message, Does.Contain(nameof(ListClaimResolver)).And.Contain(nameof(DkEl2)).And.Contain("failed to build").And.Not.Contain("target of an invocation"),
"the resolver's own exception is named, not the reflection wrapper");
// A type MessagePack itself cannot build a formatter for (two public members on one key) is refused, naming the type.
var third = Assert.Throws<InvalidOperationException>(() => CultDocumentMessagePackSerialization.OptionsForDocument(typeof(DkDoc3)))!;
Assert.That(third.Message, Does.Contain(nameof(DkEl3)).And.Contain("cannot build a formatter"));
Assert.That(third.Message, Does.Contain(nameof(DkEl3)).And.Contain("cannot build a formatter").And.Not.Contain("target of an invocation"));
}
[MessagePackObject]
@ -428,6 +441,20 @@ namespace GameCult.Caching.Tests
[Key(1)] public KColl Items = new();
}
[MessagePackObject]
public sealed class KDict : Dictionary<string, GHold>
{
[Key(0)] public int N;
}
[CultDocument("tests.kdict_doc", "tests.kdict_doc.v1")]
[MessagePackObject]
public sealed class KDictDoc
{
[Key(0)] [CultName] public string Id = string.Empty;
[Key(1)] public KDict Items = new();
}
[MessagePackObject]
public sealed class KBagE : IEnumerable<FbArm>
{
@ -465,6 +492,13 @@ namespace GameCult.Caching.Tests
[MessagePackObject]
public sealed class DkDoc2 { [Key(0)] [CultName] public string Id = string.Empty; [Key(1)] public List<DkEl2> Items = new(); }
[MessagePackFormatter(typeof(FormattedRolesFormatter))] public sealed class FormattedRoles : List<FbArm> { }
public sealed class FormattedRolesFormatter : IMessagePackFormatter<FormattedRoles>
{
public void Serialize(ref MessagePackWriter writer, FormattedRoles value, MessagePackSerializerOptions options) => writer.WriteNil();
public FormattedRoles Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) { reader.Skip(); return new FormattedRoles(); }
}
public sealed class DkFormatter : IMessagePackFormatter<DkEl>
{
public void Serialize(ref MessagePackWriter writer, DkEl value, MessagePackSerializerOptions options) => writer.Write(value.N);