Item 1 of the fix-batch-3 brief: check whether CultNetSelection.Validate refuses a cites.target.schemaId that CultNetSchemaAliasMatching cannot resolve. It already does (CultNetSelection.cs:389-394), landed by an earlier Hands pass on this branch (the "C# matrix Hands", after the parity table in the spec was captured) - so no production code changed here. What was still missing: SelectionParityVectorTests.RefusalCases() had a comment explaining why refuses_unmatched_cites_target_schema stayed "Rust-only", dated before that door check landed. The comment was stale and the vector was never added. Added it, plus refuses_cites_target_by_hash_shaped_alias (Self's ruling 2026-09-22: "the C# reference's alias rule is the rule" - C# only resolves a name-form alias, so a hash-shaped cites.target.schemaId is exactly the same unresolved-target case and must refuse at the same field). RefusalCases() now takes the fixture's schemas dictionary to reach schemas["leaf_a"].HashAlias. Verified on Yggdrasil: dotnet test tests/GameCult.Networking.Tests -c Release --filter FullyQualifiedName~SelectionParityVectorTests (the existing cross-runtime reader against the committed rs-written.json still passes, so this change does not depend on anything the Rust Hands owns). Regenerated contracts/cultnet/interop/selection-vectors.cs-written.json with CULTNET_WRITE_VECTORS=1: both new refusal vectors are present and both refuse at "cites.target.schemaId" as expected before being committed as vectors (WriteSelectionVectors's own Assert.Throws gate). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
494 lines
27 KiB
C#
494 lines
27 KiB
C#
#nullable enable
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text.Json;
|
|
using GameCult.Caching;
|
|
using MessagePack;
|
|
using NUnit.Framework;
|
|
|
|
namespace GameCult.Networking.Tests
|
|
{
|
|
// CultNet typed selection, Cut 1 commit 3/4 (docs/cultnet-selection-cut.md section 6/10, S12):
|
|
// the C#<->Rust parity vectors. New file; no existing C# source is modified.
|
|
//
|
|
// Self's ruling, 2026-09-22 (shared-fixture ruling, on top of the Cut 1 fix batch): both this
|
|
// writer and packages/cultnet-rs/tests/selection.rs's write_selection_vectors_for_the_reference
|
|
// build their rows from the one shared
|
|
// contracts/cultnet/interop/selection-vectors.fixture.json, keyed by each schema's real
|
|
// SHA-256 content-hash id, exactly as CultDocumentRegistry computes it for the fixture's own
|
|
// document types below - never by schema *name* as a stand-in id, which let every vector that
|
|
// carried an id decode on the wrong runtime's terms (Soul's whole-cut pass, F3: a name-as-id
|
|
// fixture masked the real alias-matching gap between CultNetSchemaAliasMatching and
|
|
// cultnet_rs::selection::schema_alias). AssertSchemaIdMatches below fails loudly, not silently,
|
|
// if this file's reflected shape ever drifts from the committed fixture.
|
|
public sealed class SelectionParityVectorTests
|
|
{
|
|
private static CultDocumentRegistry Registry() => CultDocumentRegistry.ForTypes(new[]
|
|
{
|
|
typeof(ParityLeafA), typeof(ParityLeafB), typeof(ParityCiter)
|
|
});
|
|
|
|
private static CultNetSelectionEvaluator.Row Row(CultDocumentRegistry registry, object document, string key, long ordinal) =>
|
|
new(registry.GetRequired(document.GetType()), new CultRecordKey(key), document, ordinal);
|
|
|
|
// ----------------------------------------------------------------------------------------
|
|
// The shared fixture: contracts/cultnet/interop/selection-vectors.fixture.json. Holds the
|
|
// real schema ids (and their unversioned aliases) and every row either runtime needs:
|
|
// astral/BMP-private-use keys (R-C), a float tie (R-D), a cites/cited pair, and a cites
|
|
// target reached by alias (R-E). Both writers build from it; neither hand-rolls its own row
|
|
// set any more.
|
|
// ----------------------------------------------------------------------------------------
|
|
|
|
// R-E: two alias forms, because the two runtimes' schema-alias matchers key off different
|
|
// attributes and no candidate resolves through both (see the fixture file's "//aliases"
|
|
// note and this file's report of the confirmed defect). NameAlias is what
|
|
// CultNetSchemaAliasMatching's descriptor overload (the only one any C# call site uses)
|
|
// 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);
|
|
|
|
private sealed record FixtureRow(
|
|
string Schema, string Key, long Ordinal,
|
|
IReadOnlyDictionary<string, string> Fields,
|
|
IReadOnlyList<FixtureReference> References);
|
|
|
|
private static string FixturePath() =>
|
|
Path.Combine(RepoRoot(), "contracts", "cultnet", "interop", "selection-vectors.fixture.json");
|
|
|
|
private static (IReadOnlyDictionary<string, FixtureSchema> Schemas, IReadOnlyList<FixtureRow> Rows) LoadFixture()
|
|
{
|
|
using var document = JsonDocument.Parse(File.ReadAllText(FixturePath()));
|
|
var root = document.RootElement;
|
|
|
|
var schemas = new Dictionary<string, FixtureSchema>(StringComparer.Ordinal);
|
|
foreach (var entry in root.GetProperty("schemas").EnumerateObject())
|
|
{
|
|
schemas[entry.Name] = new FixtureSchema(
|
|
entry.Value.GetProperty("schemaId").GetString()!,
|
|
entry.Value.GetProperty("nameAlias").GetString()!,
|
|
entry.Value.GetProperty("hashAlias").GetString()!);
|
|
}
|
|
|
|
var rows = new List<FixtureRow>();
|
|
foreach (var row in root.GetProperty("rows").EnumerateArray())
|
|
{
|
|
var fields = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
if (row.TryGetProperty("fields", out var fieldsElement))
|
|
{
|
|
foreach (var field in fieldsElement.EnumerateObject())
|
|
fields[field.Name] = field.Value.GetString()!;
|
|
}
|
|
|
|
var references = new List<FixtureReference>();
|
|
if (row.TryGetProperty("references", out var referencesElement))
|
|
{
|
|
foreach (var reference in referencesElement.EnumerateArray())
|
|
{
|
|
references.Add(new FixtureReference(
|
|
reference.GetProperty("role").GetString()!,
|
|
reference.GetProperty("targetSchema").GetString()!,
|
|
reference.GetProperty("targetKey").GetString()!));
|
|
}
|
|
}
|
|
|
|
rows.Add(new FixtureRow(
|
|
row.GetProperty("schema").GetString()!,
|
|
row.GetProperty("key").GetString()!,
|
|
row.GetProperty("ordinal").GetInt64(),
|
|
fields,
|
|
references));
|
|
}
|
|
|
|
return (schemas, rows);
|
|
}
|
|
|
|
private static void AssertSchemaIdMatches(CultDocumentRegistry registry, Type type, string expected)
|
|
{
|
|
var actual = registry.GetRequired(type).SchemaId;
|
|
Assert.That(actual, Is.EqualTo(expected),
|
|
$"{type.Name}'s real schema id drifted from contracts/cultnet/interop/selection-vectors.fixture.json - " +
|
|
"regenerate the fixture (its schemas.*.schemaId/alias fields) from CultDocumentRegistry before trusting these vectors.");
|
|
}
|
|
|
|
private static object BuildDocument(FixtureRow row) => row.Schema switch
|
|
{
|
|
"leaf_a" => new ParityLeafA
|
|
{
|
|
Name = row.Key, Kind = row.Fields["kind"], Mass = float.Parse(row.Fields["mass"], CultureInfo.InvariantCulture)
|
|
},
|
|
"leaf_b" => new ParityLeafB
|
|
{
|
|
Name = row.Key, Kind = row.Fields["kind"], Mass = float.Parse(row.Fields["mass"], CultureInfo.InvariantCulture)
|
|
},
|
|
"citer" => new ParityCiter
|
|
{
|
|
Name = row.Key,
|
|
Design = new CultRecordRef<ParityMiddle>(new CultRecordKey(
|
|
row.References.Single(reference => reference.Role == "Design").TargetKey))
|
|
},
|
|
_ => throw new InvalidOperationException($"selection-vectors.fixture.json: unknown schema '{row.Schema}'")
|
|
};
|
|
|
|
// The one row set: both SelectionVectorsWrittenByRustDecodeAndEvaluateIdenticallyInTheReference
|
|
// (reading rs-written) and WriteSelectionVectors (writing cs-written) build from it, so both
|
|
// committed vector files are regenerated together whenever the fixture changes.
|
|
private static (CultDocumentRegistry registry, CultNetSelectionEvaluator.Row[] rows, IReadOnlyDictionary<string, FixtureSchema> schemas) BuildFixture()
|
|
{
|
|
var registry = Registry();
|
|
var (schemas, fixtureRows) = LoadFixture();
|
|
|
|
AssertSchemaIdMatches(registry, typeof(ParityLeafA), schemas["leaf_a"].SchemaId);
|
|
AssertSchemaIdMatches(registry, typeof(ParityLeafB), schemas["leaf_b"].SchemaId);
|
|
AssertSchemaIdMatches(registry, typeof(ParityCiter), schemas["citer"].SchemaId);
|
|
|
|
var rows = fixtureRows.Select(row => Row(registry, BuildDocument(row), row.Key, row.Ordinal)).ToArray();
|
|
return (registry, rows, schemas);
|
|
}
|
|
|
|
private static IEnumerable<(string Name, CultNetSelection Selection)> Cases(IReadOnlyDictionary<string, FixtureSchema> schemas)
|
|
{
|
|
yield return ("any_of_plus_ge_conjunction", new CultNetSelection
|
|
{
|
|
Fields = new[]
|
|
{
|
|
new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "weapon" } },
|
|
new CultNetFieldPredicate { Index = "mass", Op = "ge", Number = "5" }
|
|
}
|
|
});
|
|
yield return ("descending_with_limit", new CultNetSelection { Descending = true, Limit = 3 });
|
|
yield return ("gt_numeric_boundary", new CultNetSelection
|
|
{
|
|
Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = "gt", Number = "1" } }
|
|
});
|
|
// R-E: the exact real schema id - matches on both runtimes' first (exact string)
|
|
// comparison branch, independent of the alias-matching gap below.
|
|
yield return ("incoming_exists_true", new CultNetSelection
|
|
{
|
|
Schemas = new[] { schemas["leaf_a"].SchemaId },
|
|
Cited = new CultNetIncoming { Role = "Design", Exists = true }
|
|
});
|
|
yield return ("incoming_negation", new CultNetSelection
|
|
{
|
|
Schemas = new[] { schemas["leaf_a"].SchemaId },
|
|
Cited = new CultNetIncoming { Role = "Design", Exists = false }
|
|
});
|
|
yield return ("shared_index_value_three_rows", new CultNetSelection
|
|
{
|
|
Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "shared" } } }
|
|
});
|
|
// R-D: the row's canonical rendering of 2233759.25f is its exact decimal expansion, not a
|
|
// shortest round-trip form that could round the tie the other way - ge against the exact
|
|
// value must match.
|
|
yield return ("float_tie_exact_ge", new CultNetSelection
|
|
{
|
|
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.
|
|
yield return ("hop_by_role_cites_exact_id", new CultNetSelection
|
|
{
|
|
Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = schemas["leaf_a"].SchemaId, RecordKey = "a-eq" } }
|
|
});
|
|
// 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.
|
|
yield return ("cites_target_by_name_alias", new CultNetSelection
|
|
{
|
|
Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = schemas["leaf_a"].NameAlias, RecordKey = "a-eq" } }
|
|
});
|
|
// 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 } });
|
|
// R-C: BmpPrivateUseKey (U+E000) sorts before AstralKey (U+1F602) in code-point order,
|
|
// despite AstralKey's leading UTF-16 code unit (0xD83D) being numerically smaller.
|
|
yield return ("astral_key_code_point_order", new CultNetSelection
|
|
{
|
|
Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "unicode" } } }
|
|
});
|
|
// R-I: page bytes under each projection, over the same match set.
|
|
yield return ("header_projection", new CultNetSelection
|
|
{
|
|
Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "unicode" } } },
|
|
Projection = CultNetSelectionProjections.Header
|
|
});
|
|
yield return ("document_projection", new CultNetSelection
|
|
{
|
|
Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "unicode" } } },
|
|
Projection = CultNetSelectionProjections.Document
|
|
});
|
|
}
|
|
|
|
// Self's ruling, 2026-09-22 (commit 2 fix batch): door-refusal vectors. Never evaluated on
|
|
// either side - only decoded and handed to Validate/validate, which must refuse at exactly
|
|
// the named field. Mirrors packages/cultnet-rs/tests/selection.rs's refusal_cases.
|
|
//
|
|
// refuses_unmatched_cites_target_schema and refuses_cites_target_by_hash_shaped_alias now
|
|
// land here too: the C# matrix Hands pass added the door check
|
|
// (CultNetSelection.cs:389-394) that CultNetSelection.Validate refuses a cites.target.schemaId
|
|
// that CultNetSchemaAliasMatching cannot resolve to any declared schema. Under Self's ruling
|
|
// 2026-09-22 ("the C# reference's alias rule is the rule"), C# only ever resolves a name-form
|
|
// alias, never a hash-shaped one, so a hash-shaped cites.target.schemaId is exactly such an
|
|
// unresolved target and is refused at the same field.
|
|
private static IEnumerable<(string Name, CultNetSelection Selection, string ExpectedField)> RefusalCases(
|
|
IReadOnlyDictionary<string, FixtureSchema> schemas)
|
|
{
|
|
yield return ("refuses_empty_schemas_list", new CultNetSelection { Schemas = Array.Empty<string>() }, "schemas");
|
|
yield return ("refuses_blank_key_entry", new CultNetSelection { Keys = new[] { "a-eq", " " } }, "keys");
|
|
yield return ("refuses_unmatched_cites_target_schema", new CultNetSelection
|
|
{
|
|
Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = "sha256:not-a-declared-schema", RecordKey = "a-eq" } }
|
|
}, "cites.target.schemaId");
|
|
yield return ("refuses_cites_target_by_hash_shaped_alias", new CultNetSelection
|
|
{
|
|
Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = schemas["leaf_a"].HashAlias, RecordKey = "a-eq" } }
|
|
}, "cites.target.schemaId");
|
|
}
|
|
|
|
// R-E: id, not name - a row's cross-runtime identity is its real schema id plus its key, the
|
|
// same shape cultnet-rs's row_id() produces.
|
|
private static string RowId(CultNetSelectionEvaluator.Row row) => $"{row.Descriptor.SchemaId}/{row.Key.Value}";
|
|
|
|
// Vectors written by Rust (packages/cultnet-rs/tests/selection.rs,
|
|
// write_selection_vectors_for_the_reference) decode and evaluate identically here.
|
|
[Test]
|
|
public void SelectionVectorsWrittenByRustDecodeAndEvaluateIdenticallyInTheReference()
|
|
{
|
|
var path = Path.Combine(RepoRoot(), "contracts", "cultnet", "interop", "selection-vectors.rs-written.json");
|
|
if (!File.Exists(path))
|
|
Assert.Inconclusive($"{path} is missing - run cargo test -p cultnet-rs --test selection with CULTNET_WRITE_VECTORS=1 first.");
|
|
|
|
var (registry, rows, _) = BuildFixture();
|
|
using var document = JsonDocument.Parse(File.ReadAllText(path));
|
|
var vectors = document.RootElement.GetProperty("vectors");
|
|
Assert.That(vectors.GetArrayLength(), Is.GreaterThan(0), "the vector file must carry at least one vector");
|
|
|
|
// Self's ruling, 2026-09-22 (shared-fixture ruling): a real parity defect is reported in
|
|
// full, not truncated by whichever vector happened to come first in the file - every
|
|
// vector is judged and every mismatch collected before the test fails once, mirroring
|
|
// packages/cultnet-rs/tests/selection.rs's own reader.
|
|
var failures = new List<string>();
|
|
foreach (var vector in vectors.EnumerateArray())
|
|
{
|
|
var name = vector.GetProperty("name").GetString();
|
|
var selectionBytes = Convert.FromBase64String(vector.GetProperty("selectionMessagePackBase64").GetString()!);
|
|
var selection = MessagePackSerializer.Deserialize<CultNetSelection>(selectionBytes, CultNetSchemaMessageSerialization.Options);
|
|
var asOf = vector.GetProperty("asOf").GetUInt64();
|
|
|
|
if (vector.TryGetProperty("expectedRefusalField", out var refusalField) && refusalField.ValueKind != JsonValueKind.Null)
|
|
{
|
|
try
|
|
{
|
|
selection.Validate(registry.AllDescriptors.ToArray());
|
|
failures.Add($"{name}: expected refusal at '{refusalField.GetString()}' but the reference accepted it");
|
|
}
|
|
catch (CultNetSelectionInvalidException error)
|
|
{
|
|
if (error.Field != refusalField.GetString())
|
|
failures.Add($"{name}: refusal field - expected '{refusalField.GetString()}', got '{error.Field}'");
|
|
}
|
|
continue;
|
|
}
|
|
|
|
CultNetSelectionEvaluator.Evaluation evaluation;
|
|
try
|
|
{
|
|
evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf);
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
var expectedIds = vector.GetProperty("expectedIds").EnumerateArray().Select(e => e.GetString()).ToArray();
|
|
failures.Add($"{name}: expected real evaluation (expectedIds=[{string.Join(", ", expectedIds)}]) but Select threw {error.GetType().Name}: {error.Message}");
|
|
continue;
|
|
}
|
|
|
|
var expectedIdsList = vector.GetProperty("expectedIds").EnumerateArray().Select(e => e.GetString()).ToArray();
|
|
var actualIds = evaluation.Rows.Select(RowId).ToArray();
|
|
if (!actualIds.SequenceEqual(expectedIdsList))
|
|
failures.Add($"{name}: row ids - expected [{string.Join(", ", expectedIdsList)}], got [{string.Join(", ", actualIds)}]");
|
|
|
|
var expectedMatched = vector.GetProperty("matched").GetUInt32();
|
|
if ((uint)evaluation.TotalMatched != expectedMatched)
|
|
failures.Add($"{name}: matched - expected {expectedMatched}, got {evaluation.TotalMatched}");
|
|
|
|
var expectedHasNext = vector.GetProperty("hasNext").GetBoolean();
|
|
if ((evaluation.NextCursor != null) != expectedHasNext)
|
|
failures.Add($"{name}: hasNext - expected {expectedHasNext}, got {evaluation.NextCursor != null}");
|
|
|
|
var expectedEdges = vector.GetProperty("expectedEdges").EnumerateArray().ToArray();
|
|
if (evaluation.Edges.Count != expectedEdges.Length)
|
|
{
|
|
failures.Add($"{name}: edge count - expected {expectedEdges.Length}, got {evaluation.Edges.Count}");
|
|
continue;
|
|
}
|
|
for (var i = 0; i < expectedEdges.Length; i++)
|
|
{
|
|
var edge = evaluation.Edges[i];
|
|
var expectedEdge = expectedEdges[i];
|
|
var expectedPayload = expectedEdge.GetProperty("payloadBase64");
|
|
var actualPayload = edge.Payload == null ? null : Convert.ToBase64String((byte[])edge.Payload);
|
|
var payloadMatches = expectedPayload.ValueKind == JsonValueKind.Null
|
|
? actualPayload == null
|
|
: actualPayload == expectedPayload.GetString();
|
|
if (RowId(edge.From) != expectedEdge.GetProperty("fromId").GetString() ||
|
|
edge.Role != expectedEdge.GetProperty("role").GetString() ||
|
|
RowId(edge.To) != expectedEdge.GetProperty("toId").GetString() ||
|
|
!payloadMatches)
|
|
{
|
|
failures.Add($"{name}: edge[{i}] mismatch");
|
|
}
|
|
}
|
|
}
|
|
|
|
Assert.That(failures, Is.Empty,
|
|
$"{failures.Count} of {vectors.GetArrayLength()} vectors disagreed with the reference:\n" + string.Join("\n", failures));
|
|
}
|
|
|
|
// Writes contracts/cultnet/interop/selection-vectors.cs-written.json for Rust's
|
|
// selection_vectors_written_by_the_reference_decode_and_evaluate_identically_in_rust to
|
|
// judge. Gated the same way the QUIC campaign's C#-written vectors are
|
|
// (docs/typescript-quic-realtime-cut.md:15-21): committed, not regenerated on every run.
|
|
[Test]
|
|
public void WriteSelectionVectors()
|
|
{
|
|
if (Environment.GetEnvironmentVariable("CULTNET_WRITE_VECTORS") != "1")
|
|
Assert.Ignore("Set CULTNET_WRITE_VECTORS=1 to (re)write the committed vector file.");
|
|
|
|
var (registry, rows, schemas) = BuildFixture();
|
|
var vectors = new List<object>();
|
|
foreach (var (name, selection) in Cases(schemas))
|
|
{
|
|
var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1);
|
|
var selectionBytes = MessagePackSerializer.Serialize(selection, CultNetSchemaMessageSerialization.Options);
|
|
|
|
vectors.Add(new
|
|
{
|
|
name,
|
|
selectionMessagePackBase64 = Convert.ToBase64String(selectionBytes),
|
|
asOf = 1UL,
|
|
expectedRefusalField = (string?)null,
|
|
expectedIds = evaluation.Rows.Select(RowId).ToArray(),
|
|
matched = (uint)evaluation.TotalMatched,
|
|
hasNext = evaluation.NextCursor != null,
|
|
expectedEdges = evaluation.Edges.Select(edge => new
|
|
{
|
|
fromId = RowId(edge.From),
|
|
role = edge.Role,
|
|
toId = RowId(edge.To),
|
|
// None of this file's cases hop through a many-dictionary reference (the only
|
|
// shape D11 gives a non-null payload), so this is always null here;
|
|
// packages/cultnet-rs/tests/selection.rs's
|
|
// dictionary_reference_entries_keep_distinct_payload_bytes pins the
|
|
// payload-carrying case directly against Row::references() instead.
|
|
payloadBase64 = edge.Payload == null ? null : Convert.ToBase64String((byte[])edge.Payload)
|
|
}).ToArray()
|
|
});
|
|
}
|
|
|
|
// Self's ruling, 2026-09-22 (commit 2 fix batch): door-refusal vectors. Never
|
|
// evaluated on either side - only decoded and handed to Validate, which must refuse
|
|
// at exactly the named field.
|
|
foreach (var (name, selection, expectedField) in RefusalCases(schemas))
|
|
{
|
|
var error = Assert.Throws<CultNetSelectionInvalidException>(
|
|
() => selection.Validate(registry.AllDescriptors.ToArray()),
|
|
$"{name} must be refused by the reference before it can be committed as a refusal vector");
|
|
Assert.That(error!.Field, Is.EqualTo(expectedField), $"{name}: refusal field");
|
|
|
|
var selectionBytes = MessagePackSerializer.Serialize(selection, CultNetSchemaMessageSerialization.Options);
|
|
vectors.Add(new
|
|
{
|
|
name,
|
|
selectionMessagePackBase64 = Convert.ToBase64String(selectionBytes),
|
|
asOf = 1UL,
|
|
expectedRefusalField = (string?)expectedField,
|
|
expectedIds = Array.Empty<string>(),
|
|
matched = 0U,
|
|
hasNext = false,
|
|
expectedEdges = Array.Empty<object>()
|
|
});
|
|
}
|
|
|
|
var directory = Path.Combine(RepoRoot(), "contracts", "cultnet", "interop");
|
|
Directory.CreateDirectory(directory);
|
|
var json = JsonSerializer.Serialize(new { vectors }, new JsonSerializerOptions { WriteIndented = true });
|
|
File.WriteAllText(Path.Combine(directory, "selection-vectors.cs-written.json"), json);
|
|
}
|
|
|
|
// Dumps each fixture schema's real, reference-computed id (and lets a maintainer derive the
|
|
// unversioned alias by stripping the trailing ".v1") so
|
|
// contracts/cultnet/interop/selection-vectors.fixture.json can be regenerated after a shape
|
|
// change. Not part of the parity contract itself.
|
|
[Test]
|
|
public void DumpFixtureSchemaIds()
|
|
{
|
|
if (Environment.GetEnvironmentVariable("CULTNET_DUMP_SCHEMA_IDS") != "1")
|
|
Assert.Ignore("Set CULTNET_DUMP_SCHEMA_IDS=1 to print the fixture's real schema ids.");
|
|
|
|
var registry = Registry();
|
|
Console.WriteLine("SCHEMA_ID leaf_a=" + registry.GetRequired(typeof(ParityLeafA)).SchemaId);
|
|
Console.WriteLine("SCHEMA_ID leaf_b=" + registry.GetRequired(typeof(ParityLeafB)).SchemaId);
|
|
Console.WriteLine("SCHEMA_ID citer=" + registry.GetRequired(typeof(ParityCiter)).SchemaId);
|
|
}
|
|
|
|
private static string RepoRoot()
|
|
{
|
|
var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory);
|
|
while (directory != null && !File.Exists(Path.Combine(directory.FullName, "CultLib.sln")))
|
|
directory = directory.Parent;
|
|
return directory?.FullName ?? throw new InvalidOperationException("CultLib.sln was not found above the test directory.");
|
|
}
|
|
|
|
// A conceptual abstract middle (no [CultDocument]), matching Design's declared target so
|
|
// D9's leaf-set resolution reaches both ParityLeafA and ParityLeafB. SchemaName is the
|
|
// literal fixture-identity string ("leaf_a", "leaf_b", "citer") the vectors compare by -
|
|
// see this file's header comment.
|
|
public abstract class ParityMiddle
|
|
{
|
|
[Key(0)]
|
|
[CultName]
|
|
public string Name = string.Empty;
|
|
|
|
[Key(1)]
|
|
[CultIndex("kind")]
|
|
public string Kind = string.Empty;
|
|
|
|
[Key(2)]
|
|
[CultIndex("mass")]
|
|
public float Mass;
|
|
}
|
|
|
|
[CultDocument("leaf_a", "cultnet.selection-parity.leaf_a.v1")]
|
|
[MessagePackObject(AllowPrivate = true)]
|
|
public sealed class ParityLeafA : ParityMiddle
|
|
{
|
|
}
|
|
|
|
[CultDocument("leaf_b", "cultnet.selection-parity.leaf_b.v1")]
|
|
[MessagePackObject(AllowPrivate = true)]
|
|
public sealed class ParityLeafB : ParityMiddle
|
|
{
|
|
}
|
|
|
|
[CultDocument("citer", "cultnet.selection-parity.citer.v1")]
|
|
[MessagePackObject(AllowPrivate = true)]
|
|
public sealed class ParityCiter
|
|
{
|
|
[Key(0)]
|
|
[CultName]
|
|
public string Name = string.Empty;
|
|
|
|
[Key(1)]
|
|
[CultReference(typeof(ParityMiddle))]
|
|
public CultRecordRef<ParityMiddle> Design;
|
|
}
|
|
}
|
|
}
|