CultLib/tests/GameCult.Networking.Tests/CultNetSelectionEvaluatorTests.cs
Meta Vens a4cf54f4a9 Land fix batch 3's R-O and R-Q: keyed length-prefixed cursors, replica-apply sequence gap
R-O: CultNetSelectionCursorKey is a per-process (per-CultNetDatabase-instance) 32-byte
random key. CultNetSelectionCursor.ComputeDigest is now an HMAC-SHA256 under that key
instead of an unkeyed SHA-256, so a cursor cannot be forged without the key and does not
verify across a restart (a fresh CultNetDatabase mints a fresh key). The cursor body
(asOf/ordinal/schemaId/recordKey/digest) is length-prefixed (<byte length>:<value>, no
delimiter) instead of joined with a bare "\x01"-separated string, so a record key
carrying any character, U+241F included, round-trips. CultNetSelectionEvaluator.Select/
Page/ComputeDigest take an optional CultNetSelectionCursorKey, defaulting to a
process-wide key generated once so existing callers that never wire up a database still
get a keyed digest. CultNetDatabase gets a CursorKey property (random unless
CultNetDatabaseOptions.CursorKey is supplied) and passes it through
CultNetDatabaseServer.CreateSelectionResponse.

Rust already landed R-O (5aac120) under its own algorithm, deliberately not byte-matching
the reference: its own doc comment says a cursor is never handed across runtimes, so only
collision-freedom and keying are load-bearing, not the wire bytes. That is a real
divergence from this pass's instruction to match Rust's wire encoding exactly - reported
in full in the batch report.

R-Q: the two replica-apply paths (ApplyCommittedPutAsync, ApplyCommittedDelete) recorded
the mutation-log entry but never set _lastWriteSequence, so a row's order/cursor position
looked like "never written" until it was written again locally. Both now set it from the
entry's own sequence, matching AppendMutationLogEntry's local-commit path.
InitializeLogSequencesFromStore rebuilt only _nextLogSequences at startup; it now also
rebuilds _lastWriteSequence by replaying the durable log's Put/Delete entries in sequence
order. CultNetDatabase.ResolveShard is a new public wrapper over the existing shard
resolution; CultNetDocumentRegistry.SelectPage/CreateSelectionResponse take an optional
shardIdOf callback and refuse (reusing CultNetSelectionInvalidException/selection_invalid,
R-N) when a selection's matched rows resolve to more than one shard's log, because asOf is
a single watermark and is not exact for a shard-spanning page.
CultNetDatabaseServer.CreateSelectionResponse wires shardIdOf/cursorKey through from the
database.

Also fixes a defect the Rust Hands found while matching wire bytes:
CultNetErrorMessage.ForReferenceOutsideTarget double-prefixed "reference_outside_target: "
- the exception's own message already carries it. Now prefixed once, matching Rust's
ForReferenceOutsideTarget.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:04:22 +02:00

559 lines
29 KiB
C#

