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>
559 lines
29 KiB
C#
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>>();
|
|
}
|
|
}
|
|
}
|