R-AF/R-AJ: C# parity vectors and the ordering-composition tests they needed
R-AF: registers ParityCiterNarrow (citer_narrow's C# type, [CultIndex "narrow_ref"] so its role matches the fixture/Rust spelling), adds Keys-scoped vectors pinning both shapes from Soul's table - a shared key where the only candidate is out of target refuses evaluation-time (expectedEvaluationRefusal, a narrow addition to the vector schema distinct from the door-only expectedRefusalField - not R-Z/R-AH's page-bytes harness), and one where an in-target row shares a key with an out-of-target row and must still resolve. Landing these exposed a real, pre-existing cross-runtime scope difference in R-W unrelated to R-AF: Rust checked every declared reference on a row regardless of the citation's requested role; C#'s ReferenceMembers(descriptor, citation.Role) narrows by role first. Fixed on the Rust side (previous commit); hop_by_role_cites_exact_id and cites_target_by_name_alias keep an explicit Keys scope here anyway as belt-and-suspenders. Also corrects S-12's last stale comment: cites_target_by_name_alias's "Confirmed cross-runtime defect" note was already false - the alias port fixed it - and still called it confirmed. R-AJ: CultNetSelectionSurvivorTests.cs's row-tiebreak fixture (Evaluator_BreaksAnOrdinalTieBySchemaIdThenRecordKeyInBothDirections) was degenerate - probed by hand-reverting the source and swapping the tiebreak's two ThenBy calls, it still passed, because whether the old 3-row fixture discriminated the swap depended on which of SelLeafA's/ SelLeafB's real schema ids happened to sort first, and on this repo's ids it didn't. Rewritten to assign row keys relative to the schema ids discovered at runtime (low-sorting schema gets the high-sorting key and vice versa) so the two candidate orderings are always the exact reverse of each other; reprobed the same mutant afterward and it now fails. Adds a direct EdgesFor test (constructing FullEvaluation/EdgeMatch by hand) pinning "From.Key before Role" in the five-key edge tiebreak - the two existing EdgesFor tests each tie one of those two components, so neither could tell a Role-first mutant from the rule. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
9bc50830c1
commit
b9db8702d2
2 changed files with 214 additions and 22 deletions
|
|
@ -185,6 +185,16 @@ namespace GameCult.Networking.Tests
|
|||
// prior test used distinct ordinals, so the schemaId/recordKey tiebreak comparators were never
|
||||
// actually invoked by the sort (LINQ's composed comparer short-circuits once a key differs).
|
||||
// Three rows share one ordinal so the sort must fall through to schemaId, then recordKey.
|
||||
//
|
||||
// R-AJ (Soul's SM-4): this test's earlier fixture (two SelLeafA rows "a"/"b", one SelLeafB row
|
||||
// "a") passed 269/269 even under a SchemaId<->Key swap mutant - not because the test is wrong
|
||||
// in principle, but because whether it discriminates the swap depends on which of SelLeafA's/
|
||||
// SelLeafB's real SHA-256 schema ids happens to sort first, and on this repo's actual ids it
|
||||
// didn't (probed: reverting the fix and applying the swap by hand, this test still passed).
|
||||
// A loosening that cannot fail is a finding about the fixture, not license to keep it. The fix
|
||||
// below assigns keys *relative to the schema ids discovered at runtime* - the low-sorting
|
||||
// schema gets the high-sorting key and vice versa - so schema-first and key-first orderings
|
||||
// are forced to disagree regardless of which real schema id happens to be smaller.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
[Test]
|
||||
public void Evaluator_BreaksAnOrdinalTieBySchemaIdThenRecordKeyInBothDirections()
|
||||
|
|
@ -195,30 +205,46 @@ namespace GameCult.Networking.Tests
|
|||
});
|
||||
var schemaA = registry.GetRequired<CultNetSelectionEvaluatorTests.SelLeafA>().SchemaId;
|
||||
var schemaB = registry.GetRequired<CultNetSelectionEvaluatorTests.SelLeafB>().SchemaId;
|
||||
// sanity: schemaA/schemaB really are distinct, or this test would not exercise the tiebreak.
|
||||
Assert.That(schemaA, Is.Not.EqualTo(schemaB));
|
||||
|
||||
// All three rows share ordinal 1: two SelLeafA rows (keys "a"/"b") and one SelLeafB row
|
||||
// (key "a"). Expected order is derived from the same comparer the evaluator uses, so this
|
||||
// test does not depend on which of schemaA/schemaB happens to sort first.
|
||||
var rows = new[]
|
||||
{
|
||||
Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "a-b", Kind = "k", Mass = 1 }, "b", 1),
|
||||
Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "a-a", Kind = "k", Mass = 1 }, "a", 1),
|
||||
Row(registry, new CultNetSelectionEvaluatorTests.SelLeafB { Name = "b-a", Kind = "k", Mass = 1 }, "a", 1)
|
||||
};
|
||||
var schemaALowersFirst = CultNetCodePointComparer.Instance.Compare(schemaA, schemaB) < 0;
|
||||
var lowSchema = schemaALowersFirst ? typeof(CultNetSelectionEvaluatorTests.SelLeafA) : typeof(CultNetSelectionEvaluatorTests.SelLeafB);
|
||||
|
||||
// The low-sorting schema's row gets the *high*-sorting key ("z"), and the high-sorting
|
||||
// schema's row gets the *low*-sorting key ("a") - anti-correlated on purpose, so
|
||||
// schema-first order ([low-schema, high-schema], independent of key) and key-first order
|
||||
// ([low-key, high-key] = [high-schema, low-schema]) are the exact reverse of each other no
|
||||
// matter which real id sorts lower. Two rows, two different schemas, two different keys:
|
||||
// the simplest shape that cannot degenerate into agreement between the two candidate rules.
|
||||
var rows = lowSchema == typeof(CultNetSelectionEvaluatorTests.SelLeafA)
|
||||
? new[]
|
||||
{
|
||||
Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "low-schema-high-key", Kind = "k", Mass = 1 }, "z", 1),
|
||||
Row(registry, new CultNetSelectionEvaluatorTests.SelLeafB { Name = "high-schema-low-key", Kind = "k", Mass = 1 }, "a", 1)
|
||||
}
|
||||
: new[]
|
||||
{
|
||||
Row(registry, new CultNetSelectionEvaluatorTests.SelLeafB { Name = "low-schema-high-key", Kind = "k", Mass = 1 }, "z", 1),
|
||||
Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "high-schema-low-key", Kind = "k", Mass = 1 }, "a", 1)
|
||||
};
|
||||
|
||||
var expectedAscending = rows
|
||||
.OrderBy(r => r.Descriptor.SchemaId, CultNetCodePointComparer.Instance)
|
||||
.ThenBy(r => r.Key.Value, CultNetCodePointComparer.Instance)
|
||||
.Select(r => (r.Descriptor.SchemaId, r.Key.Value))
|
||||
.ToArray();
|
||||
// The anti-correlated construction above means the schema-first answer must put the
|
||||
// high-key row first (its schema sorts low) - if a key-first mutant instead put the
|
||||
// low-key row first, this sanity check catches the fixture degenerating, not just the
|
||||
// main assertions below.
|
||||
Assert.That(expectedAscending[0].Value, Is.EqualTo("z"), "fixture sanity: schema-first must not agree with key-first here");
|
||||
|
||||
var ascending = CultNetSelectionEvaluator.Select(registry, rows, new CultNetSelection(), asOf: 1);
|
||||
Assert.That(ascending.Rows.Select(r => (r.Descriptor.SchemaId, r.Key.Value)), Is.EqualTo(expectedAscending));
|
||||
|
||||
var descending = CultNetSelectionEvaluator.Select(registry, rows, new CultNetSelection { Descending = true }, asOf: 1);
|
||||
Assert.That(descending.Rows.Select(r => (r.Descriptor.SchemaId, r.Key.Value)), Is.EqualTo(expectedAscending.Reverse()));
|
||||
// sanity: schemaA/schemaB really are distinct, or this test would not exercise the tiebreak.
|
||||
Assert.That(schemaA, Is.Not.EqualTo(schemaB));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
|
@ -339,6 +365,37 @@ namespace GameCult.Networking.Tests
|
|||
Assert.That(evaluation.Edges.Select(e => e.From.Key.Value), Is.EqualTo(new[] { "citer-z", "citer-a" }));
|
||||
}
|
||||
|
||||
// R-AJ (Soul's SM-4): the two tests above each isolate one component of the (From.SchemaId,
|
||||
// From.Key, Role, To.SchemaId, To.Key) tiebreak by tying every other one - so neither can
|
||||
// distinguish the *composition* "From.Key before Role" from "Role before From.Key", which is
|
||||
// exactly the mutant that survived (permuting Role ahead of From.Key, keeping every
|
||||
// component). This calls EdgesFor directly with two hand-built edges sharing one anchor, so
|
||||
// both From.Key and Role vary at once and the two orderings disagree: "aaa" < "zzz" as a key,
|
||||
// but the row keyed "zzz" carries the role "aaa" - a Role-first mutant would put that edge
|
||||
// first; the rule (R-B: "page-row order, then (from, role, to)") puts it second.
|
||||
[Test]
|
||||
public void EdgesFor_OrdersByFromKeyBeforeRoleWhenBothVaryAtTheSameAnchor()
|
||||
{
|
||||
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(SurvivorAliasTarget) });
|
||||
var anchor = Row(registry, new SurvivorAliasTarget { Name = "anchor" }, "anchor-key", 1);
|
||||
var to = Row(registry, new SurvivorAliasTarget { Name = "to" }, "to-key", 2);
|
||||
var fromZ = Row(registry, new SurvivorAliasTarget { Name = "z" }, "zzz", 3);
|
||||
var fromA = Row(registry, new SurvivorAliasTarget { Name = "a" }, "aaa", 4);
|
||||
|
||||
var edgeFromZRoleA = new CultNetSelectionEvaluator.EdgeMatch(fromZ, "aaa", to, null);
|
||||
var edgeFromARoleZ = new CultNetSelectionEvaluator.EdgeMatch(fromA, "zzz", to, null);
|
||||
var full = new CultNetSelectionEvaluator.FullEvaluation
|
||||
{
|
||||
Edges = new[] { (edgeFromZRoleA, anchor), (edgeFromARoleZ, anchor) }
|
||||
};
|
||||
|
||||
var edges = CultNetSelectionEvaluator.EdgesFor(new[] { anchor }, full);
|
||||
|
||||
Assert.That(edges.Select(e => e.From.Key.Value), Is.EqualTo(new[] { "aaa", "zzz" }),
|
||||
"From.Key must decide before Role - a Role-first composition would put the \"zzz\"-keyed edge first, since its role \"aaa\" sorts first");
|
||||
Assert.That(edges.Select(e => e.Role), Is.EqualTo(new[] { "zzz", "aaa" }));
|
||||
}
|
||||
|
||||
[CultDocument("cultnet.selection-tests.survivor_page_order_citer", "cultnet.selection-tests.survivor_page_order_citer.v1")]
|
||||
[MessagePackObject(AllowPrivate = true)]
|
||||
public sealed class SurvivorPageOrderCiter
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ namespace GameCult.Networking.Tests
|
|||
{
|
||||
private static CultDocumentRegistry Registry() => CultDocumentRegistry.ForTypes(new[]
|
||||
{
|
||||
typeof(ParityLeafA), typeof(ParityLeafB), typeof(ParityCiter)
|
||||
typeof(ParityLeafA), typeof(ParityLeafB), typeof(ParityCiter), typeof(ParityCiterNarrow)
|
||||
});
|
||||
|
||||
private static CultNetSelectionEvaluator.Row Row(CultDocumentRegistry registry, object document, string key, long ordinal) =>
|
||||
|
|
@ -49,7 +49,11 @@ namespace GameCult.Networking.Tests
|
|||
// can resolve; HashAlias is what cultnet_rs::selection::schema_alias can resolve.
|
||||
private sealed record FixtureSchema(string SchemaId, string NameAlias, string HashAlias);
|
||||
|
||||
private sealed record FixtureReference(string Role, string TargetSchema, string TargetKey);
|
||||
// R-AF: no TargetSchema - the wire a stored reference actually travels on carries a bare
|
||||
// record key (GameCult.Caching.MessagePack.CultRecordRefFormatter<T> writes only
|
||||
// value.Key.Value), so the fixture stopped pretending otherwise. See the fixture file's
|
||||
// "//references" note.
|
||||
private sealed record FixtureReference(string Role, string TargetKey);
|
||||
|
||||
private sealed record FixtureRow(
|
||||
string Schema, string Key, long Ordinal,
|
||||
|
|
@ -90,7 +94,6 @@ namespace GameCult.Networking.Tests
|
|||
{
|
||||
references.Add(new FixtureReference(
|
||||
reference.GetProperty("role").GetString()!,
|
||||
reference.GetProperty("targetSchema").GetString()!,
|
||||
reference.GetProperty("targetKey").GetString()!));
|
||||
}
|
||||
}
|
||||
|
|
@ -130,6 +133,15 @@ namespace GameCult.Networking.Tests
|
|||
Design = new CultRecordRef<ParityMiddle>(new CultRecordKey(
|
||||
row.References.Single(reference => reference.Role == "Design").TargetKey))
|
||||
},
|
||||
// R-AF: citer_narrow's reference is declared to ParityLeafA only (one leaf, not
|
||||
// ParityMiddle) - the shared-key shape needs a role whose declared target excludes at
|
||||
// least one of the schemas that can share a record key.
|
||||
"citer_narrow" => new ParityCiterNarrow
|
||||
{
|
||||
Name = row.Key,
|
||||
NarrowRef = new CultRecordRef<ParityLeafA>(new CultRecordKey(
|
||||
row.References.Single(reference => reference.Role == "narrow_ref").TargetKey))
|
||||
},
|
||||
_ => throw new InvalidOperationException($"selection-vectors.fixture.json: unknown schema '{row.Schema}'")
|
||||
};
|
||||
|
||||
|
|
@ -144,6 +156,7 @@ namespace GameCult.Networking.Tests
|
|||
AssertSchemaIdMatches(registry, typeof(ParityLeafA), schemas["leaf_a"].SchemaId);
|
||||
AssertSchemaIdMatches(registry, typeof(ParityLeafB), schemas["leaf_b"].SchemaId);
|
||||
AssertSchemaIdMatches(registry, typeof(ParityCiter), schemas["citer"].SchemaId);
|
||||
AssertSchemaIdMatches(registry, typeof(ParityCiterNarrow), schemas["citer_narrow"].SchemaId);
|
||||
|
||||
var rows = fixtureRows.Select(row => Row(registry, BuildDocument(row), row.Key, row.Ordinal)).ToArray();
|
||||
return (registry, rows, schemas);
|
||||
|
|
@ -188,23 +201,38 @@ namespace GameCult.Networking.Tests
|
|||
Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = "ge", Number = "2233759.25" } }
|
||||
});
|
||||
// R-E: the exact real schema id, mirroring cultnet-rs's hop_by_role_cites_exact_id.
|
||||
// R-AF: Keys=["citer-1"] (the fixture's one row that actually cites a-eq via Design)
|
||||
// scopes this away from citer_narrow's rows, as hygiene rather than necessity -
|
||||
// landing R-AF's vectors surfaced a real R-T/R-W parity bug this exposed: Rust's
|
||||
// matches_citation used to resolve and R-W-check every declared reference a row
|
||||
// carries before filtering by the citation's own role, where C#'s
|
||||
// ReferenceMembers(descriptor, citation.Role) filters by role first
|
||||
// (CultNetSelectionEvaluator.cs:451-458). Fixed in cultnet_rs::selection::matches_citation
|
||||
// to filter first too, so narrow-citer-refused's out-of-target narrow_ref edge no longer
|
||||
// reaches a Role="Design" citation in either runtime - this vector keeps the Keys scope
|
||||
// anyway so it never depends on that.
|
||||
yield return ("hop_by_role_cites_exact_id", new CultNetSelection
|
||||
{
|
||||
Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = schemas["leaf_a"].SchemaId, RecordKey = "a-eq" } }
|
||||
Keys = new[] { "citer-1" },
|
||||
Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = schemas["leaf_a"].SchemaId, RecordKey = "a-eq" }, Role = "Design" }
|
||||
});
|
||||
// R-E: the same citation, but the target is named by its C#-resolvable alias
|
||||
// ("leaf_a.v9") rather than the exact id. CultNetSchemaAliasMatching's descriptor overload
|
||||
// (CultNetDatabase.cs:74-87, the only overload any `schemas`/`cites.target.schemaId` call
|
||||
// site in src/ uses) strips the trailing ".v9" and compares "leaf_a" to
|
||||
// ParityLeafA's descriptor.SchemaName - a real match here, in the reference. Rust's
|
||||
// schema_alias has no concept of a schema's human name at all - it only ever sees the raw
|
||||
// hash id (Row::schema_id()) - so this vector's expectedIds are real and non-empty here,
|
||||
// but when cultnet-rs's reader judges this same vector it cannot resolve "leaf_a.v9" to
|
||||
// anything (packages/cultnet-rs/src/selection.rs:452-459) and gets zero rows instead.
|
||||
// Confirmed cross-runtime defect; see the report.
|
||||
// ParityLeafA's descriptor.SchemaName - a real match here, in the reference. S-12
|
||||
// (2026-09-22) is not a leftover defect: cultnet_rs::selection::schema_alias now resolves
|
||||
// the same way, through Row::schema_name/RowSet::schema_name standing in for
|
||||
// CultDocumentDescriptor.SchemaName (packages/cultnet-rs/src/selection.rs:452-475), so
|
||||
// this vector's expectedIds are real and non-empty in both runtimes - the alias port made
|
||||
// the two DIFFERENT alias forms deliberate (see the fixture file's "//aliases" note)
|
||||
// rather than leaving this one unresolved on the Rust side.
|
||||
// R-AF: Role="Design" plus Keys=["citer-1"] for the same reason as
|
||||
// hop_by_role_cites_exact_id above.
|
||||
yield return ("cites_target_by_name_alias", new CultNetSelection
|
||||
{
|
||||
Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = schemas["leaf_a"].NameAlias, RecordKey = "a-eq" } }
|
||||
Keys = new[] { "citer-1" },
|
||||
Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = schemas["leaf_a"].NameAlias, RecordKey = "a-eq" }, Role = "Design" }
|
||||
});
|
||||
// R-E: `schemas` reached by the same name alias. Same one-sided-match caveat as above.
|
||||
yield return ("schemas_by_name_alias", new CultNetSelection { Schemas = new[] { schemas["leaf_a"].NameAlias } });
|
||||
|
|
@ -225,6 +253,45 @@ namespace GameCult.Networking.Tests
|
|||
Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "unicode" } } },
|
||||
Projection = CultNetSelectionProjections.Document
|
||||
});
|
||||
// R-AJ: a tied ordinal across two DIFFERENT schemas (kind=tie-schema is leaf_b/"zz-tie-
|
||||
// schema" and leaf_a/"aa-tie-schema") - the shape the row order's schemaId tiebreak
|
||||
// needs and the astral/BMP pair above never covered (both those rows are leaf_a). The
|
||||
// fixture's keys are anti-correlated with the real schema ids on purpose, so a
|
||||
// schemaId-first order and a recordKey-first order disagree: only the correct
|
||||
// composition puts leaf_b's row ("zz-tie-schema") first.
|
||||
yield return ("row_tiebreak_schema_id_before_record_key_at_tied_ordinal", new CultNetSelection
|
||||
{
|
||||
Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "tie-schema" } } }
|
||||
});
|
||||
// R-AF: "dup-key" carries two rows (leaf_a and leaf_b); citer_narrow's narrow_ref
|
||||
// targets leaf_a only. Keys scopes the citer candidate set to narrow-citer-accepted
|
||||
// alone, so this vector never touches narrow-citer-refused's bad edge (R-W resolves
|
||||
// every reference member unconditionally, so leaving both citer_narrow rows in one
|
||||
// candidate set would always refuse regardless of which edge the query named). One
|
||||
// rule, derived from the wire (docs/cultnet-selection-cut.md, R-AF): resolving "the
|
||||
// row this edge names" is by record key plus the declared leaf set, not an exact
|
||||
// (schema, key) pair, so this must accept in both runtimes and resolve to the leaf_a
|
||||
// row even though a leaf_b row shares the same key.
|
||||
yield return ("shared_key_narrow_ref_resolves_leaf_within_target", new CultNetSelection
|
||||
{
|
||||
Keys = new[] { "narrow-citer-accepted" },
|
||||
Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = schemas["leaf_a"].SchemaId, RecordKey = "dup-key" }, Role = "narrow_ref" }
|
||||
});
|
||||
}
|
||||
|
||||
// R-AF: the shared-key shape's other direction - the only row at the declared edge's key is
|
||||
// outside the reference's declared target, so evaluating the selection itself must refuse
|
||||
// (CultNetSelectionReferenceOutsideTargetException/SelectionRefusal::ReferenceOutsideTarget),
|
||||
// not merely return an empty page. Keys scopes the candidate set to narrow-citer-refused
|
||||
// alone (see the comment on shared_key_narrow_ref_resolves_leaf_within_target above).
|
||||
private static IEnumerable<(string Name, CultNetSelection Selection)> EvaluationRefusalCases(
|
||||
IReadOnlyDictionary<string, FixtureSchema> schemas)
|
||||
{
|
||||
yield return ("shared_key_narrow_ref_refuses_when_only_candidate_is_outside_target", new CultNetSelection
|
||||
{
|
||||
Keys = new[] { "narrow-citer-refused" },
|
||||
Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = schemas["leaf_a"].SchemaId, RecordKey = "narrow-only" }, Role = "narrow_ref" }
|
||||
});
|
||||
}
|
||||
|
||||
// Self's ruling, 2026-09-22 (commit 2 fix batch): door-refusal vectors. Never evaluated on
|
||||
|
|
@ -298,6 +365,30 @@ namespace GameCult.Networking.Tests
|
|||
continue;
|
||||
}
|
||||
|
||||
// R-AF: a small, separate signal from expectedRefusalField above - that one is the
|
||||
// door (Validate, never touches Select); this one names an evaluation-time typed
|
||||
// refusal Select itself must throw (currently only reference_outside_target, S18).
|
||||
// Deliberately narrower than R-Z/R-AH's page-bytes-and-code parity harness (not
|
||||
// this cut's job): it checks which typed refusal fires, nothing about wire bytes.
|
||||
if (vector.TryGetProperty("expectedEvaluationRefusal", out var evalRefusal) && evalRefusal.ValueKind != JsonValueKind.Null)
|
||||
{
|
||||
var expectedKind = evalRefusal.GetString();
|
||||
try
|
||||
{
|
||||
CultNetSelectionEvaluator.Select(registry, rows, selection, asOf);
|
||||
failures.Add($"{name}: expected Select to refuse ({expectedKind}) but it accepted the selection");
|
||||
}
|
||||
catch (CultNetSelectionReferenceOutsideTargetException) when (expectedKind == "reference_outside_target")
|
||||
{
|
||||
// expected
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
failures.Add($"{name}: expected Select to refuse with {expectedKind}, got {error.GetType().Name}: {error.Message}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
CultNetSelectionEvaluator.Evaluation evaluation;
|
||||
try
|
||||
{
|
||||
|
|
@ -375,6 +466,7 @@ namespace GameCult.Networking.Tests
|
|||
selectionMessagePackBase64 = Convert.ToBase64String(selectionBytes),
|
||||
asOf = 1UL,
|
||||
expectedRefusalField = (string?)null,
|
||||
expectedEvaluationRefusal = (string?)null,
|
||||
expectedIds = evaluation.Rows.Select(RowId).ToArray(),
|
||||
matched = (uint)evaluation.TotalMatched,
|
||||
hasNext = evaluation.NextCursor != null,
|
||||
|
|
@ -410,6 +502,32 @@ namespace GameCult.Networking.Tests
|
|||
selectionMessagePackBase64 = Convert.ToBase64String(selectionBytes),
|
||||
asOf = 1UL,
|
||||
expectedRefusalField = (string?)expectedField,
|
||||
expectedEvaluationRefusal = (string?)null,
|
||||
expectedIds = Array.Empty<string>(),
|
||||
matched = 0U,
|
||||
hasNext = false,
|
||||
expectedEdges = Array.Empty<object>()
|
||||
});
|
||||
}
|
||||
|
||||
// R-AF: evaluation-time refusal vectors. Never handed to Validate - the selection
|
||||
// decodes and passes the door; only Select itself refuses, and only once the shared-key
|
||||
// resolution rule actually runs. Asserted here before being committed, mirroring how
|
||||
// RefusalCases above asserts Validate refuses before its vectors are written.
|
||||
foreach (var (name, selection) in EvaluationRefusalCases(schemas))
|
||||
{
|
||||
Assert.Throws<CultNetSelectionReferenceOutsideTargetException>(
|
||||
() => CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1),
|
||||
$"{name} must be refused by Select before it can be committed as an evaluation-refusal vector");
|
||||
|
||||
var selectionBytes = MessagePackSerializer.Serialize(selection, CultNetSchemaMessageSerialization.Options);
|
||||
vectors.Add(new
|
||||
{
|
||||
name,
|
||||
selectionMessagePackBase64 = Convert.ToBase64String(selectionBytes),
|
||||
asOf = 1UL,
|
||||
expectedRefusalField = (string?)null,
|
||||
expectedEvaluationRefusal = (string?)"reference_outside_target",
|
||||
expectedIds = Array.Empty<string>(),
|
||||
matched = 0U,
|
||||
hasNext = false,
|
||||
|
|
@ -490,5 +608,22 @@ namespace GameCult.Networking.Tests
|
|||
[CultReference(typeof(ParityMiddle))]
|
||||
public CultRecordRef<ParityMiddle> Design;
|
||||
}
|
||||
|
||||
// R-AF: a citer whose declared reference target is one leaf only, so a record key shared
|
||||
// by a ParityLeafA row and a ParityLeafB row has exactly one row inside the declared
|
||||
// target and exactly one outside it - see the fixture file's "citer_narrow" schema.
|
||||
[CultDocument("citer_narrow", "cultnet.selection-parity.citer_narrow.v1")]
|
||||
[MessagePackObject(AllowPrivate = true)]
|
||||
public sealed class ParityCiterNarrow
|
||||
{
|
||||
[Key(0)]
|
||||
[CultName]
|
||||
public string Name = string.Empty;
|
||||
|
||||
[Key(1)]
|
||||
[CultIndex("narrow_ref")]
|
||||
[CultReference(typeof(ParityLeafA))]
|
||||
public CultRecordRef<ParityLeafA> NarrowRef;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue