- Adds mutation entries for R-A, R-B, R-C, R-E, R-F, R-G, R-H, R-J (scripts/mutate-cultnet-selection-networking.entries.ps1), each a revert and a loosening where the rule supports one, targeting CultNetSelectionEvaluator.cs, CultNetSelection.cs, CultNetDatabaseServer.cs and CultNetDatabaseSubscriptionServer.cs. - Adds S9 (docs/cultnet-selection-cut.md section 10): a new RUDP round-trip test proving a hop-bearing v1 subscription reconciles the whole matched set when the citer (not the page row itself) changes, plus its own mutation entry (NET-S9-HopFastPath-Revert) reverting D6's Watch routing. - Adds tests/GameCult.Networking.Tests/CultNetSelectionFixBatchTests.cs: regression coverage for R-B (edge anchor/order), R-C (code-point order in both the row tiebreak and the cursor position compare), R-E (alias matcher; door refusal), R-F (Select validates unconditionally), R-H (digest collision), R-J (numeric any_of canonical rendering) - none of these rules had a test that could fail independently of the fix-batch regression tests already in place. - Fixes a real gap R-E named but the first fix-batch pass never wrote: CultNetSelection.Validate now refuses a cites.target.schemaId that matches no declared schema, at the door, instead of MatchesCitation silently treating an unresolved target as "not found" and answering an empty page. Resolved through CultNetSchemaAliasMatching, the reference rule, against every declared schema - not only the selection's own Schemas-filtered reachable set, which a second entry (NET-RE-DoorTargetReachableOnly-Loosening) proves: a cites target is a separate structural reference, not itself constrained by Schemas. Coordinator-assigned item; the shared parity fixture's refuses_unmatched_cites_target_schema vector (Rust side, another Hands) is not yet in selection-vectors.rs-written.json, so SelectionParityVectorTests.SelectionVectorsWrittenByRustDecodeAndEvaluateIdenticallyInTheReference does not yet exercise it - it will once that vector lands, unmodified by this commit. - NetworkingTests.cs gains CultNetDatabaseServer_AnswersV1SnapshotRequestRespectingItsFields (R-A's loosening) and DatabaseSubscriptionServer_SubscribesV1AndReconcilesAHopBearingSelectionWhenTheCiterChanges (S9 / R-A's subscribe-listener revert). Discrepancy: R-A's snapshot-request v1 listener registration (CultNetDatabaseServer's _server.OnCultNet(_snapshotHandlerV1)) has no mutation entry. CultNetDatabaseServer pairs only with Server (the LiteNetLib/TCP-secured production construction site), and no existing test in this suite - before or after this pass - drives Server's real client/listener dispatch for CultNetDatabaseServer; every test calls CreateSelectionResponse/CreateSnapshotResponse directly. That revert is recorded as not yet reached in the entries file, not swapped for an easier mutant. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
219 lines
13 KiB
C#
219 lines
13 KiB
C#
#nullable enable
|
|
using System;
|
|
using System.Linq;
|
|
using GameCult.Caching;
|
|
using NUnit.Framework;
|
|
|
|
namespace GameCult.Networking.Tests
|
|
{
|
|
// CultNet typed selection, Cut 1, second Hands pass on the fix batch (docs/cultnet-selection-cut.md,
|
|
// "Self's rulings for the Cut 1 fix batch" and "The C# half of the fix batch landed"): regression
|
|
// tests for the rules the first Hands pass fixed but left proven only by 7903853..3d32c67's own
|
|
// (unnamed, un-mutated) coverage or, for R-E's cites.target.schemaId door refusal, left entirely
|
|
// unimplemented. Independent of any live server/client transport, same style as
|
|
// CultNetSelectionEvaluatorTests - a hand-built row set against CultNetSelectionEvaluator directly.
|
|
// Reuses CultNetSelectionEvaluatorTests' public fixture types (SelLeafA/SelLeafB/SelFixtureMiddle/
|
|
// SelCiter/SelCiterNarrow) rather than declaring a fourth near-identical set.
|
|
public sealed class CultNetSelectionFixBatchTests
|
|
{
|
|
private static CultDocumentRegistry Registry() => CultDocumentRegistry.ForTypes(new[]
|
|
{
|
|
typeof(CultNetSelectionEvaluatorTests.SelLeafA),
|
|
typeof(CultNetSelectionEvaluatorTests.SelLeafB),
|
|
typeof(CultNetSelectionEvaluatorTests.SelCiter)
|
|
});
|
|
|
|
private static CultNetSelectionEvaluator.Row Row(CultDocumentRegistry registry, object document, string key, long ordinal) =>
|
|
new(registry.GetRequired(document.GetType()), new CultRecordKey(key), document, ordinal);
|
|
|
|
// R-B: edges are anchored to the row the hop direction actually reports against - the cited row
|
|
// under `cited`, not the citer - and edge order is deterministic: page-row order, then
|
|
// (from, role, to) in code-point order. Two citers citing the same target through the same role
|
|
// put two edges on one page row (`target`), so only a real (from, role, to) tie-break - not
|
|
// insertion order - can produce "citer-a" before "citer-b".
|
|
[Test]
|
|
public void Evaluator_AnchorsCitedEdgesToTheCitedRowAndOrdersThemByFromThenRole()
|
|
{
|
|
var registry = Registry();
|
|
var target = new CultNetSelectionEvaluatorTests.SelLeafA { Name = "target", Kind = "k", Mass = 1 };
|
|
var citerB = new CultNetSelectionEvaluatorTests.SelCiter { Name = "b", Design = new CultRecordRef<CultNetSelectionEvaluatorTests.SelFixtureMiddle>(new CultRecordKey("target")) };
|
|
var citerA = new CultNetSelectionEvaluatorTests.SelCiter { Name = "a", Design = new CultRecordRef<CultNetSelectionEvaluatorTests.SelFixtureMiddle>(new CultRecordKey("target")) };
|
|
// Supplied out of code-point order (b before a) on purpose - the assertion only passes if
|
|
// EdgesFor's own tie-break sorts them, not if it merely preserves row-supply order.
|
|
var rows = new[]
|
|
{
|
|
Row(registry, target, "target", 1),
|
|
Row(registry, citerB, "citer-b", 2),
|
|
Row(registry, citerA, "citer-a", 3)
|
|
};
|
|
|
|
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" }));
|
|
// Anchor: both edges must be reported (dropped entirely if wrongly anchored to the citer,
|
|
// which is not on this page under `cited`).
|
|
Assert.That(evaluation.Edges.Select(e => e.From.Key.Value), Is.EqualTo(new[] { "citer-a", "citer-b" }));
|
|
Assert.That(evaluation.Edges.All(e => e.To.Key.Value == "target"), Is.True);
|
|
}
|
|
|
|
// R-C: every ordered comparison the vocabulary makes uses Unicode code-point order, not UTF-16
|
|
// code-unit order. U+E000 (BMP private use) sorts before U+1F602 (astral, "😂") in code-point
|
|
// order despite the astral character's leading UTF-16 surrogate (0xD83D) being numerically
|
|
// smaller than 0xE000 - a code-unit ("ordinal") compare sorts it the other way.
|
|
private const string BmpKey = "-row";
|
|
private const string AstralKey = "😂-row";
|
|
|
|
[Test]
|
|
public void Evaluator_OrdersAstralKeysByCodePointNotUtf16CodeUnit()
|
|
{
|
|
var registry = Registry();
|
|
var rows = new[]
|
|
{
|
|
Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "astral", Kind = "k", Mass = 1 }, AstralKey, 1),
|
|
Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "bmp", Kind = "k", Mass = 1 }, BmpKey, 1)
|
|
};
|
|
|
|
var evaluation = CultNetSelectionEvaluator.Select(registry, rows, new CultNetSelection(), asOf: 1);
|
|
|
|
Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { BmpKey, AstralKey }));
|
|
}
|
|
|
|
// R-C, the cursor half: the cursor's position search must compare by the same code-point order
|
|
// the row tiebreak uses, or a page walk over astral/BMP-boundary keys can skip or repeat rows.
|
|
[Test]
|
|
public void Evaluator_CursorPositionUsesCodePointOrderMatchingTheRowTiebreak()
|
|
{
|
|
var registry = Registry();
|
|
var rows = new[]
|
|
{
|
|
Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "astral", Kind = "k", Mass = 1 }, AstralKey, 1),
|
|
Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "bmp", Kind = "k", Mass = 1 }, BmpKey, 1)
|
|
};
|
|
var selection = new CultNetSelection { Limit = 1 };
|
|
|
|
var first = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1);
|
|
Assert.That(first.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { BmpKey }));
|
|
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.Key.Value), Is.EqualTo(new[] { AstralKey }));
|
|
}
|
|
|
|
// R-E: cites.target.schemaId is matched through the same alias matcher `schemas` uses (a
|
|
// SchemaName, not only the real content-hash SchemaId), and a target whose schema matches
|
|
// nothing declared is refused at the door - never answered with a quietly empty page.
|
|
[Test]
|
|
public void Evaluator_MatchesCitesTargetThroughTheAliasMatcher()
|
|
{
|
|
var registry = Registry();
|
|
var target = new CultNetSelectionEvaluatorTests.SelLeafA { Name = "target", Kind = "k", Mass = 1 };
|
|
var citer = new CultNetSelectionEvaluatorTests.SelCiter { Name = "citer", Design = new CultRecordRef<CultNetSelectionEvaluatorTests.SelFixtureMiddle>(new CultRecordKey("target")) };
|
|
var rows = new[] { Row(registry, target, "target", 1), Row(registry, citer, "citer", 2) };
|
|
|
|
var alias = registry.GetRequired<CultNetSelectionEvaluatorTests.SelLeafA>().SchemaName;
|
|
var selection = new CultNetSelection
|
|
{
|
|
Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = alias, RecordKey = "target" } }
|
|
};
|
|
var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1);
|
|
|
|
Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "citer" }));
|
|
}
|
|
|
|
[Test]
|
|
public void Validation_RefusesACitesTargetSchemaThatMatchesNoDeclaredSchema()
|
|
{
|
|
var descriptors = Registry().AllDescriptors.ToArray();
|
|
var selection = new CultNetSelection
|
|
{
|
|
Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = "no-such-schema", RecordKey = "target" } }
|
|
};
|
|
|
|
var ex = Assert.Throws<CultNetSelectionInvalidException>(() => selection.Validate(descriptors));
|
|
Assert.That(ex!.Field, Is.EqualTo("cites.target.schemaId"));
|
|
}
|
|
|
|
// R-E: cites.target.schemaId is resolved against every declared schema, not only the schemas
|
|
// `selection.Schemas` itself reaches - a cites target is a separate structural reference, not
|
|
// filtered by the selection's own schema allowlist. A plausible weaker implementation reuses the
|
|
// `reachable` set already computed for Fields validation and wrongly refuses a valid target whose
|
|
// schema sits outside it.
|
|
[Test]
|
|
public void Validation_ResolvesACitesTargetSchemaEvenWhenNotInTheSelectionsOwnSchemasFilter()
|
|
{
|
|
var registry = Registry();
|
|
var descriptors = registry.AllDescriptors.ToArray();
|
|
var citerSchemaId = registry.GetRequired<CultNetSelectionEvaluatorTests.SelCiter>().SchemaId;
|
|
var leafAlias = registry.GetRequired<CultNetSelectionEvaluatorTests.SelLeafA>().SchemaName;
|
|
|
|
var selection = new CultNetSelection
|
|
{
|
|
Schemas = new[] { citerSchemaId },
|
|
Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = leafAlias, RecordKey = "target" } }
|
|
};
|
|
|
|
Assert.DoesNotThrow(() => selection.Validate(descriptors));
|
|
}
|
|
|
|
// R-F: the door is inside Select/EvaluateAll unconditionally - there is no public entry point
|
|
// that evaluates a selection without validating it first.
|
|
[Test]
|
|
public void Evaluator_SelectValidatesFirstAndNeverAnswersAnInvalidSelection()
|
|
{
|
|
var registry = Registry();
|
|
var rows = new[] { Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "a", Kind = "k", Mass = 1 }, "a", 1) };
|
|
var invalid = new CultNetSelection { Schemas = Array.Empty<string>() };
|
|
|
|
Assert.Throws<CultNetSelectionInvalidException>(() => CultNetSelectionEvaluator.Select(registry, rows, invalid, asOf: 1));
|
|
}
|
|
|
|
// R-H: the cursor digest length-prefixes every string and list, so no delimiter choice can make
|
|
// two different selections collide. The pre-fix scheme joined keys with "," and a field
|
|
// predicate's values with "|" with no length prefix - Keys=["a,b"] (one string containing a
|
|
// literal comma) digested the same as Keys=["a","b"] (two strings), and a field's
|
|
// Values=["a|b"] digested the same as Values=["a","b"].
|
|
[Test]
|
|
public void ComputeDigest_DoesNotCollideOnDelimiterAmbiguousKeysOrFieldValues()
|
|
{
|
|
var keysOne = new CultNetSelection { Keys = new[] { "a,b" } };
|
|
var keysTwo = new CultNetSelection { Keys = new[] { "a", "b" } };
|
|
Assert.That(CultNetSelectionCursor.ComputeDigest(keysOne), Is.Not.EqualTo(CultNetSelectionCursor.ComputeDigest(keysTwo)));
|
|
|
|
var valuesOne = new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "a|b" } } } };
|
|
var valuesTwo = new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "a", "b" } } } };
|
|
Assert.That(CultNetSelectionCursor.ComputeDigest(valuesOne), Is.Not.EqualTo(CultNetSelectionCursor.ComputeDigest(valuesTwo)));
|
|
}
|
|
|
|
// R-J: any_of on a numeric alias compares TryGetIndexNumber's canonical rendering, not the
|
|
// string getter's culture-dependent ToString() - a float large enough that .NET's default
|
|
// ToString() renders exponent notation ("1E+21") must still match its canonical decimal form.
|
|
[Test]
|
|
public void Evaluator_AnyOfOnANumericAliasComparesTheCanonicalRenderingNotToString()
|
|
{
|
|
var registry = Registry();
|
|
var row = new CultNetSelectionEvaluatorTests.SelLeafA { Name = "big", Kind = "k", Mass = 1e21f };
|
|
var rows = new[] { Row(registry, row, "big", 1) };
|
|
var descriptor = registry.GetRequired<CultNetSelectionEvaluatorTests.SelLeafA>();
|
|
Assert.That(descriptor.TryGetIndexNumber(row, "mass", out var canonical), Is.True);
|
|
// .NET's default ToString() on this magnitude renders exponent notation ("1E+21"); the
|
|
// canonical decimal never does - if the two happened to be textually equal this assertion,
|
|
// not the mutant, would be the problem, so pin that precondition rather than assume it.
|
|
Assert.That(canonical, Does.Not.Contain("E"));
|
|
Assert.That(row.Mass.ToString(System.Globalization.CultureInfo.InvariantCulture), Is.Not.EqualTo(canonical));
|
|
|
|
var selection = new CultNetSelection
|
|
{
|
|
Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = "any_of", Values = new[] { canonical! } } }
|
|
};
|
|
var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1);
|
|
|
|
Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "big" }));
|
|
}
|
|
}
|
|
}
|