CultNet selection cut1 fix batch, second Hands pass: mutation entries and S9
- 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>
This commit is contained in:
parent
a31993d518
commit
d94174d597
4 changed files with 576 additions and 0 deletions
|
|
@ -160,5 +160,233 @@
|
|||
Killer = 'FullyQualifiedName=GameCult.Networking.Tests.CultNetCanonicalNumberTests.CompareOrdersNegativeBelowPositive'
|
||||
Old = 'if (negativeLeft != negativeRight)'
|
||||
New = 'if (false)'
|
||||
},
|
||||
#
|
||||
# Cut 1 fix batch, second Hands pass (docs/cultnet-selection-cut.md, "Not done, sent to a second
|
||||
# Hands"): mutation entries for the C# rules R-A/B/C/D/E/F/G/H/J/L the first pass (7903853..3d32c67)
|
||||
# fixed but proved only by regression tests, plus S9 (D6) which had no test at all. Killers below are
|
||||
# tests/GameCult.Networking.Tests.CultNetSelectionFixBatchTests.cs (new file, this pass),
|
||||
# NetworkingTests.cs (two new RUDP round-trip tests, this pass) and the pre-existing
|
||||
# SelectionParityVectorTests.cs / CultNetSelectionPageTests.cs where those already pin the rule.
|
||||
#
|
||||
@{
|
||||
# R-A: both C# servers register v1 listeners. Revert = unregister the subscription server's,
|
||||
# which S9's own test also depends on - if this listener is gone, that test's subscribe message
|
||||
# is never answered at all and the test times out.
|
||||
Id = 'NET-RA-SubscribeListener-Revert'
|
||||
Rule = 'R-A: CultNetDatabaseSubscriptionServer registers a v1 subscribe listener, not only a v0 one.'
|
||||
Mutant = 'revert'
|
||||
File = 'src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs'
|
||||
Killer = 'FullyQualifiedName=GameCult.Networking.Tests.NetworkingTests.DatabaseSubscriptionServer_SubscribesV1AndReconcilesAHopBearingSelectionWhenTheCiterChanges'
|
||||
Old = '_server.OnCultNet(_subscribeV1);'
|
||||
New = ''
|
||||
},
|
||||
# NET-RA-SnapshotListener-Revert (unregistering CultNetDatabaseServer's v1 snapshot listener) is not
|
||||
# here: CultNetDatabaseServer pairs only with Server (the LiteNetLib/TCP-secured production
|
||||
# construction site), and no existing test in this suite drives Server's real client/listener
|
||||
# dispatch for CultNetDatabaseServer at all - every existing test (this pass's included) calls
|
||||
# CreateSelectionResponse/CreateSnapshotResponse directly. A revert of that OnCultNet registration
|
||||
# line is therefore not yet reached by this suite - a real gap in wire-level coverage for the
|
||||
# snapshot path, not a fixture-tunable one; the subscription server's equivalent registration is
|
||||
# covered below (NET-RA-SubscribeListener-Revert) because CultNetDatabaseSubscriptionServer does
|
||||
# accept RudpCultNetSchemaServer and this pass's S9 test drives it over the real wire.
|
||||
@{
|
||||
# Loosening: a v1 request is answered, but through a selection quietly lowered to v0's two
|
||||
# allowlists - fields/cites/cited/projection silently dropped instead of honoured, the exact
|
||||
# "answers v1 through v0 lowering" shortcut R-A rules out.
|
||||
Id = 'NET-RA-Snapshot-V0Lowering-Loosening'
|
||||
Rule = 'R-A: a v1 snapshot request is answered against its own full Selection, not a v0-shaped lowering that drops fields/cites/cited/projection.'
|
||||
Mutant = 'loosening'
|
||||
File = 'src/GameCult.Networking/CultNetDatabaseServer.cs'
|
||||
Killer = 'FullyQualifiedName=GameCult.Networking.Tests.NetworkingTests.CultNetDatabaseServer_AnswersV1SnapshotRequestRespectingItsFields'
|
||||
Old = ' request.Selection,
|
||||
ordinalOf: (schemaId, key) => _database.LastWriteSequence(schemaId, key) ?? 0,'
|
||||
New = ' new CultNetSelection { Schemas = request.Selection.Schemas, Keys = request.Selection.Keys },
|
||||
ordinalOf: (schemaId, key) => _database.LastWriteSequence(schemaId, key) ?? 0,'
|
||||
},
|
||||
@{
|
||||
# S9/D6: a hop-bearing selection must run the full reconcile diff on every change, not the
|
||||
# single-row fast path - a change to the citer (not the page row itself) must still surface.
|
||||
Id = 'NET-S9-HopFastPath-Revert'
|
||||
Rule = 'D6/S9: Watch routes a hop-bearing selection to Reconcile on every change; it must not fall through to the single-row fast path.'
|
||||
Mutant = 'revert'
|
||||
File = 'src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs'
|
||||
Killer = 'FullyQualifiedName=GameCult.Networking.Tests.NetworkingTests.DatabaseSubscriptionServer_SubscribesV1AndReconcilesAHopBearingSelectionWhenTheCiterChanges'
|
||||
Old = ' // D6: cites/cited are set-dependent - a change to row B can change whether row A
|
||||
// matches - so a hop-bearing selection runs the full diff-against-delivered
|
||||
// reconcile on every change instead of the single-row fast path.
|
||||
if (request.Selection.HasHop)
|
||||
{
|
||||
Reconcile(key, request);
|
||||
return;
|
||||
}
|
||||
'
|
||||
New = ''
|
||||
},
|
||||
@{
|
||||
# R-B: edges are anchored to the row the hop direction actually reports against, not always to
|
||||
# the citer - the pre-fix bug filtered every edge by "is the citer (From) on the page", even
|
||||
# under `cited`, where the page rows are the cited (To) rows.
|
||||
Id = 'NET-RB-CitedAnchor-Revert'
|
||||
Rule = 'R-B: a cited edge is anchored to the cited row (To), not the citer (From) - the direction-blind pre-fix anchor.'
|
||||
Mutant = 'revert'
|
||||
File = 'src/GameCult.Networking/CultNetSelectionEvaluator.cs'
|
||||
Killer = 'FullyQualifiedName=GameCult.Networking.Tests.CultNetSelectionFixBatchTests.Evaluator_AnchorsCitedEdgesToTheCitedRowAndOrdersThemByFromThenRole'
|
||||
Old = 'foreach (var edge in citedEdges) edges.Add((edge, edge.To));'
|
||||
New = 'foreach (var edge in citedEdges) edges.Add((edge, edge.From));'
|
||||
},
|
||||
@{
|
||||
# R-B loosening: the order rule is page-row order then (from, role, to) in code-point order -
|
||||
# dropping the (from, role, to) tie-break still groups edges by their anchor row correctly (a
|
||||
# plausible partial fix) but no longer orders two edges anchored to the same row.
|
||||
Id = 'NET-RB-EdgeOrder-Loosening'
|
||||
Rule = 'R-B: two edges anchored to the same page row are ordered by (from, role, to) in code-point order, not left in row-supply order.'
|
||||
Mutant = 'loosening'
|
||||
File = 'src/GameCult.Networking/CultNetSelectionEvaluator.cs'
|
||||
Killer = 'FullyQualifiedName=GameCult.Networking.Tests.CultNetSelectionFixBatchTests.Evaluator_AnchorsCitedEdgesToTheCitedRowAndOrdersThemByFromThenRole'
|
||||
Old = ' return full.Edges
|
||||
.Where(pair => pageRowIndex.ContainsKey(pair.Anchor.Key.Value))
|
||||
.OrderBy(pair => pageRowIndex[pair.Anchor.Key.Value])
|
||||
.ThenBy(pair => pair.Edge.From.Descriptor.SchemaId, CultNetCodePointComparer.Instance)
|
||||
.ThenBy(pair => pair.Edge.From.Key.Value, CultNetCodePointComparer.Instance)
|
||||
.ThenBy(pair => pair.Edge.Role, CultNetCodePointComparer.Instance)
|
||||
.ThenBy(pair => pair.Edge.To.Descriptor.SchemaId, CultNetCodePointComparer.Instance)
|
||||
.ThenBy(pair => pair.Edge.To.Key.Value, CultNetCodePointComparer.Instance)
|
||||
.Select(pair => pair.Edge)
|
||||
.ToArray();'
|
||||
New = ' return full.Edges
|
||||
.Where(pair => pageRowIndex.ContainsKey(pair.Anchor.Key.Value))
|
||||
.OrderBy(pair => pageRowIndex[pair.Anchor.Key.Value])
|
||||
.Select(pair => pair.Edge)
|
||||
.ToArray();'
|
||||
},
|
||||
@{
|
||||
# R-C: the row tiebreak (schemaId, then key) uses code-point order everywhere, not UTF-16
|
||||
# code-unit ("ordinal") order - the two disagree on astral-vs-BMP key comparisons.
|
||||
Id = 'NET-RC-RowTiebreak-Revert'
|
||||
Rule = 'R-C: the ascending row tiebreak compares the record key by code-point order (CultNetCodePointComparer), not UTF-16 code-unit order.'
|
||||
Mutant = 'revert'
|
||||
File = 'src/GameCult.Networking/CultNetSelectionEvaluator.cs'
|
||||
Killer = 'FullyQualifiedName=GameCult.Networking.Tests.CultNetSelectionFixBatchTests.Evaluator_OrdersAstralKeysByCodePointNotUtf16CodeUnit'
|
||||
Old = 'matched.OrderBy(row => row.Ordinal).ThenBy(row => row.Descriptor.SchemaId, CultNetCodePointComparer.Instance).ThenBy(row => row.Key.Value, CultNetCodePointComparer.Instance))'
|
||||
New = 'matched.OrderBy(row => row.Ordinal).ThenBy(row => row.Descriptor.SchemaId, CultNetCodePointComparer.Instance).ThenBy(row => row.Key.Value, StringComparer.Ordinal))'
|
||||
},
|
||||
@{
|
||||
# R-C loosening: the row order itself is fixed (kills the revert above), but the cursor position
|
||||
# search still compares by UTF-16 code-unit order - a plausible partial fix that forgets the
|
||||
# cursor half, which desyncs a page walk over an astral/BMP key boundary.
|
||||
Id = 'NET-RC-CursorPosition-Loosening'
|
||||
Rule = 'R-C: the cursor position compare uses code-point order matching the row tiebreak, not UTF-16 code-unit order.'
|
||||
Mutant = 'loosening'
|
||||
File = 'src/GameCult.Networking/CultNetSelectionEvaluator.cs'
|
||||
Killer = 'FullyQualifiedName=GameCult.Networking.Tests.CultNetSelectionFixBatchTests.Evaluator_CursorPositionUsesCodePointOrderMatchingTheRowTiebreak'
|
||||
Old = 'var schema = CultNetCodePointComparer.Instance.Compare(row.Descriptor.SchemaId, cursor.SchemaId);
|
||||
return schema != 0 ? schema : CultNetCodePointComparer.Instance.Compare(row.Key.Value, cursor.RecordKey);'
|
||||
New = 'var schema = string.CompareOrdinal(row.Descriptor.SchemaId, cursor.SchemaId);
|
||||
return schema != 0 ? schema : string.CompareOrdinal(row.Key.Value, cursor.RecordKey);'
|
||||
},
|
||||
@{
|
||||
# R-E: cites.target.schemaId is matched through the alias matcher, same as `schemas` - not an
|
||||
# exact SchemaId string compare a schema alias could never satisfy.
|
||||
Id = 'NET-RE-AliasMatcher-Revert'
|
||||
Rule = 'R-E: MatchesCitation compares a cites target through CultNetSchemaAliasMatching, not an exact SchemaId string compare.'
|
||||
Mutant = 'revert'
|
||||
File = 'src/GameCult.Networking/CultNetSelectionEvaluator.cs'
|
||||
Killer = 'FullyQualifiedName=GameCult.Networking.Tests.CultNetSelectionFixBatchTests.Evaluator_MatchesCitesTargetThroughTheAliasMatcher'
|
||||
Old = 'if (!CultNetSchemaAliasMatching.Matches(citation.Target.SchemaId, resolved.Descriptor))'
|
||||
New = 'if (resolved.Descriptor.SchemaId != citation.Target.SchemaId)'
|
||||
},
|
||||
@{
|
||||
# R-E: an unmatched cites.target.schemaId is refused at the door (this pass added the check -
|
||||
# docs/cultnet-selection-cut.md named it but the first fix-batch pass never wrote it), never
|
||||
# answered with a silently empty page by MatchesCitation's own "not found" continue.
|
||||
Id = 'NET-RE-DoorRefusesUnmatchedTarget-Revert'
|
||||
Rule = 'R-E: Validate refuses a cites.target.schemaId that matches no declared schema.'
|
||||
Mutant = 'revert'
|
||||
File = 'src/GameCult.Networking/CultNetSelection.cs'
|
||||
Killer = 'FullyQualifiedName=GameCult.Networking.Tests.CultNetSelectionFixBatchTests.Validation_RefusesACitesTargetSchemaThatMatchesNoDeclaredSchema'
|
||||
Old = 'if (!allDescriptors.Any(descriptor => CultNetSchemaAliasMatching.Matches(selection.Cites.Target!.SchemaId, descriptor)))
|
||||
throw new CultNetSelectionInvalidException("cites.target.schemaId", selection.Cites.Target!.SchemaId, $"selection.cites.target.schemaId \"{selection.Cites.Target!.SchemaId}\" does not match any declared schema.");'
|
||||
New = ''
|
||||
},
|
||||
@{
|
||||
# R-E loosening: the door reuses `reachable` (Fields validation's own Schemas-filtered set)
|
||||
# instead of the full declared-schema set - a cites target is a separate structural reference,
|
||||
# not itself constrained by the selection's own Schemas allowlist, so this wrongly refuses a
|
||||
# valid target whose schema sits outside that filter.
|
||||
Id = 'NET-RE-DoorTargetReachableOnly-Loosening'
|
||||
Rule = 'R-E: the door resolves cites.target.schemaId against every declared schema, not only the selection''s own Schemas-filtered reachable set.'
|
||||
Mutant = 'loosening'
|
||||
File = 'src/GameCult.Networking/CultNetSelection.cs'
|
||||
Killer = 'FullyQualifiedName=GameCult.Networking.Tests.CultNetSelectionFixBatchTests.Validation_ResolvesACitesTargetSchemaEvenWhenNotInTheSelectionsOwnSchemasFilter'
|
||||
Old = 'if (!allDescriptors.Any(descriptor => CultNetSchemaAliasMatching.Matches(selection.Cites.Target!.SchemaId, descriptor)))'
|
||||
New = 'if (!reachable.Any(descriptor => CultNetSchemaAliasMatching.Matches(selection.Cites.Target!.SchemaId, descriptor)))'
|
||||
},
|
||||
@{
|
||||
# R-F: the door is inside Select/EvaluateAll unconditionally - the pre-fix code took a
|
||||
# validate:true/false flag that let a caller skip it.
|
||||
Id = 'NET-RF-DoorInsideSelect-Revert'
|
||||
Rule = 'R-F: EvaluateAll validates the selection before matching anything - no public evaluation path can skip the door.'
|
||||
Mutant = 'revert'
|
||||
File = 'src/GameCult.Networking/CultNetSelectionEvaluator.cs'
|
||||
Killer = 'FullyQualifiedName=GameCult.Networking.Tests.CultNetSelectionFixBatchTests.Evaluator_SelectValidatesFirstAndNeverAnswersAnInvalidSelection'
|
||||
Old = 'selection.Validate(registry.AllDescriptors.ToArray());'
|
||||
New = ''
|
||||
},
|
||||
@{
|
||||
# R-G: SelectPage/Page reports the selection''s whole matching count (TotalMatched), not the
|
||||
# page''s own row count (C5). Already regression-tested by CultNetSelectionPageTests; this entry
|
||||
# is that rule''s mutation coverage.
|
||||
Id = 'NET-RG-TotalMatched-Revert'
|
||||
Rule = 'R-G/C5: Evaluation.TotalMatched is the selection''s whole matching count, not this page''s row count.'
|
||||
Mutant = 'revert'
|
||||
File = 'src/GameCult.Networking/CultNetSelectionEvaluator.cs'
|
||||
Killer = 'FullyQualifiedName=GameCult.Networking.Tests.CultNetSelectionPageTests.CreateSelectionResponse_ReportsTotalMatchedNotPageLength'
|
||||
Old = 'return new Evaluation { Rows = page, Edges = pageEdges, NextCursor = nextCursor, TotalMatched = ordered.Count };'
|
||||
New = 'return new Evaluation { Rows = page, Edges = pageEdges, NextCursor = nextCursor, TotalMatched = page.Length };'
|
||||
},
|
||||
@{
|
||||
# R-H: every string and list in the cursor digest is length-prefixed, closing the delimiter-
|
||||
# ambiguous collisions Soul named (Keys=["a,b"] vs ["a","b"]; a field's Values=["a|b"] vs
|
||||
# ["a","b"]). Revert reproduces a single-separator-joined digest (comma), which the keys half of
|
||||
# the killer test catches; the loosening below reproduces the exact pipe-joined case Soul named,
|
||||
# which only the field-values half catches.
|
||||
Id = 'NET-RH-Digest-CommaJoin-Revert'
|
||||
Rule = 'R-H: AppendList length-prefixes each entry and the list itself, rather than joining entries with one separator that can appear inside an entry.'
|
||||
Mutant = 'revert'
|
||||
File = 'src/GameCult.Networking/CultNetSelectionEvaluator.cs'
|
||||
Killer = 'FullyQualifiedName=GameCult.Networking.Tests.CultNetSelectionFixBatchTests.ComputeDigest_DoesNotCollideOnDelimiterAmbiguousKeysOrFieldValues'
|
||||
Old = ' var ordered = (values ?? Array.Empty<string>()).OrderBy(v => v, CultNetCodePointComparer.Instance).ToArray();
|
||||
sb.Append(ordered.Length).Append('':'');
|
||||
foreach (var value in ordered)
|
||||
AppendString(sb, value);'
|
||||
New = ' sb.Append(string.Join(",", (values ?? Array.Empty<string>()).OrderBy(v => v, CultNetCodePointComparer.Instance)));'
|
||||
},
|
||||
@{
|
||||
Id = 'NET-RH-Digest-PipeJoin-Loosening'
|
||||
Rule = 'R-H: the exact collision Soul named - a separator-joined digest using "|" still collides Values=["a|b"] with Values=["a","b"].'
|
||||
Mutant = 'loosening'
|
||||
File = 'src/GameCult.Networking/CultNetSelectionEvaluator.cs'
|
||||
Killer = 'FullyQualifiedName=GameCult.Networking.Tests.CultNetSelectionFixBatchTests.ComputeDigest_DoesNotCollideOnDelimiterAmbiguousKeysOrFieldValues'
|
||||
Old = ' var ordered = (values ?? Array.Empty<string>()).OrderBy(v => v, CultNetCodePointComparer.Instance).ToArray();
|
||||
sb.Append(ordered.Length).Append('':'');
|
||||
foreach (var value in ordered)
|
||||
AppendString(sb, value);'
|
||||
New = ' sb.Append(string.Join("|", (values ?? Array.Empty<string>()).OrderBy(v => v, CultNetCodePointComparer.Instance)));'
|
||||
},
|
||||
@{
|
||||
# R-J: any_of on a numeric alias compares TryGetIndexNumber's canonical rendering, not the
|
||||
# string getter's culture-dependent ToString() (which renders exponent notation for a large
|
||||
# float, never matching the canonical decimal form a caller would send).
|
||||
Id = 'NET-RJ-NumericAnyOf-Revert'
|
||||
Rule = 'R-J: any_of on a numeric alias compares the canonical rendering (TryGetIndexNumber), not the raw string getter.'
|
||||
Mutant = 'revert'
|
||||
File = 'src/GameCult.Networking/CultNetSelectionEvaluator.cs'
|
||||
Killer = 'FullyQualifiedName=GameCult.Networking.Tests.CultNetSelectionFixBatchTests.Evaluator_AnyOfOnANumericAliasComparesTheCanonicalRenderingNotToString'
|
||||
Old = ' var isNumeric = descriptor.DeclaredMembers.Any(member => member.IndexAlias == field.Index && member.IsNumeric);
|
||||
var value = isNumeric
|
||||
? (descriptor.TryGetIndexNumber(document, field.Index, out var numeric) ? numeric : null)
|
||||
: (descriptor.TryGetIndexValue(document, field.Index, out var raw) ? raw : null);'
|
||||
New = ' var value = descriptor.TryGetIndexValue(document, field.Index, out var raw) ? raw : null;'
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -377,6 +377,12 @@ namespace GameCult.Networking
|
|||
{
|
||||
if (string.IsNullOrEmpty(selection.Cites.Target?.SchemaId) || string.IsNullOrEmpty(selection.Cites.Target?.RecordKey))
|
||||
throw new CultNetSelectionInvalidException("cites.target", null, "selection.cites.target requires schemaId and recordKey.");
|
||||
// R-E (docs/cultnet-selection-cut.md): a cites target names a schema through the same
|
||||
// alias matcher `schemas` uses. One that matches no declared schema is refused here,
|
||||
// never answered with an empty page by MatchesCitation silently treating an unresolved
|
||||
// target as "not found".
|
||||
if (!allDescriptors.Any(descriptor => CultNetSchemaAliasMatching.Matches(selection.Cites.Target!.SchemaId, descriptor)))
|
||||
throw new CultNetSelectionInvalidException("cites.target.schemaId", selection.Cites.Target!.SchemaId, $"selection.cites.target.schemaId \"{selection.Cites.Target!.SchemaId}\" does not match any declared schema.");
|
||||
if (selection.Cites.Role != null && !AnySchemaDeclaresRole(allDescriptors, selection.Cites.Role))
|
||||
throw new CultNetSelectionInvalidException("cites.role", selection.Cites.Role, $"selection.cites.role \"{selection.Cites.Role}\" is not declared by any schema.");
|
||||
}
|
||||
|
|
|
|||
219
tests/GameCult.Networking.Tests/CultNetSelectionFixBatchTests.cs
Normal file
219
tests/GameCult.Networking.Tests/CultNetSelectionFixBatchTests.cs
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
#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" }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1886,6 +1886,129 @@ namespace GameCult.Networking.Tests
|
|||
}
|
||||
}
|
||||
|
||||
// R-A (docs/cultnet-selection-cut.md, Self's rulings for the Cut 1 fix batch): CultNetDatabaseServer
|
||||
// answers a v1 snapshot request against the selection's own Fields predicate, not a selection
|
||||
// quietly lowered to v0's two allowlists. CultNetDatabaseServer pairs with Server (the
|
||||
// LiteNetLib/TCP-secured production construction site, per section 1), not RudpCultNetSchemaServer
|
||||
// - matching CultNetDatabaseServer_Creates_Filtered_SnapshotResponse above, this drives
|
||||
// CreateSelectionResponse directly rather than a real client/server round trip.
|
||||
[Test]
|
||||
public async Task CultNetDatabaseServer_AnswersV1SnapshotRequestRespectingItsFields()
|
||||
{
|
||||
var cache = new CultCache();
|
||||
var database = new CultNetDatabase(cache);
|
||||
await database.PutAsync(new CultRecordKey("v1:weapon"), new CultNetSelectionEvaluatorTests.SelLeafA
|
||||
{
|
||||
Name = "sword",
|
||||
Kind = "weapon",
|
||||
Mass = 3
|
||||
});
|
||||
await database.PutAsync(new CultRecordKey("v1:armor"), new CultNetSelectionEvaluatorTests.SelLeafA
|
||||
{
|
||||
Name = "shield",
|
||||
Kind = "armor",
|
||||
Mass = 3
|
||||
});
|
||||
|
||||
using var server = new Server(cache, DevelopmentServerSecurity);
|
||||
using var databaseServer = new CultNetDatabaseServer(server, database);
|
||||
|
||||
var response = databaseServer.CreateSelectionResponse(new CultNetSnapshotRequestV1Message
|
||||
{
|
||||
MessageId = "v1-snapshot",
|
||||
Selection = new CultNetSelection
|
||||
{
|
||||
Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "weapon" } } },
|
||||
Projection = CultNetSelectionProjections.Document
|
||||
}
|
||||
});
|
||||
|
||||
Assert.That(response.Matched, Is.EqualTo(1u));
|
||||
Assert.That(response.Documents!.Select(document => document.RecordKey), Is.EqualTo(new[] { "v1:weapon" }));
|
||||
}
|
||||
|
||||
// S9 (docs/cultnet-selection-cut.md section 10) / D6: a hop-bearing subscription reconciles the
|
||||
// whole matched set on every change, not only the changed row. "target" does not itself change
|
||||
// here - a new citer is put that cites it - so only the reconcile path (not the single-row fast
|
||||
// path, which would evaluate the citer's own row against a Selection carrying no hop and answer
|
||||
// null, since the citer's schema is excluded by this selection's Schemas filter) can produce the
|
||||
// add this test waits for.
|
||||
[Test]
|
||||
public async Task DatabaseSubscriptionServer_SubscribesV1AndReconcilesAHopBearingSelectionWhenTheCiterChanges()
|
||||
{
|
||||
var cache = new CultCache();
|
||||
var database = new CultNetDatabase(cache);
|
||||
await database.PutAsync(new CultRecordKey("target"), new CultNetSelectionEvaluatorTests.SelLeafA
|
||||
{
|
||||
Name = "target",
|
||||
Kind = "k",
|
||||
Mass = 1
|
||||
});
|
||||
var targetSchemaId = cache.Registry.GetRequired<CultNetSelectionEvaluatorTests.SelLeafA>().SchemaId;
|
||||
|
||||
using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
|
||||
{
|
||||
RuntimeId = "database-v1-hop-subscription-server",
|
||||
Socket = BindUdpSocket()
|
||||
});
|
||||
using var subscriptions = new CultNetDatabaseSubscriptionServer(server, database);
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
var serverThread = new Thread(() =>
|
||||
{
|
||||
while (!cancellation.IsCancellationRequested)
|
||||
{
|
||||
_ = server.PollOnceAsync().GetAwaiter().GetResult();
|
||||
Thread.Sleep(1);
|
||||
}
|
||||
}) { IsBackground = true };
|
||||
serverThread.Start();
|
||||
try
|
||||
{
|
||||
using var client = CultNetSchemaClients.CreateRudp("database-v1-hop-subscription-client");
|
||||
var subscribed = new TaskCompletionSource<CultNetSnapshotResponseRawV1Message>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var changes = new ConcurrentQueue<CultNetDatabaseChangeRawMessage>();
|
||||
client.OnCultNet<CultNetSnapshotResponseRawV1Message>(message => subscribed.TrySetResult(message));
|
||||
client.OnCultNet<CultNetDatabaseChangeRawMessage>(message => changes.Enqueue(message));
|
||||
client.Connect("127.0.0.1", server.LocalEndPoint.Port);
|
||||
await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2));
|
||||
|
||||
client.SendCultNet(new CultNetDatabaseSubscribeV1Message
|
||||
{
|
||||
MessageId = "subscribe-v1-hop",
|
||||
SubscriptionId = "hop",
|
||||
IncludeSnapshot = true,
|
||||
Selection = new CultNetSelection
|
||||
{
|
||||
Schemas = new[] { targetSchemaId },
|
||||
Cited = new CultNetIncoming { Role = "Design", Exists = true }
|
||||
}
|
||||
});
|
||||
|
||||
var snapshot = await AwaitWithTimeout(subscribed.Task, TimeSpan.FromSeconds(2));
|
||||
// "target" is not yet cited by anything - the hop-bearing selection matches nothing yet.
|
||||
Assert.That(snapshot.Matched, Is.EqualTo(0u));
|
||||
|
||||
// The citer is a different schema entirely, excluded by this selection's own Schemas
|
||||
// filter - the change that must cause "target" to be reported is not to "target" itself.
|
||||
await database.PutAsync(new CultRecordKey("citer"), new CultNetSelectionEvaluatorTests.SelCiter
|
||||
{
|
||||
Name = "citer",
|
||||
Design = new CultRecordRef<CultNetSelectionEvaluatorTests.SelFixtureMiddle>(new CultRecordKey("target"))
|
||||
});
|
||||
|
||||
await WaitUntilAsync(
|
||||
() => changes.Any(change => change.ChangeKind == "added" && change.Document?.RecordKey == "target"),
|
||||
TimeSpan.FromSeconds(2));
|
||||
Assert.That(changes.Any(change => change.ChangeKind == "added" && change.Document?.RecordKey == "target"), Is.True);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cancellation.Cancel();
|
||||
serverThread.Join();
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DatabaseSubscriptionServer_ProjectsBodyDemandAndWithdrawalFromExactSubscription()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue