CultLib/tests/GameCult.Caching.Tests/CultDocumentSelectionSurfaceTests.cs
Meta Vens f01c0b6112 R-AL/R-AM/R-AO: C# regression tests for the fix batch
- R-AL: DataMemberNameCollidingWithAReferenceMembersAliasIsRefusedAtRegistration
  pins D10b's registration refusal. ReferenceMembersExcludesADataMember...
  pins the restored evaluator guard directly (hand-built descriptor via
  reflection, since D10b now closes the registry door this shape used to
  slip through).
- R-AM: CultNetDatabaseServer_SnapshotV1Handler_CatchesAnUntypedFault... and
  RudpCultNetSchemaServer_DispatchBackstop_LogsEveryUntypedHandlerFault pin
  the handler's catch and the backstop's logging, each against a real
  untyped fault.
- R-AO: Evaluator_CitedWithTwoInTargetCandidates_ResolvesToWhicheverThe-
  CallerListedFirst pins "first row in the caller's order wins" when two
  in-target rows share a key, in both row orders.

Every test in this commit was checked against a hand-reverted mutation of
its own production fix and confirmed to fail before being confirmed to pass
against the real fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 07:17:21 +02:00

381 lines
19 KiB
C#

#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Reflection.Emit;
using GameCult.Caching;
using MessagePack;
using NUnit.Framework;
namespace GameCult.Caching.Tests
{
// CultNet typed selection, Cut 1, commit 0: the cache's public read surface over declared members
// (docs/cultnet-selection-cut.md, section 6). These tests are independent of GameCult.Networking -
// the evaluator that will consume this surface does not exist yet in this commit.
public sealed class CultDocumentSelectionSurfaceTests
{
// S19: a dictionary reference is enumerated as edges carrying its values, not merely its keys.
[Test]
public void ReferencesOfEnumeratesADictionaryReferenceAsEdgesCarryingItsValues()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafA), typeof(FixtureLeafB), typeof(FixtureCiter) });
var citer = registry.GetRequired<FixtureCiter>();
var a1 = new CultRecordRef<FixtureLeafA>(new CultRecordKey("leaf-a-1"));
var a2 = new CultRecordRef<FixtureLeafA>(new CultRecordKey("leaf-a-2"));
var document = new FixtureCiter
{
Components = new Dictionary<CultRecordRef<FixtureLeafA>, float> { [a1] = 1.5f, [a2] = 2.5f }
};
var edges = citer.ReferencesOf(document, "Components").ToArray();
Assert.That(edges.Select(edge => edge.Target.Value).OrderBy(v => v, StringComparer.Ordinal),
Is.EqualTo(new[] { "leaf-a-1", "leaf-a-2" }));
Assert.That(edges.Single(edge => edge.Target.Value == "leaf-a-1").Payload, Is.EqualTo(1.5f));
Assert.That(edges.Single(edge => edge.Target.Value == "leaf-a-2").Payload, Is.EqualTo(2.5f));
// Two entries with different payload bytes: a shape-only assertion (keys enumerated, values
// dropped) would pass without this distinguishing the payloads (spec section 10, S19).
Assert.That(edges.Select(edge => edge.Payload).Distinct().Count(), Is.EqualTo(2));
}
[Test]
public void ReferencesOfEnumeratesAManyListReferenceWithNoPayload()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafA), typeof(FixtureLeafB), typeof(FixtureCiter) });
var citer = registry.GetRequired<FixtureCiter>();
var document = new FixtureCiter
{
Related = new List<CultRecordRef<FixtureLeafA>>
{
new(new CultRecordKey("leaf-a-3")),
new(new CultRecordKey("leaf-a-4"))
}
};
var edges = citer.ReferencesOf(document, "Related").ToArray();
Assert.That(edges.Select(edge => edge.Target.Value), Is.EqualTo(new[] { "leaf-a-3", "leaf-a-4" }));
Assert.That(edges.All(edge => edge.Payload == null), Is.True);
}
[Test]
public void ReferencesOfEnumeratesASingleReferenceToAnAbstractTarget()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafA), typeof(FixtureLeafB), typeof(FixtureCiter) });
var citer = registry.GetRequired<FixtureCiter>();
var document = new FixtureCiter { Design = new CultRecordRef<FixtureMiddle>(new CultRecordKey("leaf-b-1")) };
var edges = citer.ReferencesOf(document, "Design").ToArray();
Assert.That(edges.Single().Target.Value, Is.EqualTo("leaf-b-1"));
Assert.That(edges.Single().Payload, Is.Null);
}
// D9: the abstract middle carries no [CultDocument] and no catalog entry (TargetSchemaName is
// null for exactly this reference), so the leaf set must be resolved by assignability against
// the live registry, not read off a persisted name.
[Test]
public void ResolveTargetLeavesFindsBothLeavesUnderAnAbstractMiddleWithNoSchemaOfItsOwn()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafA), typeof(FixtureLeafB), typeof(FixtureCiter) });
var citer = registry.GetRequired<FixtureCiter>();
var designMember = citer.DeclaredMembers.Single(member => member.MemberName == nameof(FixtureCiter.Design));
Assert.That(designMember.TargetType, Is.EqualTo(typeof(FixtureMiddle)));
Assert.That(typeof(FixtureMiddle).GetCustomAttribute<CultDocumentAttribute>(), Is.Null);
var leaves = registry.ResolveTargetLeaves(designMember.TargetType!);
Assert.That(leaves.Select(d => d.DocumentType).OrderBy(t => t.Name),
Is.EqualTo(new[] { typeof(FixtureLeafA), typeof(FixtureLeafB) }.OrderBy(t => t.Name)));
}
[Test]
public void ResolveTargetLeavesSeesALeafRegisteredAfterAnEarlierDescriptorWasBuilt()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureCiter), typeof(FixtureLeafA) });
var citer = registry.GetRequired<FixtureCiter>();
var designMember = citer.DeclaredMembers.Single(member => member.MemberName == nameof(FixtureCiter.Design));
var before = registry.ResolveTargetLeaves(designMember.TargetType!);
Assert.That(before.Select(d => d.DocumentType), Does.Not.Contain(typeof(FixtureLeafB)));
registry.GetRequired<FixtureLeafB>();
var after = registry.ResolveTargetLeaves(designMember.TargetType!);
Assert.That(after.Select(d => d.DocumentType), Does.Contain(typeof(FixtureLeafB)));
}
// D8's inherited-alias case, read through the public surface: mass is declared once on the
// abstract middle and reachable on both leaves.
[Test]
public void TryGetIndexValueAndNumberReadAnInheritedAliasOnEachLeaf()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafA), typeof(FixtureLeafB) });
var leafA = registry.GetRequired<FixtureLeafA>();
var documentA = new FixtureLeafA { Name = "sword", Mass = 3.5f };
Assert.That(leafA.TryGetIndexValue(documentA, "mass", out var text), Is.True);
Assert.That(text, Is.EqualTo("3.5"));
Assert.That(leafA.TryGetIndexNumber(documentA, "mass", out var number), Is.True);
Assert.That(number, Is.EqualTo("3.5"));
}
[Test]
public void TryGetIndexValueRefusesAnUndeclaredAlias()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafA) });
var leafA = registry.GetRequired<FixtureLeafA>();
Assert.That(leafA.TryGetIndexValue(new FixtureLeafA(), "not_declared", out _), Is.False);
}
// S22: IsNumeric is derived from the CLR set (sbyte..decimal, Nullable<T> unwrapped) and must
// agree for a nullable numeric member and a decimal member - the two authorities D8 names.
[Test]
public void IsNumericAgreesForANullableNumericMemberAndADecimalMember()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafB) });
var leafB = registry.GetRequired<FixtureLeafB>();
var weight = leafB.DeclaredMembers.Single(member => member.MemberName == nameof(FixtureLeafB.Weight));
Assert.That(weight.IsNumeric, Is.True);
var document = new FixtureLeafB { Weight = 4.25m };
Assert.That(leafB.TryGetIndexNumber(document, "weight", out var number), Is.True);
Assert.That(number, Is.EqualTo("4.25"));
}
[Test]
public void IsNumericIsFalseForAStringAlias()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafA) });
var leafA = registry.GetRequired<FixtureLeafA>();
var name = leafA.DeclaredMembers.Single(member => member.MemberName == nameof(FixtureLeafA.Name));
Assert.That(name.IsNumeric, Is.False);
}
// Q-J (docs/cultnet-selection-cut.md, section 2 "Numbers", 2026-09-22): the row side renders a
// member's exact canonical decimal - never a double. A long past 2^53 (where double loses
// integer precision) is the named boundary: rendering it through float64 would silently produce
// "9007199254740992" for 9007199254740993, and the two would then compare equal on the wire.
[Test]
public void TryGetIndexNumberRendersALongPast2Pow53ExactlyNotThroughFloat64()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafNumbers) });
var leaf = registry.GetRequired<FixtureLeafNumbers>();
var document = new FixtureLeafNumbers { BigInteger = 9007199254740993L };
Assert.That(leaf.TryGetIndexNumber(document, "big_integer", out var number), Is.True);
Assert.That(number, Is.EqualTo("9007199254740993"));
}
[Test]
public void TryGetIndexNumberStripsTrailingZerosFromADecimal()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafNumbers) });
var leaf = registry.GetRequired<FixtureLeafNumbers>();
var document = new FixtureLeafNumbers { TrimmedDecimal = 13.500m };
Assert.That(leaf.TryGetIndexNumber(document, "trimmed_decimal", out var number), Is.True);
Assert.That(number, Is.EqualTo("13.5"));
}
// The shortest round-trip form .NET renders for a double/float outside a middle magnitude range
// uses exponent notation ("1E+21", "1E-07"); the canonical wire form never does (Q-J).
[Test]
public void TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafNumbers) });
var leaf = registry.GetRequired<FixtureLeafNumbers>();
var wide = new FixtureLeafNumbers { WideDouble = 1e21 };
var tiny = new FixtureLeafNumbers { TinyDouble = 1e-7 };
Assert.That(leaf.TryGetIndexNumber(wide, "wide_double", out var wideNumber), Is.True);
Assert.That(wideNumber, Does.Not.Contain("E"));
Assert.That(wideNumber, Is.EqualTo("1000000000000000000000"));
Assert.That(leaf.TryGetIndexNumber(tiny, "tiny_double", out var tinyNumber), Is.True);
Assert.That(tinyNumber, Does.Not.Contain("E"));
// R-D (docs/cultnet-selection-cut.md): the exact decimal expansion, not a shortest
// round-trip form. 1e-7 is not exactly representable as a double; its nearest double's
// exact value is this - the old assertion pinned "0.0000001", the shortest form a
// pre-R-D render would have produced instead.
Assert.That(tinyNumber, Is.EqualTo("0.0000000999999999999999954748111825886258685613938723690807819366455078125"));
}
[Test]
public void TryGetIndexNumberReturnsFalseForNaNAndInfinity()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafNumbers) });
var leaf = registry.GetRequired<FixtureLeafNumbers>();
var nan = new FixtureLeafNumbers { WideDouble = double.NaN };
var infinite = new FixtureLeafNumbers { WideDouble = double.PositiveInfinity };
Assert.That(leaf.TryGetIndexNumber(nan, "wide_double", out _), Is.False);
Assert.That(leaf.TryGetIndexNumber(infinite, "wide_double", out _), Is.False);
}
// D10 and D11 registration refusals: emitted into a dynamic assembly, invisible to registry
// discovery by attribute scan, exactly as DocumentShapeTests does for every other shape refusal.
private static readonly ModuleBuilder Rejected = AssemblyBuilder
.DefineDynamicAssembly(new AssemblyName("RejectedSelectionShapes"), AssemblyBuilderAccess.Run)
.DefineDynamicModule("RejectedSelectionShapes");
[Test]
public void DuplicateIndexAliasOnTwoDifferentlyNamedMembersIsRefusedByName()
{
var type = Document("DupAlias");
Field(type, "First", typeof(string), Key(0), IndexAttr("shared"));
Field(type, "Second", typeof(string), Key(1), IndexAttr("shared"));
var message = Assert.Throws<InvalidOperationException>(() => CultDocumentRegistry.ForTypes(new[] { type.CreateType()! }))!.Message;
Assert.That(message, Does.Contain("DupAlias"));
Assert.That(message, Does.Contain("First"));
Assert.That(message, Does.Contain("Second"));
Assert.That(message, Does.Contain("shared"));
}
// R-AL/SM-6: a plain data member's bare name (it declares no [CultIndex], so its role in
// ReferencesOf/ReferenceMembers falls back to its own MemberName) colliding with a reference
// member's explicit alias. D10 above only groups members that declare an alias and misses
// this; before the fix this registered without error and then every hop through the
// reference member threw ArgumentNullException at resolution time (probed by Soul).
[Test]
public void DataMemberNameCollidingWithAReferenceMembersAliasIsRefusedAtRegistration()
{
var type = Document("OwnerCollision");
Field(type, "Owner", typeof(string), Key(0));
Field(type, "Ref", typeof(CultRecordRef<FixtureLeafA>), Key(1), IndexAttr("Owner"));
var message = Assert.Throws<InvalidOperationException>(() => CultDocumentRegistry.ForTypes(new[] { type.CreateType()! }))!.Message;
Assert.That(message, Does.Contain("OwnerCollision"));
Assert.That(message, Does.Contain("Owner"));
Assert.That(message, Does.Contain("Ref"));
}
[Test]
public void ManyReferenceOfAnElementTypeThatIsNotACultRecordRefIsRefused()
{
var type = Document("BadMany");
Field(type, "Values", typeof(List<int>), Key(0), ReferenceAttr(null, many: true));
var message = Assert.Throws<InvalidOperationException>(() => CultDocumentRegistry.ForTypes(new[] { type.CreateType()! }))!.Message;
Assert.That(message, Does.Contain("BadMany"));
Assert.That(message, Does.Contain("Values"));
}
[Test]
public void SingleReferenceWhoseMemberTypeIsNotACultRecordRefIsRefused()
{
var type = Document("BadSingle");
Field(type, "Target", typeof(int), Key(0), ReferenceAttr(typeof(FixtureLeafA), many: false));
var message = Assert.Throws<InvalidOperationException>(() => CultDocumentRegistry.ForTypes(new[] { type.CreateType()! }))!.Message;
Assert.That(message, Does.Contain("BadSingle"));
Assert.That(message, Does.Contain("Target"));
}
private static TypeBuilder Document(string name)
{
var type = Rejected.DefineType(name, TypeAttributes.Public);
type.SetCustomAttribute(new CustomAttributeBuilder(
typeof(CultDocumentAttribute).GetConstructor(new[] { typeof(string), typeof(string) })!,
new object[] { "selection.reject." + name, "selection.reject." + name + ".v1" }));
type.SetCustomAttribute(new CustomAttributeBuilder(typeof(MessagePackObjectAttribute).GetConstructor(new[] { typeof(bool) })!, new object[] { false }));
return type;
}
private static void Field(TypeBuilder type, string name, Type fieldType, params CustomAttributeBuilder[] attributes)
{
var field = type.DefineField(name, fieldType, FieldAttributes.Public);
foreach (var attribute in attributes) field.SetCustomAttribute(attribute);
}
private static CustomAttributeBuilder Key(int slot) =>
new(typeof(KeyAttribute).GetConstructor(new[] { typeof(int) })!, new object[] { slot });
private static CustomAttributeBuilder IndexAttr(string alias) =>
new(typeof(CultIndexAttribute).GetConstructor(new[] { typeof(string) })!, new object?[] { alias });
private static CustomAttributeBuilder ReferenceAttr(Type? targetType, bool many) =>
new(typeof(CultReferenceAttribute).GetConstructor(new[] { typeof(Type), typeof(bool) })!, new object?[] { targetType, many });
public abstract class FixtureMiddle
{
[Key(0)]
[CultName]
public string Name = string.Empty;
[Key(1)]
[CultIndex("mass")]
public float Mass;
}
[CultDocument("selection.fixture.leaf_a", "selection.fixture.leaf_a.v1")]
[MessagePackObject(AllowPrivate = true)]
public sealed class FixtureLeafA : FixtureMiddle
{
[Key(2)]
public int Extra;
}
[CultDocument("selection.fixture.leaf_b", "selection.fixture.leaf_b.v1")]
[MessagePackObject(AllowPrivate = true)]
public sealed class FixtureLeafB : FixtureMiddle
{
[Key(2)]
[CultIndex("weight")]
public decimal? Weight;
}
// Q-J: a fixture dedicated to the row-side canonical rendering rules, independent of the
// abstract-middle/inheritance fixtures above so it can carry a long, a decimal and two doubles
// without disturbing the schemas other tests in this file pin.
[CultDocument("selection.fixture.leaf_numbers", "selection.fixture.leaf_numbers.v1")]
[MessagePackObject(AllowPrivate = true)]
public sealed class FixtureLeafNumbers
{
[Key(0)]
[CultName]
public string Name = string.Empty;
[Key(1)]
[CultIndex("big_integer")]
public long BigInteger;
[Key(2)]
[CultIndex("trimmed_decimal")]
public decimal TrimmedDecimal;
[Key(3)]
[CultIndex("wide_double")]
public double WideDouble;
[Key(4)]
[CultIndex("tiny_double")]
public double TinyDouble;
}
[CultDocument("selection.fixture.citer", "selection.fixture.citer.v1")]
[MessagePackObject(AllowPrivate = true)]
public sealed class FixtureCiter
{
[Key(0)]
[CultName]
public string Name = string.Empty;
[Key(1)]
[CultReference(typeof(FixtureMiddle))]
public CultRecordRef<FixtureMiddle> Design;
[Key(2)]
[CultReference(typeof(FixtureLeafA), many: true)]
public Dictionary<CultRecordRef<FixtureLeafA>, float> Components = new();
[Key(3)]
[CultReference(typeof(FixtureLeafA), many: true)]
public List<CultRecordRef<FixtureLeafA>> Related = new();
}
}
}