CultLib/tests/GameCult.Networking.Tests/CarrierVectorTests.cs
Libby 51941fa61b Carrier vectors: drop the derived hasHop member from the reference's expected JSON
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 01:39:57 +00:00

178 lines
9.2 KiB
C#

#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using MessagePack;
using NUnit.Framework;
namespace GameCult.Networking.Tests
{
// The C# reference's own bytes for the selection/v1 message carriers and the cultnet.error.v0
// code/details shape, written to contracts/cultnet/interop/carrier-vectors.cs-written.json for
// the runtimes that decode and re-encode them (packages/cultnet-ts reads it; a Rust or Python
// reader may follow). Each vector carries the whole message's MessagePack bytes plus `expected`,
// the reference's own camelCase JSON of the same object (byte[] as base64), so a reader judges
// against what the reference serialized rather than a second hand-written spec.
//
// Gated like SelectionParityVectorTests: CULTNET_WRITE_VECTORS=1 rewrites the committed file;
// otherwise the committed bytes are compared with a fresh serialization so reference drift fails
// here instead of in a reader.
public sealed class CarrierVectorTests
{
private const string LeafA = "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff";
private const string CiterId = "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc";
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
private static CultNetSelection FullSelection() => new()
{
Schemas = new[] { "leaf_a.v9", LeafA },
Keys = new[] { "a-eq", "\U0001F600-row" },
Fields = new[]
{
new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "weapon", "tool" } },
new CultNetFieldPredicate { Index = "mass", Op = "ge", Number = "-2233759.25" }
},
Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = LeafA, RecordKey = "a-eq" }, Role = "Design" },
Cited = new CultNetIncoming { Role = "Design", Exists = false },
Projection = CultNetSelectionProjections.Document,
Descending = true,
Limit = 200,
Cursor = "opaque-cursor"
};
private static CultNetRawDocumentHeader Header(string key, params string[] tags) => new()
{
SchemaId = LeafA,
SchemaName = "leaf_a",
SchemaVersion = "1",
SchemaContentHash = "abc",
RecordKey = key,
StoredAt = "1970-01-01T00:00:00.000Z",
SourceRuntimeId = "rt",
SourceAgentId = "agent",
SourceRole = "role",
Tags = tags.Length == 0 ? null : tags
};
private static IEnumerable<(string Name, ICultNetSchemaMessage Message)> Cases()
{
yield return ("snapshot_request_v1_defaults", new CultNetSnapshotRequestV1Message { MessageId = "m" });
yield return ("snapshot_request_v1_full_selection_and_shard", new CultNetSnapshotRequestV1Message
{
MessageId = "m-full", Selection = FullSelection(), ShardId = "shard-1", ShardEpoch = 7
});
yield return ("database_subscribe_v1_defaults", new CultNetDatabaseSubscribeV1Message { MessageId = "m", SubscriptionId = "sub" });
yield return ("database_subscribe_v1_full", new CultNetDatabaseSubscribeV1Message
{
MessageId = "m-full", SubscriptionId = "sub-full", Selection = FullSelection(), IncludeSnapshot = false,
ConsumerRuntimeId = "consumer", BodyIds = new[] { "body-a" }, SupportedBodyTransports = new[] { "rudp", "quic" }
});
yield return ("snapshot_response_raw_v1_headers", new CultNetSnapshotResponseRawV1Message
{
MessageId = "m", Matched = 2, AsOf = 5, Next = "next-cursor",
Headers = new[] { Header("a-eq"), Header("a-lo", "alpha", "beta") },
ShardId = "shard-1", ShardEpoch = 3, ShardLogSequence = 41
});
yield return ("snapshot_response_raw_v1_documents_and_edges", new CultNetSnapshotResponseRawV1Message
{
MessageId = "m", Matched = 1, AsOf = 5,
Documents = new[]
{
new CultNetRawDocumentRecord { SchemaId = LeafA, RecordKey = "a-eq", StoredAt = "1970-01-01T00:00:00.000Z", Payload = new byte[] { 0x81, 0xA1, 0x6B, 0x01 } }
},
Edges = new[]
{
new CultNetEdge
{
From = new CultNetRecordRef { SchemaId = CiterId, RecordKey = "citer-1" }, Role = "Design",
To = new CultNetRecordRef { SchemaId = LeafA, RecordKey = "a-eq" }
},
new CultNetEdge
{
From = new CultNetRecordRef { SchemaId = CiterId, RecordKey = "citer-1" }, Role = "Design",
To = new CultNetRecordRef { SchemaId = LeafA, RecordKey = "a-eq" },
PayloadEncoding = "messagepack", Payload = new byte[] { 1, 2, 3 }
}
}
});
yield return ("error_plain", new CultNetErrorMessage { Error = "boom" });
yield return ("error_selection_invalid", CultNetErrorMessage.ForSelectionInvalid(
new CultNetSelectionInvalidException("fields[0].index", "kind", "fields[0].index \"kind\" is not declared.")));
yield return ("error_cursor_stale", new CultNetErrorMessage
{
Error = "cursor_stale: stale", Code = "cursor_stale", Details = new CultNetErrorDetails { AsOf = 3, Current = 9 }
});
yield return ("error_cursor_invalid_no_details", new CultNetErrorMessage { Error = "cursor_invalid: bad", Code = "cursor_invalid" });
yield return ("error_write_not_authorized", new CultNetErrorMessage
{
Error = "write_not_authorized", Code = "write_not_authorized", Details = new CultNetErrorDetails { Field = "recordKey", Value = "k" }
});
}
private static object Vector(string name, ICultNetSchemaMessage message)
{
var bytes = MessagePackSerializer.Serialize(message.GetType(), message, CultNetSchemaMessageSerialization.Options);
return new
{
name,
schemaVersion = message.SchemaVersion,
messagePackBase64 = Convert.ToBase64String(bytes),
expected = WithoutDerivedMembers(JsonSerializer.SerializeToNode(message, message.GetType(), JsonOptions))
};
}
// CultNetSelection.HasHop is an [IgnoreMember] convenience, not wire; System.Text.Json would emit it.
private static JsonNode? WithoutDerivedMembers(JsonNode? node)
{
switch (node)
{
case JsonObject obj:
obj.Remove("hasHop");
foreach (var child in obj.Select(pair => pair.Value).ToArray()) WithoutDerivedMembers(child);
break;
case JsonArray array:
foreach (var child in array) WithoutDerivedMembers(child);
break;
}
return node;
}
private static string VectorPath()
{
var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory);
while (directory != null && !File.Exists(Path.Combine(directory.FullName, "CultLib.sln")))
directory = directory.Parent;
var root = directory?.FullName ?? throw new InvalidOperationException("CultLib.sln was not found above the test directory.");
return Path.Combine(root, "contracts", "cultnet", "interop", "carrier-vectors.cs-written.json");
}
[Test]
public void WriteCarrierVectors()
{
if (Environment.GetEnvironmentVariable("CULTNET_WRITE_VECTORS") != "1")
Assert.Ignore("Set CULTNET_WRITE_VECTORS=1 to (re)write the committed vector file.");
var vectors = Cases().Select(c => Vector(c.Name, c.Message)).ToArray();
File.WriteAllText(VectorPath(), JsonSerializer.Serialize(new { vectors }, new JsonSerializerOptions { WriteIndented = true }));
}
[Test]
public void CommittedCarrierVectorsMatchAFreshSerialization()
{
using var committed = JsonDocument.Parse(File.ReadAllText(VectorPath()));
var byName = committed.RootElement.GetProperty("vectors").EnumerateArray()
.ToDictionary(v => v.GetProperty("name").GetString()!, v => v.GetProperty("messagePackBase64").GetString()!);
Assert.That(byName.Count, Is.EqualTo(Cases().Count()), "the committed file must carry exactly the cases this writer produces");
foreach (var (name, message) in Cases())
{
var fresh = Convert.ToBase64String(MessagePackSerializer.Serialize(message.GetType(), message, CultNetSchemaMessageSerialization.Options));
Assert.That(byName[name], Is.EqualTo(fresh), $"{name}: the committed bytes drifted from the reference serializer");
}
}
}
}