CultLib/tests/GameCult.Networking.Tests/CultNetSelectionSurvivorTests.cs
Meta Vens 63a677bea9 Correct one comment still naming the deleted To.SchemaId tiebreak
Soul found this while confirming batch 8. The Survivor-5 header in the same
file was updated when the component was deleted and this one was not, so it
described a comparator that no longer exists.

Self edited this directly rather than dispatching Hands for a single stale
sentence. Naming that because Self editing code is a boundary worth saying
out loud when it is crossed, even for a comment.

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

769 lines
45 KiB
C#

#nullable enable
using System;
using System.Linq;
using GameCult.Caching;
using MessagePack;
using NUnit.Framework;
namespace GameCult.Networking.Tests
{
// CultNet typed selection, Cut 1: behavioural tests for the Stryker survivors Soul triaged as real
// against CultNetSelection.cs/CultNetSelectionEvaluator.cs (docs/cultnet-selection-cut.md, S-5/R-AA,
// "Self's rulings for fix batch 4"). Each test targets one named survivor by the rule it pins, not
// the mutated line's spelling - see the method-level comment for the rule and where the pre-fix
// mutant differs in the answer, not in the code's shape. Fixtures are new where the survivor's own
// absence-of-fixture is the reason it survived (a mixed-numeric alias, an index alias that differs
// from its member name, two reference members that resolve to one target).
public sealed class CultNetSelectionSurvivorTests
{
private static CultNetSelectionEvaluator.Row Row(CultDocumentRegistry registry, object document, string key, long ordinal) =>
new(registry.GetRequired(document.GetType()), new CultRecordKey(key), document, ordinal);
// ---------------------------------------------------------------------------------------------
// Survivor 1: CultNetSelection.cs, ValidateField - `declaring.Any(member => !member.IsNumeric)`
// mutated to `.All(...)`. Rule (section 2, "Inheritance"): "a numeric comparison on an alias that
// is numeric on one reachable leaf and a string on another is refused at the door, because the
// row owner cannot compare a string." The mutant only refuses when EVERY reachable leaf declares
// the alias non-numeric - the exact mixed case the rule exists for was untested.
// ---------------------------------------------------------------------------------------------
[Test]
public void Validation_RefusesAComparisonOnAnAliasNumericOnOneReachableLeafAndStringOnAnother()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(SurvivorNumericLeaf), typeof(SurvivorStringLeaf) });
var descriptors = registry.AllDescriptors.ToArray();
var selection = new CultNetSelection
{
Fields = new[] { new CultNetFieldPredicate { Index = "level", Op = "gt", Number = "5" } }
};
var ex = Assert.Throws<CultNetSelectionInvalidException>(() => selection.Validate(descriptors));
Assert.That(ex!.Field, Is.EqualTo("fields[0].index"));
}
// The any_of counterpart must still pass on the same mixed pair - only comparisons need the
// alias numeric everywhere reachable.
[Test]
public void Validation_AllowsAnyOfOnAMixedNumericStringAliasAcrossReachableLeaves()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(SurvivorNumericLeaf), typeof(SurvivorStringLeaf) });
var descriptors = registry.AllDescriptors.ToArray();
var selection = new CultNetSelection
{
Fields = new[] { new CultNetFieldPredicate { Index = "level", Op = "any_of", Values = new[] { "5" } } }
};
Assert.DoesNotThrow(() => selection.Validate(descriptors));
}
[CultDocument("cultnet.selection-tests.survivor_numeric_leaf", "cultnet.selection-tests.survivor_numeric_leaf.v1")]
[MessagePackObject(AllowPrivate = true)]
public sealed class SurvivorNumericLeaf
{
[Key(0)] [CultName] public string Name = string.Empty;
[Key(1)] [CultIndex("level")] public int Level;
}
[CultDocument("cultnet.selection-tests.survivor_string_leaf", "cultnet.selection-tests.survivor_string_leaf.v1")]
[MessagePackObject(AllowPrivate = true)]
public sealed class SurvivorStringLeaf
{
[Key(0)] [CultName] public string Name = string.Empty;
[Key(1)] [CultIndex("level")] public string Level = string.Empty;
}
// ---------------------------------------------------------------------------------------------
// Survivor 2: CultNetSelection.cs, Validate - the `cites.target` blank-field door mutated `||`
// to `&&`. A half-blank target (schemaId present, recordKey blank, or the reverse) must be
// refused; the mutant only refuses when BOTH are blank.
// ---------------------------------------------------------------------------------------------
[TestCase("some-schema", "")]
[TestCase("", "some-key")]
public void Validation_RefusesACitesTargetWithOnlyOneOfSchemaIdOrRecordKeyBlank(string schemaId, string recordKey)
{
var descriptors = CultDocumentRegistry.ForTypes(new[] { typeof(SurvivorNumericLeaf) }).AllDescriptors.ToArray();
var selection = new CultNetSelection
{
Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = schemaId, RecordKey = recordKey } }
};
var ex = Assert.Throws<CultNetSelectionInvalidException>(() => selection.Validate(descriptors));
Assert.That(ex!.Field, Is.EqualTo("cites.target"));
}
// ---------------------------------------------------------------------------------------------
// Survivor 3: `member.IndexAlias ?? member.MemberName` mutated to `member.MemberName` at all
// four sites (CultNetSelectionEvaluator MatchesCitation, BuildIncomingIndex, ReferenceMembers;
// CultNetSelection.AnySchemaDeclaresRole). Rule (section 2, "The hop"): "role = the member's
// index alias or name". SurvivorAliasedCiter.Link declares alias "linkRole", which differs from
// its member name "Link" - the exact case with no prior fixture.
// ---------------------------------------------------------------------------------------------
// Door: CultNetSelection.AnySchemaDeclaresRole (line 445). Only the declared alias satisfies
// cited.role/cites.role - the bare member name does not.
[TestCase("linkRole", true)]
[TestCase("Link", false)]
public void Validation_RoleDoorAcceptsTheDeclaredIndexAliasNotTheMemberName(string role, bool shouldValidate)
{
var descriptors = CultDocumentRegistry.ForTypes(new[] { typeof(SurvivorAliasTarget), typeof(SurvivorAliasedCiter) }).AllDescriptors.ToArray();
var selection = new CultNetSelection { Cited = new CultNetIncoming { Role = role, Exists = true } };
if (shouldValidate)
Assert.DoesNotThrow(() => selection.Validate(descriptors));
else
Assert.Throws<CultNetSelectionInvalidException>(() => selection.Validate(descriptors));
}
// Evaluator, cites direction: MatchesCitation (role resolution) and ReferenceMembers (the role
// filter) both read the alias. The citation names the role by its alias; the edge reported back
// must also carry the alias, not the member name.
[Test]
public void Evaluator_CitesMatchesAndReportsTheDeclaredIndexAliasAsTheEdgeRole()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(SurvivorAliasTarget), typeof(SurvivorAliasedCiter) });
var target = new SurvivorAliasTarget { Name = "target" };
var citer = new SurvivorAliasedCiter { Name = "citer", Link = new CultRecordRef<SurvivorAliasTarget>(new CultRecordKey("target")) };
var rows = new[] { Row(registry, target, "target", 1), Row(registry, citer, "citer", 2) };
var selection = new CultNetSelection
{
Cites = new CultNetCitation
{
Target = new CultNetRecordRef { SchemaId = registry.GetRequired<SurvivorAliasTarget>().SchemaId, RecordKey = "target" },
Role = "linkRole"
}
};
var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1);
Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "citer" }));
Assert.That(evaluation.Edges.Select(e => e.Role), Is.EqualTo(new[] { "linkRole" }));
}
// Evaluator, cited direction: BuildIncomingIndex (role resolution) and ReferenceMembers again -
// the same alias, read on the incoming-edge path instead of the outgoing one.
[Test]
public void Evaluator_CitedMatchesAndReportsTheDeclaredIndexAliasAsTheEdgeRole()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(SurvivorAliasTarget), typeof(SurvivorAliasedCiter) });
var target = new SurvivorAliasTarget { Name = "target" };
var citer = new SurvivorAliasedCiter { Name = "citer", Link = new CultRecordRef<SurvivorAliasTarget>(new CultRecordKey("target")) };
var rows = new[] { Row(registry, target, "target", 1), Row(registry, citer, "citer", 2) };
var selection = new CultNetSelection { Cited = new CultNetIncoming { Role = "linkRole", Exists = true } };
var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1);
Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "target" }));
Assert.That(evaluation.Edges.Select(e => e.Role), Is.EqualTo(new[] { "linkRole" }));
}
[CultDocument("cultnet.selection-tests.survivor_alias_target", "cultnet.selection-tests.survivor_alias_target.v1")]
[MessagePackObject(AllowPrivate = true)]
public sealed class SurvivorAliasTarget
{
[Key(0)] [CultName] public string Name = string.Empty;
}
[CultDocument("cultnet.selection-tests.survivor_aliased_citer", "cultnet.selection-tests.survivor_aliased_citer.v1")]
[MessagePackObject(AllowPrivate = true)]
public sealed class SurvivorAliasedCiter
{
[Key(0)] [CultName] public string Name = string.Empty;
// The declared index alias ("linkRole") differs from the member name ("Link") - the exact
// shape section 2's role-naming rule exists for, and no prior fixture had one.
[Key(1)]
[CultIndex("linkRole")]
[CultReference(typeof(SurvivorAliasTarget))]
public CultRecordRef<SurvivorAliasTarget> Link;
}
// ---------------------------------------------------------------------------------------------
// Survivor 4: CultNetSelectionEvaluator.EvaluateAll - `ThenBy`/`ThenByDescending` on schemaId
// and recordKey mutated, on both the ascending and descending sort expressions. Rule (section 2,
// "Order"): "(ordinal, schemaId, recordKey) ascending, or the reverse under descending." Every
// prior test used distinct ordinals, so the schemaId/recordKey tiebreak comparators were never
// actually invoked by the sort (LINQ's composed comparer short-circuits once a key differs).
// Three rows share one ordinal so the sort must fall through to schemaId, then recordKey.
//
// R-AJ (Soul's SM-4): this test's earlier fixture (two SelLeafA rows "a"/"b", one SelLeafB row
// "a") passed 269/269 even under a SchemaId<->Key swap mutant - not because the test is wrong
// in principle, but because whether it discriminates the swap depends on which of SelLeafA's/
// SelLeafB's real SHA-256 schema ids happens to sort first, and on this repo's actual ids it
// didn't (probed: reverting the fix and applying the swap by hand, this test still passed).
// A loosening that cannot fail is a finding about the fixture, not license to keep it. The fix
// below assigns keys *relative to the schema ids discovered at runtime* - the low-sorting
// schema gets the high-sorting key and vice versa - so schema-first and key-first orderings
// are forced to disagree regardless of which real schema id happens to be smaller.
// ---------------------------------------------------------------------------------------------
[Test]
public void Evaluator_BreaksAnOrdinalTieBySchemaIdThenRecordKeyInBothDirections()
{
var registry = CultDocumentRegistry.ForTypes(new[]
{
typeof(CultNetSelectionEvaluatorTests.SelLeafA), typeof(CultNetSelectionEvaluatorTests.SelLeafB)
});
var schemaA = registry.GetRequired<CultNetSelectionEvaluatorTests.SelLeafA>().SchemaId;
var schemaB = registry.GetRequired<CultNetSelectionEvaluatorTests.SelLeafB>().SchemaId;
// sanity: schemaA/schemaB really are distinct, or this test would not exercise the tiebreak.
Assert.That(schemaA, Is.Not.EqualTo(schemaB));
var schemaALowersFirst = CultNetCodePointComparer.Instance.Compare(schemaA, schemaB) < 0;
var lowSchema = schemaALowersFirst ? typeof(CultNetSelectionEvaluatorTests.SelLeafA) : typeof(CultNetSelectionEvaluatorTests.SelLeafB);
// The low-sorting schema's row gets the *high*-sorting key ("z"), and the high-sorting
// schema's row gets the *low*-sorting key ("a") - anti-correlated on purpose, so
// schema-first order ([low-schema, high-schema], independent of key) and key-first order
// ([low-key, high-key] = [high-schema, low-schema]) are the exact reverse of each other no
// matter which real id sorts lower. Two rows, two different schemas, two different keys:
// the simplest shape that cannot degenerate into agreement between the two candidate rules.
var rows = lowSchema == typeof(CultNetSelectionEvaluatorTests.SelLeafA)
? new[]
{
Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "low-schema-high-key", Kind = "k", Mass = 1 }, "z", 1),
Row(registry, new CultNetSelectionEvaluatorTests.SelLeafB { Name = "high-schema-low-key", Kind = "k", Mass = 1 }, "a", 1)
}
: new[]
{
Row(registry, new CultNetSelectionEvaluatorTests.SelLeafB { Name = "low-schema-high-key", Kind = "k", Mass = 1 }, "z", 1),
Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "high-schema-low-key", Kind = "k", Mass = 1 }, "a", 1)
};
var expectedAscending = rows
.OrderBy(r => r.Descriptor.SchemaId, CultNetCodePointComparer.Instance)
.ThenBy(r => r.Key.Value, CultNetCodePointComparer.Instance)
.Select(r => (r.Descriptor.SchemaId, r.Key.Value))
.ToArray();
// The anti-correlated construction above means the schema-first answer must put the
// high-key row first (its schema sorts low) - if a key-first mutant instead put the
// low-key row first, this sanity check catches the fixture degenerating, not just the
// main assertions below.
Assert.That(expectedAscending[0].Value, Is.EqualTo("z"), "fixture sanity: schema-first must not agree with key-first here");
var ascending = CultNetSelectionEvaluator.Select(registry, rows, new CultNetSelection(), asOf: 1);
Assert.That(ascending.Rows.Select(r => (r.Descriptor.SchemaId, r.Key.Value)), Is.EqualTo(expectedAscending));
var descending = CultNetSelectionEvaluator.Select(registry, rows, new CultNetSelection { Descending = true }, asOf: 1);
Assert.That(descending.Rows.Select(r => (r.Descriptor.SchemaId, r.Key.Value)), Is.EqualTo(expectedAscending.Reverse()));
}
// ---------------------------------------------------------------------------------------------
// Survivor 5: CultNetSelectionEvaluator.EdgesFor - the ordering keys after page-row position
// (From.SchemaId, From.Key, Role, To.Key) each mutated ThenBy<->ThenByDescending. Rule (R-B):
// "page-row order, then (from, role, to.recordKey) in code-point order." Two behavioural
// angles: the From.SchemaId tiebreak (two differently-schema'd citers, same key, citing one
// target under the same role), and the Role tiebreak (one citer with two reference members
// that both resolve to the same target, so From and To tie and only Role differs).
//
// A fifth component, To.SchemaId, used to sit last in this chain. R-BA (docs/cultnet-selection
// -cut.md, "Soul, the merge gate, fourth pass") deleted it from both runtimes'
// comparators: TryResolveReferenceTarget/resolve_reference_target resolve purely from
// (the citer's schema, role, recordKey) against the evaluation's own fixed candidate index, so
// once From and Role already tie (pinning the citer's schema and the target type/leaves) and
// To.RecordKey also ties, To.SchemaId is forced equal - no input can make it decide an order
// the earlier keys did not already decide, so a Stryker mutant on it cannot be killed and the
// component bought nothing.
// ---------------------------------------------------------------------------------------------
[Test]
public void EdgesFor_BreaksATiedAnchorByFromSchemaIdWhenFromKeysAreEqual()
{
var registry = CultDocumentRegistry.ForTypes(new[]
{
typeof(CultNetSelectionEvaluatorTests.SelLeafA),
typeof(CultNetSelectionEvaluatorTests.SelCiter),
typeof(SurvivorAltCiter)
});
var target = new CultNetSelectionEvaluatorTests.SelLeafA { Name = "target", Kind = "k", Mass = 1 };
// Both citers use record key "same" - only their schemaId differs, isolating that tiebreak
// from the already-covered From.Key case (CultNetSelectionFixBatchTests, citer-a/citer-b).
var citerOriginal = new CultNetSelectionEvaluatorTests.SelCiter
{
Name = "orig",
Design = new CultRecordRef<CultNetSelectionEvaluatorTests.SelFixtureMiddle>(new CultRecordKey("target"))
};
var citerAlt = new SurvivorAltCiter { Name = "alt", Design = new CultRecordRef<CultNetSelectionEvaluatorTests.SelFixtureMiddle>(new CultRecordKey("target")) };
var rows = new[]
{
Row(registry, target, "target", 1),
Row(registry, citerOriginal, "same", 2),
Row(registry, citerAlt, "same", 3)
};
var schemaOriginal = registry.GetRequired<CultNetSelectionEvaluatorTests.SelCiter>().SchemaId;
var schemaAlt = registry.GetRequired<SurvivorAltCiter>().SchemaId;
var expectedFirst = CultNetCodePointComparer.Instance.Compare(schemaOriginal, schemaAlt) <= 0 ? schemaOriginal : schemaAlt;
var expectedSecond = expectedFirst == schemaOriginal ? schemaAlt : schemaOriginal;
var selection = new CultNetSelection
{
Schemas = new[] { registry.GetRequired<CultNetSelectionEvaluatorTests.SelLeafA>().SchemaId },
Cited = new CultNetIncoming { Role = "Design", Exists = true }
};
var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1);
Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "target" }));
Assert.That(evaluation.Edges.Select(e => e.From.Descriptor.SchemaId), Is.EqualTo(new[] { expectedFirst, expectedSecond }));
}
[Test]
public void EdgesFor_BreaksATiedAnchorByRoleWhenFromAndToAreEqual()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(SurvivorAliasTarget), typeof(SurvivorDualRoleCiter) });
var target = new SurvivorAliasTarget { Name = "target" };
// Both reference members on the one citer point at the same target row, so From and To tie
// for both edges and only Role can order them.
var citer = new SurvivorDualRoleCiter
{
Name = "citer",
RefZ = new CultRecordRef<SurvivorAliasTarget>(new CultRecordKey("target")),
RefA = new CultRecordRef<SurvivorAliasTarget>(new CultRecordKey("target"))
};
var rows = new[] { Row(registry, target, "target", 1), Row(registry, citer, "citer", 2) };
var selection = new CultNetSelection
{
Cites = new CultNetCitation
{
Target = new CultNetRecordRef { SchemaId = registry.GetRequired<SurvivorAliasTarget>().SchemaId, RecordKey = "target" }
// Role left null: matches any declared reference, so both RefZ (alias "zLastRole")
// and RefA (alias "aFirstRole") contribute an edge.
}
};
var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1);
Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "citer" }));
// "aFirstRole" sorts before "zLastRole" in code-point order - the opposite of RefZ/RefA's
// declaration order (Key(1)/Key(2)), so a correct answer cannot come from preserving
// declaration order by accident.
Assert.That(evaluation.Edges.Select(e => e.Role), Is.EqualTo(new[] { "aFirstRole", "zLastRole" }));
}
// A sixth EdgesFor mutant Stryker reported alongside the five R-B names: OrderBy(pageRowIndex)
// itself (the primary key, page-row position) mutated to OrderByDescending. Two page rows, each
// the anchor for exactly one edge from a differently-keyed citer, where the citers' own
// (from, role, to) order is the *opposite* of the pages' row order - so only the primary
// page-position key, not the secondary from/role/to tiebreak, can produce the right answer.
[Test]
public void EdgesFor_OrdersByPageRowPositionBeforeFromRoleTo()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(SurvivorAliasTarget), typeof(SurvivorPageOrderCiter) });
var targetZ = new SurvivorAliasTarget { Name = "z" };
var targetA = new SurvivorAliasTarget { Name = "a" };
var citerForZ = new SurvivorPageOrderCiter { Name = "cz", Link = new CultRecordRef<SurvivorAliasTarget>(new CultRecordKey("z-target")) };
// "citer-a" sorts before "citer-z" in code-point order - the opposite of the page order below
// (targetZ's ordinal puts it first), so a from/role/to-only sort would put this edge first.
var citerForA = new SurvivorPageOrderCiter { Name = "ca", Link = new CultRecordRef<SurvivorAliasTarget>(new CultRecordKey("a-target")) };
var rows = new[]
{
Row(registry, targetZ, "z-target", 1),
Row(registry, targetA, "a-target", 2),
Row(registry, citerForZ, "citer-z", 3),
Row(registry, citerForA, "citer-a", 4)
};
var selection = new CultNetSelection { Cited = new CultNetIncoming { Role = "hopRole", Exists = true } };
var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1);
// Page order is by ordinal: z-target (1) then a-target (2).
Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "z-target", "a-target" }));
// Edges must follow that same page order, not "citer-a" < "citer-z".
Assert.That(evaluation.Edges.Select(e => e.From.Key.Value), Is.EqualTo(new[] { "citer-z", "citer-a" }));
}
// R-AJ (Soul's SM-4): the two tests above each isolate one component of the (From.SchemaId,
// From.Key, Role, To.Key) tiebreak by tying every other one - so neither can
// distinguish the *composition* "From.Key before Role" from "Role before From.Key", which is
// exactly the mutant that survived (permuting Role ahead of From.Key, keeping every
// component). This calls EdgesFor directly with two hand-built edges sharing one anchor, so
// both From.Key and Role vary at once and the two orderings disagree: "aaa" < "zzz" as a key,
// but the row keyed "zzz" carries the role "aaa" - a Role-first mutant would put that edge
// first; the rule (R-B: "page-row order, then (from, role, to)") puts it second.
[Test]
public void EdgesFor_OrdersByFromKeyBeforeRoleWhenBothVaryAtTheSameAnchor()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(SurvivorAliasTarget) });
var anchor = Row(registry, new SurvivorAliasTarget { Name = "anchor" }, "anchor-key", 1);
var to = Row(registry, new SurvivorAliasTarget { Name = "to" }, "to-key", 2);
var fromZ = Row(registry, new SurvivorAliasTarget { Name = "z" }, "zzz", 3);
var fromA = Row(registry, new SurvivorAliasTarget { Name = "a" }, "aaa", 4);
var edgeFromZRoleA = new CultNetSelectionEvaluator.EdgeMatch(fromZ, "aaa", to, null);
var edgeFromARoleZ = new CultNetSelectionEvaluator.EdgeMatch(fromA, "zzz", to, null);
var full = new CultNetSelectionEvaluator.FullEvaluation
{
Edges = new[] { (edgeFromZRoleA, anchor), (edgeFromARoleZ, anchor) }
};
var edges = CultNetSelectionEvaluator.EdgesFor(new[] { anchor }, full);
Assert.That(edges.Select(e => e.From.Key.Value), Is.EqualTo(new[] { "aaa", "zzz" }),
"From.Key must decide before Role - a Role-first composition would put the \"zzz\"-keyed edge first, since its role \"aaa\" sorts first");
Assert.That(edges.Select(e => e.Role), Is.EqualTo(new[] { "zzz", "aaa" }));
}
[CultDocument("cultnet.selection-tests.survivor_page_order_citer", "cultnet.selection-tests.survivor_page_order_citer.v1")]
[MessagePackObject(AllowPrivate = true)]
public sealed class SurvivorPageOrderCiter
{
[Key(0)] [CultName] public string Name = string.Empty;
[Key(1)]
[CultIndex("hopRole")]
[CultReference(typeof(SurvivorAliasTarget))]
public CultRecordRef<SurvivorAliasTarget> Link;
}
[CultDocument("cultnet.selection-tests.survivor_alt_citer", "cultnet.selection-tests.survivor_alt_citer.v1")]
[MessagePackObject(AllowPrivate = true)]
public sealed class SurvivorAltCiter
{
[Key(0)] [CultName] public string Name = string.Empty;
[Key(1)]
[CultReference(typeof(CultNetSelectionEvaluatorTests.SelFixtureMiddle))]
public CultRecordRef<CultNetSelectionEvaluatorTests.SelFixtureMiddle> Design;
}
[CultDocument("cultnet.selection-tests.survivor_dual_role_citer", "cultnet.selection-tests.survivor_dual_role_citer.v1")]
[MessagePackObject(AllowPrivate = true)]
public sealed class SurvivorDualRoleCiter
{
[Key(0)] [CultName] public string Name = string.Empty;
[Key(1)]
[CultIndex("zLastRole")]
[CultReference(typeof(SurvivorAliasTarget))]
public CultRecordRef<SurvivorAliasTarget> RefZ;
[Key(2)]
[CultIndex("aFirstRole")]
[CultReference(typeof(SurvivorAliasTarget))]
public CultRecordRef<SurvivorAliasTarget> RefA;
}
// ---------------------------------------------------------------------------------------------
// Survivor 6: CultNetSelectionCursor.ComputeDigest - the `cites`/`cited` branches (and the
// field-number presence flag) had no coverage at all. Rule (R-H): "every string and list is
// length-prefixed, so no delimiter choice can make two different selections collide" - extended
// here to the branch flags themselves: two selections differing only in whether they carry a
// hop, or only in a field's number-vs-no-number flag, must not collide.
// ---------------------------------------------------------------------------------------------
[Test]
public void ComputeDigest_DiffersWhenOnlyCitesPresenceOrItsRoleDiffers()
{
var withoutCites = new CultNetSelection();
var withCitesNoRole = new CultNetSelection
{
Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = "s", RecordKey = "k" } }
};
var withCitesRole = new CultNetSelection
{
Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = "s", RecordKey = "k" }, Role = "r" }
};
Assert.That(
CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", withoutCites),
Is.Not.EqualTo(CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", withCitesNoRole)));
Assert.That(
CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", withCitesNoRole),
Is.Not.EqualTo(CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", withCitesRole)));
}
[Test]
public void ComputeDigest_DiffersWhenOnlyCitedPresenceOrItsExistsFlagDiffers()
{
var withoutCited = new CultNetSelection();
var citedExistsTrue = new CultNetSelection { Cited = new CultNetIncoming { Role = "r", Exists = true } };
var citedExistsFalse = new CultNetSelection { Cited = new CultNetIncoming { Role = "r", Exists = false } };
Assert.That(
CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", withoutCited),
Is.Not.EqualTo(CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", citedExistsTrue)));
Assert.That(
CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", citedExistsTrue),
Is.Not.EqualTo(CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", citedExistsFalse)));
}
// The number-presence flag: Number = null (an any_of predicate never carries one) must not
// digest the same as Number = "" (an empty-but-present string) - AppendString alone would render
// both as a zero-length field, so the trailing '0'/'1' flag is the only thing that tells them
// apart.
[Test]
public void ComputeDigest_DiffersBetweenAnAbsentFieldNumberAndAnEmptyOne()
{
var absentNumber = new CultNetSelection
{
Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = "any_of", Values = new[] { "x" } } }
};
var emptyNumber = new CultNetSelection
{
Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = "any_of", Values = new[] { "x" }, Number = "" } }
};
Assert.That(
CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", absentNumber),
Is.Not.EqualTo(CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", emptyNumber)));
}
// Every "differs" assertion above is symmetric to a mutant that swaps which of two cases gets
// which flag value (e.g. field.Number == null ? '0' : '1' mutated to != null) - both cases still
// produce two different digests, just with the labels swapped, so inequality alone cannot catch
// it. Nor can it catch deleting one AppendString/sb.Append call whose content happens not to be
// the only thing distinguishing a particular pair. An independently reconstructed golden value -
// built from ComputeDigest's own doc comments, not by calling its private helpers - pins the
// exact byte-for-byte wire shape: every append call and every flag's polarity must be exactly
// right for this to still match, so deleting or mislabelling any of them fails it.
[Test]
public void ComputeDigest_MatchesAnIndependentlyReconstructedGoldenValue()
{
var keyBytes = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray();
var key = new CultNetSelectionCursorKey(keyBytes);
var selection = new CultNetSelection
{
Schemas = new[] { "schema-b", "schema-a" },
Keys = new[] { "key-a" },
Fields = new[]
{
new CultNetFieldPredicate { Index = "idx-a", Op = "any_of", Values = new[] { "v2", "v1" } },
new CultNetFieldPredicate { Index = "idx-b", Op = "gt", Number = "5" }
},
Cites = new CultNetCitation
{
Target = new CultNetRecordRef { SchemaId = "cite-schema", RecordKey = "cite-key" },
Role = "cite-role"
},
Cited = new CultNetIncoming { Role = "cited-role", Exists = true },
Projection = CultNetSelectionProjections.Document,
Descending = true
};
var expected = IndependentDigest(asOf: 42, ordinal: 7, schemaId: "row-schema", recordKey: "row-key", selection, keyBytes);
var actual = CultNetSelectionCursor.ComputeDigest(42, 7, "row-schema", "row-key", selection, key);
Assert.That(actual, Is.EqualTo(expected));
}
// Independent re-implementation of ComputeDigest's wire shape (R-H/R-Y), written from the
// method's doc comments rather than by delegating to it or its private helpers.
private static string IndependentDigest(ulong asOf, long ordinal, string schemaId, string recordKey, CultNetSelection selection, byte[] keyBytes)
{
var sb = new System.Text.StringBuilder();
void AppendString(string value) => sb.Append(System.Text.Encoding.UTF8.GetByteCount(value)).Append(':').Append(value);
void AppendList(System.Collections.Generic.IReadOnlyList<string>? values)
{
var ordered = (values ?? Array.Empty<string>()).OrderBy(v => v, CultNetCodePointComparer.Instance).ToArray();
sb.Append(ordered.Length).Append(':');
foreach (var v in ordered) AppendString(v);
}
AppendString(asOf.ToString(System.Globalization.CultureInfo.InvariantCulture));
AppendString(ordinal.ToString(System.Globalization.CultureInfo.InvariantCulture));
AppendString(schemaId);
AppendString(recordKey);
AppendList(selection.Schemas);
AppendList(selection.Keys);
var fields = selection.Fields ?? Array.Empty<CultNetFieldPredicate>();
sb.Append(fields.Length).Append(':');
foreach (var field in fields)
{
AppendString(field.Index);
AppendString(field.Op);
AppendList(field.Values);
AppendString(field.Number ?? string.Empty);
sb.Append(field.Number == null ? '0' : '1');
}
if (selection.Cites != null)
{
sb.Append('1');
AppendString(selection.Cites.Target.SchemaId);
AppendString(selection.Cites.Target.RecordKey);
AppendString(selection.Cites.Role ?? string.Empty);
sb.Append(selection.Cites.Role == null ? '0' : '1');
}
else
{
sb.Append('0');
}
if (selection.Cited != null)
{
sb.Append('1');
AppendString(selection.Cited.Role);
sb.Append(selection.Cited.Exists ? '1' : '0');
}
else
{
sb.Append('0');
}
AppendString(selection.Projection);
sb.Append(selection.Descending ? '1' : '0');
using var hmac = new System.Security.Cryptography.HMACSHA256(keyBytes);
var bytes = hmac.ComputeHash(System.Text.Encoding.UTF8.GetBytes(sb.ToString()));
var hex = new System.Text.StringBuilder(bytes.Length * 2);
foreach (var b in bytes) hex.Append(b.ToString("x2", System.Globalization.CultureInfo.InvariantCulture));
return hex.ToString();
}
// ---------------------------------------------------------------------------------------------
// Survivor 7: CultNetSelectionCursor.Parse / ReadLengthPrefixedFields had no coverage at all on
// attacker-supplied base64. A length prefix claiming more bytes than the payload actually holds,
// and trailing bytes left over after the five fields, must both refuse cursor_invalid rather
// than throw an unhandled exception or silently accept the extra bytes.
// ---------------------------------------------------------------------------------------------
[Test]
public void CursorParse_RefusesALengthPrefixClaimingMoreBytesThanArePresent()
{
// "999:" claims a 999-byte field but supplies only "x" - the door must refuse, not read
// past the end of the buffer.
var hostile = Base64UrlEncode(System.Text.Encoding.UTF8.GetBytes("999:x"));
var ex = Assert.Throws<CultNetSelectionCursorException>(() => CultNetSelectionCursor.Parse(hostile));
Assert.That(ex!.Code, Is.EqualTo("cursor_invalid"));
}
[Test]
public void CursorParse_RefusesTrailingBytesAfterTheFiveFields()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(CultNetSelectionEvaluatorTests.SelLeafA) });
var rows = new[] { Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "a", Kind = "k", Mass = 1 }, "a", 1) };
var selection = new CultNetSelection { Limit = 1 };
var key = CultNetSelectionCursorKey.Random();
var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: key);
Assert.That(evaluation.NextCursor, Is.Null, "a single-row set with limit 1 has no next page to mint a cursor from");
// Mint a real cursor over a two-row set instead, so there is one to tamper with.
var rows2 = new[]
{
Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "a", Kind = "k", Mass = 1 }, "a", 1),
Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "b", Kind = "k", Mass = 1 }, "b", 2)
};
var page = CultNetSelectionEvaluator.Select(registry, rows2, selection, asOf: 1, cursorKey: key);
Assert.That(page.NextCursor, Is.Not.Null);
var raw = Base64UrlDecode(page.NextCursor!);
var withTrailingGarbage = new byte[raw.Length + 3];
Array.Copy(raw, withTrailingGarbage, raw.Length);
withTrailingGarbage[raw.Length] = (byte)'x';
withTrailingGarbage[raw.Length + 1] = (byte)'y';
withTrailingGarbage[raw.Length + 2] = (byte)'z';
var tampered = Base64UrlEncode(withTrailingGarbage);
var ex = Assert.Throws<CultNetSelectionCursorException>(() => CultNetSelectionCursor.Parse(tampered));
Assert.That(ex!.Code, Is.EqualTo("cursor_invalid"));
}
private static string Base64UrlEncode(byte[] bytes) =>
Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
private static byte[] Base64UrlDecode(string value)
{
var padded = value.Replace('-', '+').Replace('_', '/');
switch (padded.Length % 4)
{
case 2: padded += "=="; break;
case 3: padded += "="; break;
case 0: break;
default: throw new FormatException("Invalid base64url string length.");
}
return Convert.FromBase64String(padded);
}
// ---------------------------------------------------------------------------------------------
// Survivor 8: FindCursorPosition (descending walk) and ComparePosition (the schemaId-vs-key
// tiebreak) had no test exercising an equal-ordinal boundary. Two angles: a full descending page
// walk (mirroring the existing ascending-only walk test), and a cursor boundary where two rows
// share both ordinal and record key but differ only by schemaId.
// ---------------------------------------------------------------------------------------------
[Test]
public void Evaluator_PagesDescendingExactlyOnceEachRow()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(CultNetSelectionEvaluatorTests.SelLeafA) });
var rows = Enumerable.Range(0, 5)
.Select(i => Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = $"n{i}", Kind = "k", Mass = i }, $"k{i}", i))
.ToArray();
var selection = new CultNetSelection { Limit = 2, Descending = true };
var seen = new System.Collections.Generic.List<string>();
string? cursor = null;
for (var guard = 0; guard < 10; guard++)
{
selection.Cursor = cursor;
var page = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1);
seen.AddRange(page.Rows.Select(r => r.Key.Value));
if (page.NextCursor == null) break;
cursor = page.NextCursor;
}
Assert.That(seen, Is.EqualTo(new[] { "k4", "k3", "k2", "k1", "k0" }));
}
// ComparePosition's ordinal.CompareTo ties (both rows ordinal 1, both keyed "m") - only the
// schemaId comparison can tell the cursor which row it already returned.
[Test]
public void Page_ResolvesAnEqualOrdinalEqualKeyCursorBoundaryBySchemaId()
{
var registry = CultDocumentRegistry.ForTypes(new[]
{
typeof(CultNetSelectionEvaluatorTests.SelLeafA), typeof(CultNetSelectionEvaluatorTests.SelLeafB)
});
var rows = new[]
{
Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "a", Kind = "k", Mass = 1 }, "m", 1),
Row(registry, new CultNetSelectionEvaluatorTests.SelLeafB { Name = "b", Kind = "k", Mass = 1 }, "m", 1)
};
var schemaA = registry.GetRequired<CultNetSelectionEvaluatorTests.SelLeafA>().SchemaId;
var schemaB = registry.GetRequired<CultNetSelectionEvaluatorTests.SelLeafB>().SchemaId;
var expectedOrder = CultNetCodePointComparer.Instance.Compare(schemaA, schemaB) <= 0
? new[] { schemaA, schemaB }
: new[] { schemaB, schemaA };
var selection = new CultNetSelection { Limit = 1 };
var first = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1);
Assert.That(first.Rows.Select(r => r.Descriptor.SchemaId), Is.EqualTo(new[] { expectedOrder[0] }));
Assert.That(first.NextCursor, Is.Not.Null);
selection.Cursor = first.NextCursor;
var second = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1);
Assert.That(second.Rows.Select(r => r.Descriptor.SchemaId), Is.EqualTo(new[] { expectedOrder[1] }));
Assert.That(second.NextCursor, Is.Null);
}
// ---------------------------------------------------------------------------------------------
// Survivor 9: CultNetCodePointComparer's private CodePointAt - the unpaired (lone) surrogate
// branch. R-C's existing astral-key test always supplies a complete, valid surrogate pair, so
// the "not actually a pair" branch (documented on the type: "a comparer must not throw on it")
// was never reached: a high surrogate at the very end of the string (no following char at all),
// and a high surrogate followed by a non-low-surrogate character.
// ---------------------------------------------------------------------------------------------
[Test]
public void CodePointComparer_OrdersALoneHighSurrogateAtStringEndAsItsOwnCodeUnit()
{
// U+D800 (lone high surrogate) as its own value (55296) sorts before U+E000 (57344, BMP
// private-use) - the same relative order code points would give, confirming the lone
// surrogate is compared by its raw unit value rather than causing a crash or being skipped.
var withLoneSurrogateAtEnd = "x\uD800";
var bmpPrivateUse = "x";
Assert.That(CultNetCodePointComparer.Instance.Compare(withLoneSurrogateAtEnd, bmpPrivateUse), Is.LessThan(0));
Assert.That(CultNetCodePointComparer.Instance.Compare(bmpPrivateUse, withLoneSurrogateAtEnd), Is.GreaterThan(0));
}
[Test]
public void CodePointComparer_TreatsAHighSurrogateNotFollowedByALowSurrogateAsOneCodeUnit()
{
// The high surrogate is immediately followed by an ordinary BMP character, not a low
// surrogate - IsLowSurrogate must fail and the comparer must advance by one unit, not two,
// continuing on to compare "A" vs "B" rather than treating \uD800 + 'A' as a surrogate pair.
var first = "x\uD800A";
var second = "x\uD800B";
Assert.That(CultNetCodePointComparer.Instance.Compare(first, second), Is.LessThan(0));
Assert.That(CultNetCodePointComparer.Instance.Compare(second, first), Is.GreaterThan(0));
}
}
}