#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using GameCult.Caching;
using MessagePack;
using NUnit.Framework;
namespace GameCult.Networking.Tests
{
// CultNet typed selection, Cut 1, commit 1 (docs/cultnet-selection-cut.md): the shape, its
// validation door and the one evaluator. Independent of any live server/client transport - these
// exercise CultNetSelection/CultNetSelectionEvaluator directly against a registry and a hand-built
// row set, matching section 10's S1-S23 naming.
public sealed class CultNetSelectionEvaluatorTests
{
private static CultDocumentRegistry Registry() => CultDocumentRegistry.ForTypes(new[]
{
typeof(SelLeafA), typeof(SelLeafB), typeof(SelCiter)
});
private static CultNetSelectionEvaluator.Row Row(CultDocumentRegistry registry, object document, string key, long ordinal) =>
new(registry.GetRequired(document.GetType()), new CultRecordKey(key), document, ordinal);
// S1: an index no reachable schema declares, an empty values list, and an empty schemas/keys
// list are refused typed at the door, never answered as an empty page.
[Test]
public void ValidationRefusesUndeclaredIndexRoleAndEmptyLists()
{
var descriptors = Registry().AllDescriptors.ToArray();
AssertInvalid(new CultNetSelection { Schemas = Array.Empty<string>() }, descriptors, "schemas");
AssertInvalid(new CultNetSelection { Keys = Array.Empty<string>() }, descriptors, "keys");
AssertInvalid(
new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "not_declared", Op = "any_of", Values = new[] { "x" } } } },
descriptors,
"fields[0].index");
AssertInvalid(
new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = "any_of", Values = Array.Empty<string>() } } },
descriptors,
"fields[0].values");
AssertInvalid(
new CultNetSelection { Cited = new CultNetIncoming { Role = "no_such_role", Exists = true } },
descriptors,
"cited.role");
}
private static void AssertInvalid(CultNetSelection selection, IReadOnlyList<CultDocumentDescriptor> descriptors, string expectedField)
{
var ex = Assert.Throws<CultNetSelectionInvalidException>(() => selection.Validate(descriptors));
Assert.That(ex!.Field, Is.EqualTo(expectedField));
}
// Q-J (docs/cultnet-selection-cut.md, section 2 "Numbers", 2026-09-22): the door refuses every
// non-canonical spelling of a comparison number, rather than normalising it - a leading '+', a
// trailing fractional zero, a leading integer zero, exponent notation, and negative zero.
[TestCase("+1")]
[TestCase("1.0")]
[TestCase("01")]
[TestCase("1e3")]
[TestCase("-0")]
public void ValidationRefusesNonCanonicalNumberSpellings(string spelling)
{
var descriptors = Registry().AllDescriptors.ToArray();
AssertInvalid(
new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = "gt", Number = spelling } } },
descriptors,
"fields[0].number");
}
// S2: fields conjoin; any_of matches on membership.
[Test]
public void EvaluatorConjoinsAnyOfPredicatesOverDeclaredIndexes()
{
var registry = Registry();
var a = new SelLeafA { Name = "sword", Kind = "weapon", Mass = 3 };
var b = new SelLeafA { Name = "shield", Kind = "armor", Mass = 3 };
var rows = new[] { Row(registry, a, "a", 1), Row(registry, b, "b", 2) };
var selection = new CultNetSelection
{
Fields = new[]
{
new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "weapon" } },
new CultNetFieldPredicate { Index = "mass", Op = "ge", Number = "3" }
}
};
var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1);
Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "a" }));
}
// S3: order is (ordinal, schemaId, recordKey) ascending, reversed under descending.
[Test]
public void EvaluatorOrdersByOrdinalThenIdentityAndReverses()
{
var registry = Registry();
var rows = new[]
{
Row(registry, new SelLeafA { Name = "c", Kind = "k", Mass = 1 }, "c", 3),
Row(registry, new SelLeafA { Name = "a", Kind = "k", Mass = 1 }, "a", 1),
Row(registry, new SelLeafA { Name = "b", Kind = "k", Mass = 1 }, "b", 2)
};
var ascending = CultNetSelectionEvaluator.Select(registry, rows, new CultNetSelection(), asOf: 1);
Assert.That(ascending.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "a", "b", "c" }));
var descending = CultNetSelectionEvaluator.Select(registry, rows, new CultNetSelection { Descending = true }, asOf: 1);
Assert.That(descending.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "c", "b", "a" }));
}
// S4: a page walk visits every row exactly once and the last page carries no cursor.
[Test]
public void EvaluatorPagesExactlyOnceAndTheLastPageSaysSo()
{
var registry = Registry();
var rows = Enumerable.Range(0, 5)
.Select(i => Row(registry, new SelLeafA { Name = $"n{i}", Kind = "k", Mass = i }, $"k{i}", i))
.ToArray();
var selection = new CultNetSelection { Limit = 2 };
var seen = new 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[] { "k0", "k1", "k2", "k3", "k4" }));
}
// S5: a cursor answers only the asOf and the selection it was minted for.
[Test]
public void EvaluatorRefusesAStaleCursorAndAMismatchedSelection()
{
var registry = Registry();
var rows = new[]
{
Row(registry, new SelLeafA { Name = "a", Kind = "k", Mass = 1 }, "a", 1),
Row(registry, new SelLeafA { Name = "b", Kind = "k", Mass = 1 }, "b", 2)
};
var selection = new CultNetSelection { Limit = 1 };
var first = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1);
Assert.That(first.NextCursor, Is.Not.Null);
selection.Cursor = first.NextCursor;
Assert.That(
() => CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 2),
Throws.TypeOf<CultNetSelectionCursorException>()
.With.Property(nameof(CultNetSelectionCursorException.Code)).EqualTo("cursor_stale"));
var otherSelection = new CultNetSelection { Limit = 1, Cursor = first.NextCursor, Descending = true };
Assert.That(
() => CultNetSelectionEvaluator.Select(registry, rows, otherSelection, asOf: 1),
Throws.TypeOf<CultNetSelectionCursorException>()
.With.Property(nameof(CultNetSelectionCursorException.Code)).EqualTo("cursor_invalid"));
}
// R-N (docs/cultnet-selection-cut.md, Self's rulings for fix batch 3): every refusal maps to a
// wire code, and selection_invalid/cursor_stale carry structured details, on cultnet.error.v0.
// Each test here decodes the bytes a peer actually receives (CultNetSchemaMessageSerialization
// round trip), not the in-process exception or message object.
[Test]
public void ErrorMessage_WireDecodesSelectionInvalidCodeAndDetails()
{
var registry = Registry();
var ex = Assert.Throws<CultNetSelectionInvalidException>(
() => new CultNetSelection { Schemas = Array.Empty<string>() }.Validate(registry.AllDescriptors.ToArray()))!;
var payload = CultNetSchemaMessageSerialization.Serialize(CultNetErrorMessage.ForSelectionInvalid(ex));
var decoded = (CultNetErrorMessage)CultNetSchemaMessageSerialization.Deserialize(payload);
Assert.That(decoded.Code, Is.EqualTo("selection_invalid"));
Assert.That(decoded.Details, Is.Not.Null);
Assert.That(decoded.Details!.Field, Is.EqualTo("schemas"));
Assert.That(decoded.Details!.Value, Is.Null);
}
[Test]
public void ErrorMessage_WireDecodesCursorStaleCodeAndDetails()
{
var registry = Registry();
var rows = new[]
{
Row(registry, new SelLeafA { Name = "a", Kind = "k", Mass = 1 }, "a", 1),
Row(registry, new SelLeafA { Name = "b", Kind = "k", Mass = 1 }, "b", 2)
};
var selection = new CultNetSelection { Limit = 1 };
var first = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1);
selection.Cursor = first.NextCursor;
var ex = Assert.Throws<CultNetSelectionCursorException>(
() => CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 2))!;
var payload = CultNetSchemaMessageSerialization.Serialize(CultNetErrorMessage.ForCursor(ex));
var decoded = (CultNetErrorMessage)CultNetSchemaMessageSerialization.Deserialize(payload);
Assert.That(decoded.Code, Is.EqualTo("cursor_stale"));
Assert.That(decoded.Details, Is.Not.Null);
Assert.That(decoded.Details!.AsOf, Is.EqualTo(1ul));
Assert.That(decoded.Details!.Current, Is.EqualTo(2ul));
}
[Test]
public void ErrorMessage_WireDecodesCursorInvalidCodeWithNoDetails()
{
var registry = Registry();
var rows = new[]
{
Row(registry, new SelLeafA { Name = "a", Kind = "k", Mass = 1 }, "a", 1),
Row(registry, new SelLeafA { Name = "b", Kind = "k", Mass = 1 }, "b", 2)
};
var selection = new CultNetSelection { Limit = 1 };
var first = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1);
var mismatched = new CultNetSelection { Limit = 1, Cursor = first.NextCursor, Descending = true };
var ex = Assert.Throws<CultNetSelectionCursorException>(
() => CultNetSelectionEvaluator.Select(registry, rows, mismatched, asOf: 1))!;
var payload = CultNetSchemaMessageSerialization.Serialize(CultNetErrorMessage.ForCursor(ex));
var decoded = (CultNetErrorMessage)CultNetSchemaMessageSerialization.Deserialize(payload);
Assert.That(decoded.Code, Is.EqualTo("cursor_invalid"));
Assert.That(decoded.Details, Is.Null);
}
[Test]
public void ErrorMessage_WireDecodesReferenceOutsideTargetCodeWithNoDetails()
{
var leafB = new SelLeafB { Name = "b", Kind = "k", Mass = 1 };
var offTargetCiter = new SelCiterNarrow { Name = "bad", NarrowRef = new CultRecordRef<SelLeafA>(new CultRecordKey("b")) };
var narrowRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(SelLeafA), typeof(SelLeafB), typeof(SelCiterNarrow) });
var rows = new[]
{
new CultNetSelectionEvaluator.Row(narrowRegistry.GetRequired<SelLeafB>(), new CultRecordKey("b"), leafB, 1),
new CultNetSelectionEvaluator.Row(narrowRegistry.GetRequired<SelCiterNarrow>(), new CultRecordKey("bad"), offTargetCiter, 2)
};
var selection = new CultNetSelection { Cited = new CultNetIncoming { Role = "NarrowRef", Exists = true } };
var ex = Assert.Throws<CultNetSelectionReferenceOutsideTargetException>(
() => CultNetSelectionEvaluator.Select(narrowRegistry, rows, selection, asOf: 1))!;
var payload = CultNetSchemaMessageSerialization.Serialize(CultNetErrorMessage.ForReferenceOutsideTarget(ex));
var decoded = (CultNetErrorMessage)CultNetSchemaMessageSerialization.Deserialize(payload);
Assert.That(decoded.Code, Is.EqualTo("reference_outside_target"));
Assert.That(decoded.Details, Is.Null);
// ForReferenceOutsideTarget used to prefix "reference_outside_target: " a second time on
// top of the exception's own message, which already carries it - a peer received the code
// name twice. One prefix only, on the wire bytes a peer actually decodes.
Assert.That(decoded.Error, Does.StartWith("reference_outside_target: "));
Assert.That(decoded.Error, Does.Not.Contain("reference_outside_target: reference_outside_target:"));
}
// S6: the hop follows one declared reference by role; a second role is not followed.
[Test]
public void EvaluatorHopsOneEdgeByDeclaredReferenceAndRole()
{
var registry = Registry();
var target = new SelLeafA { Name = "target", Kind = "k", Mass = 1 };
var citer = new SelCiter { Name = "citer", Design = new CultRecordRef<SelFixtureMiddle>(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<SelLeafA>().SchemaId, RecordKey = "target" }, Role = "Design" }
};
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.Single().Role, Is.EqualTo("Design"));
var wrongRole = new CultNetSelection
{
Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = registry.GetRequired<SelLeafA>().SchemaId, RecordKey = "target" }, Role = "NotARole" }
};
// Validate refuses an undeclared role before evaluation ever runs (S1).
Assert.Throws<CultNetSelectionInvalidException>(() => wrongRole.Validate(registry.AllDescriptors.ToArray()));
}
// S7: cited { exists: false } is the one negation.
[Test]
public void EvaluatorNegatedIncomingEdgeIsTheOnlyNegation()
{
var registry = Registry();
var cited = new SelLeafA { Name = "cited", Kind = "k", Mass = 1 };
var uncited = new SelLeafA { Name = "uncited", Kind = "k", Mass = 1 };
var citer = new SelCiter { Name = "citer", Design = new CultRecordRef<SelFixtureMiddle>(new CultRecordKey("cited")) };
var rows = new[] { Row(registry, cited, "cited", 1), Row(registry, uncited, "uncited", 2), Row(registry, citer, "citer", 3) };
var exists = CultNetSelectionEvaluator.Select(registry, rows,
new CultNetSelection { Schemas = new[] { registry.GetRequired<SelLeafA>().SchemaId }, Cited = new CultNetIncoming { Role = "Design", Exists = true } },
asOf: 1);
Assert.That(exists.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "cited" }));
var absent = CultNetSelectionEvaluator.Select(registry, rows,
new CultNetSelection { Schemas = new[] { registry.GetRequired<SelLeafA>().SchemaId }, Cited = new CultNetIncoming { Role = "Design", Exists = false } },
asOf: 1);
Assert.That(absent.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "uncited" }));
}
// S8: header projection carries no payload; edges under header carry no payload either (S20).
[Test]
public void HeaderProjectionCarriesNoPayload()
{
var record = new CultNetRawDocumentRecord
{
SchemaId = "s", RecordKey = "k", StoredAt = "now", PayloadEncoding = "messagepack", Payload = new byte[] { 1, 2, 3 }
};
var header = CultNetRawDocumentHeader.FromRecord(record);
Assert.That(header.SchemaId, Is.EqualTo("s"));
// CultNetRawDocumentHeader has no Payload/PayloadEncoding members at all - a compile-time
// guarantee, not merely a null check; this test exists to name the rule.
}
// S16: the four comparisons at the boundary, including equal-to-the-compared-number rows.
[Test]
public void EvaluatorComparesNumbersAtTheBoundaryForEachOfTheFourOperators()
{
var registry = Registry();
var rows = new[]
{
Row(registry, new SelLeafA { Name = "lo", Kind = "k", Mass = 4 }, "lo", 1),
Row(registry, new SelLeafA { Name = "eq", Kind = "k", Mass = 5 }, "eq", 2),
Row(registry, new SelLeafA { Name = "hi", Kind = "k", Mass = 6 }, "hi", 3)
};
string[] Matches(string op) => CultNetSelectionEvaluator.Select(
registry, rows,
new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = op, Number = "5" } } },
asOf: 1).Rows.Select(r => r.Key.Value).OrderBy(k => k, StringComparer.Ordinal).ToArray();
Assert.That(Matches("lt"), Is.EqualTo(new[] { "lo" }));
Assert.That(Matches("le"), Is.EqualTo(new[] { "eq", "lo" }));
Assert.That(Matches("ge"), Is.EqualTo(new[] { "eq", "hi" }));
Assert.That(Matches("gt"), Is.EqualTo(new[] { "hi" }));
}
// S17: an alias declared once on the abstract middle reaches every leaf that inherits it.
[Test]
public void EvaluatorAppliesAnInheritedAliasToEveryReachableLeaf()
{
var registry = Registry();
var a = new SelLeafA { Name = "a", Kind = "k", Mass = 20 };
var b = new SelLeafB { Name = "b", Kind = "k", Mass = 20 };
var rows = new[] { Row(registry, a, "a", 1), Row(registry, b, "b", 2) };
var evaluation = CultNetSelectionEvaluator.Select(
registry, rows,
new CultNetSelection
{
Schemas = new[] { registry.GetRequired<SelLeafA>().SchemaId, registry.GetRequired<SelLeafB>().SchemaId },
Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = "gt", Number = "10" } }
},
asOf: 1);
Assert.That(evaluation.Rows.Select(r => r.Key.Value).OrderBy(k => k, StringComparer.Ordinal), Is.EqualTo(new[] { "a", "b" }));
}
// S18: an edge naming a row outside the reference's declared target refuses the selection.
[Test]
public void EvaluatorRefusesAnEdgeOutsideItsDeclaredTarget()
{
var registry = Registry();
var leafB = new SelLeafB { Name = "b", Kind = "k", Mass = 1 };
// Design's declared target is SelFixtureMiddle (an abstract type with no schema of its own),
// and both leaves are within that target, so this citer is a legitimate in-target edge.
var okCiter = new SelCiter { Name = "ok", Design = new CultRecordRef<SelFixtureMiddle>(new CultRecordKey("b")) };
var refA = registry.GetRequired<SelLeafA>();
var offTargetCiter = new SelCiterNarrow { Name = "bad", NarrowRef = new CultRecordRef<SelLeafA>(new CultRecordKey("b")) };
var narrowRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(SelLeafA), typeof(SelLeafB), typeof(SelCiterNarrow) });
var rows = new[]
{
new CultNetSelectionEvaluator.Row(narrowRegistry.GetRequired<SelLeafB>(), new CultRecordKey("b"), leafB, 1),
new CultNetSelectionEvaluator.Row(narrowRegistry.GetRequired<SelCiterNarrow>(), new CultRecordKey("bad"), offTargetCiter, 2)
};
var selection = new CultNetSelection
{
Cited = new CultNetIncoming { Role = "NarrowRef", Exists = true }
};
Assert.Throws<CultNetSelectionReferenceOutsideTargetException>(
() => CultNetSelectionEvaluator.Select(narrowRegistry, rows, selection, asOf: 1));
}
// R-T(b) (docs/cultnet-selection-cut.md, Self's rulings for fix batch 3): EnsureWithinDeclaredTarget
// checks every edge against its declared target, including a many reference declared with no
// explicit [CultReference(typeof(...))] - CultDocumentRegistry.PersistedMember used to leave
// TargetType null for that shape (only the explicit-target and scalar CultRecordRef<T> cases were
// inferred), which made EnsureWithinDeclaredTarget skip the check outright for every edge through
// it. SelCiterManyUntyped's ManyRefs declares many:true with no explicit target, so its target is
// inferred from the CultRecordRef<SelLeafA> element alone; a SelLeafB target must still refuse.
[Test]
public void EvaluatorRefusesAnOutOfTargetEdgeThroughAnImplicitlyTypedManyReference()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(SelLeafA), typeof(SelLeafB), typeof(SelCiterManyUntyped) });
var leafA = new SelLeafA { Name = "a", Kind = "k", Mass = 1 };
var leafB = new SelLeafB { Name = "b", Kind = "k", Mass = 1 };
var okCiter = new SelCiterManyUntyped
{
Name = "ok",
ManyRefs = new[] { new CultRecordRef<SelLeafA>(new CultRecordKey("a")) }
};
var offTargetCiter = new SelCiterManyUntyped
{
Name = "bad",
ManyRefs = new[] { new CultRecordRef<SelLeafA>(new CultRecordKey("b")) }
};
var okRows = new[]
{
new CultNetSelectionEvaluator.Row(registry.GetRequired<SelLeafA>(), new CultRecordKey("a"), leafA, 1),
new CultNetSelectionEvaluator.Row(registry.GetRequired<SelCiterManyUntyped>(), new CultRecordKey("ok"), okCiter, 2)
};
var selection = new CultNetSelection { Cited = new CultNetIncoming { Role = "ManyRefs", Exists = true } };
var okEvaluation = CultNetSelectionEvaluator.Select(registry, okRows, selection, asOf: 1);
Assert.That(okEvaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "a" }));
var badRows = new[]
{
new CultNetSelectionEvaluator.Row(registry.GetRequired<SelLeafB>(), new CultRecordKey("b"), leafB, 1),
new CultNetSelectionEvaluator.Row(registry.GetRequired<SelCiterManyUntyped>(), new CultRecordKey("bad"), offTargetCiter, 2)
};
Assert.Throws<CultNetSelectionReferenceOutsideTargetException>(
() => CultNetSelectionEvaluator.Select(registry, badRows, selection, asOf: 1));
}
// S23: the evaluator matches every row sharing an index value, never the cache's own
// last-writer-wins unique-index map. Three rows share "shared" here.
[Test]
public void EvaluatorMatchesEveryRowSharingAnIndexValueNotAWinner()
{
var registry = Registry();
var rows = new[]
{
Row(registry, new SelLeafA { Name = "1", Kind = "shared", Mass = 1 }, "1", 1),
Row(registry, new SelLeafA { Name = "2", Kind = "shared", Mass = 1 }, "2", 2),
Row(registry, new SelLeafA { Name = "3", Kind = "shared", Mass = 1 }, "3", 3)
};
var evaluation = CultNetSelectionEvaluator.Select(
registry, rows,
new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "shared" } } } },
asOf: 1);
Assert.That(evaluation.Rows.Select(r => r.Key.Value).OrderBy(k => k, StringComparer.Ordinal), Is.EqualTo(new[] { "1", "2", "3" }));
}
// S1-Loose (docs/cultnet-selection-cut.md, "Self's rulings for fix batch 3" -> "Surviving
// mutants"): ReachableSchemas narrows to the schemas selection.schemas actually names; a mutant
// that widens it back to every registered descriptor lets a predicate on an index only an
// EXCLUDED schema declares reach the door silently (matching nothing, never refusing), instead
// of the typed "not declared by any schema this selection can reach" refusal. "kind" is declared
// on SelLeafA/SelLeafB, not on SelCiter; Schemas here names only SelCiter.
[Test]
public void ValidationRefusesAnIndexDeclaredOnlyOnASchemaTheSelectionDoesNotReach()
{
var registry = Registry();
var descriptors = registry.AllDescriptors.ToArray();
var selection = new CultNetSelection
{
Schemas = new[] { registry.GetRequired<SelCiter>().SchemaId },
Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "x" } } }
};
var ex = Assert.Throws<CultNetSelectionInvalidException>(() => selection.Validate(descriptors));
Assert.That(ex!.Field, Is.EqualTo("fields[0].index"));
}
// S2-AllOf (fix batch 3 "Surviving mutants"): any_of matches when the row's declared value
// equals ANY one of the predicate's values, not when it equals every one of them - impossible
// for a single-valued index whenever Values carries more than one entry, so a mutant that
// requires all of them makes a real multi-value any_of predicate match nothing.
[Test]
public void EvaluatorAnyOfMatchesWhenTheRowsValueIsAnyOneOfSeveralPredicateValues()
{
var registry = Registry();
var row = Row(registry, new SelLeafA { Name = "a", Kind = "weapon", Mass = 1 }, "a", 1);
var selection = new CultNetSelection
{
Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "armor", "weapon" } } }
};
var evaluation = CultNetSelectionEvaluator.Select(registry, new[] { row }, selection, asOf: 1);
Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "a" }));
}
public abstract class SelFixtureMiddle
{
[Key(0)]
[CultName]
public string Name = string.Empty;
[Key(1)]
[CultIndex("mass")]
public float Mass;
[Key(2)]
[CultIndex("kind")]
public string Kind = string.Empty;
}
[CultDocument("cultnet.selection-tests.leaf_a", "cultnet.selection-tests.leaf_a.v1")]
[MessagePackObject(AllowPrivate = true)]
public sealed class SelLeafA : SelFixtureMiddle
{
}
[CultDocument("cultnet.selection-tests.leaf_b", "cultnet.selection-tests.leaf_b.v1")]
[MessagePackObject(AllowPrivate = true)]
public sealed class SelLeafB : SelFixtureMiddle
{
}
[CultDocument("cultnet.selection-tests.citer", "cultnet.selection-tests.citer.v1")]
[MessagePackObject(AllowPrivate = true)]
public sealed class SelCiter
{
[Key(0)]
[CultName]
public string Name = string.Empty;
[Key(1)]
[CultReference(typeof(SelFixtureMiddle))]
public CultRecordRef<SelFixtureMiddle> Design;
}
[CultDocument("cultnet.selection-tests.citer_narrow", "cultnet.selection-tests.citer_narrow.v1")]
[MessagePackObject(AllowPrivate = true)]
public sealed class SelCiterNarrow
{
[Key(0)]
[CultName]
public string Name = string.Empty;
// Declared target is the single leaf SelLeafA, not the abstract middle - a value pointing at
// a SelLeafB row is outside this reference's declared target set (S18).
[Key(1)]
[CultReference(typeof(SelLeafA))]
public CultRecordRef<SelLeafA> NarrowRef;
}
[CultDocument("cultnet.selection-tests.citer_many_untyped", "cultnet.selection-tests.citer_many_untyped.v1")]
[MessagePackObject(AllowPrivate = true)]
public sealed class SelCiterManyUntyped
{
[Key(0)]
[CultName]
public string Name = string.Empty;
// No explicit [CultReference(typeof(...))] target - the target is inferred from the
// CultRecordRef<SelLeafA> element alone (R-T(b)).
[Key(1)]
[CultReference(many: true)]
public CultRecordRef<SelLeafA>[] ManyRefs = Array.Empty<CultRecordRef<SelLeafA>>();
}
}
}