contracts/cultmesh/cdn-record-key-vectors.json is written by the reference under CULTNET_WRITE_VECTORS=1 (19 artifact keys, 7 chunk keys; ids as UTF-16 unit arrays so a lone surrogate survives). The judge compares text with line endings normalised so a Windows checkout and a Linux one agree. Builds. Co-Authored-By below. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
385 lines
19 KiB
C#
385 lines
19 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text.Json;
|
|
using System.Threading.Tasks;
|
|
using FluentAssertions;
|
|
using GameCult.Caching;
|
|
using GameCult.Networking;
|
|
using MessagePack;
|
|
using NUnit.Framework;
|
|
|
|
namespace GameCult.Mesh.Tests;
|
|
|
|
/// <summary>
|
|
/// Content-plane parity vectors shared with <c>packages/cultnet-rs/tests/content.rs</c>. Each runtime
|
|
/// writes its own file under <c>contracts/cultmesh</c> and judges both. The chunk answers in these
|
|
/// files are produced by <see cref="CultMeshLegacyRudpContentServer"/> itself, so the failure
|
|
/// spellings are the reference's own bytes.
|
|
/// </summary>
|
|
[TestFixture]
|
|
public sealed class CultMeshContentVectorTests
|
|
{
|
|
private const int BodyLength = 1_315_551;
|
|
private const int ChunkSize = 262_144;
|
|
private const string CreatedAtUtc = "2026-09-22T00:00:00.0000000+00:00";
|
|
private const string ArtifactId = "eureka/content-vector";
|
|
|
|
private sealed record Vector(string Label, string Kind, byte[] Bytes);
|
|
|
|
[Test]
|
|
public void WriteContentVectors()
|
|
{
|
|
if (Environment.GetEnvironmentVariable("CULTNET_WRITE_VECTORS") != "1")
|
|
Assert.Ignore("Set CULTNET_WRITE_VECTORS=1 to (re)write the committed vector file.");
|
|
File.WriteAllText(VectorPath("content-vectors.cs-written.json"), ToJson(BuildVectors(out _, out _)));
|
|
File.WriteAllText(VectorPath("cdn-record-key-vectors.json"), RecordKeyVectorsToJson(BuildRecordKeyVectors()));
|
|
}
|
|
|
|
[Test]
|
|
public void CommittedCsVectorsEqualAFreshEncode()
|
|
{
|
|
var fresh = BuildVectors(out _, out _);
|
|
var committed = ReadVectors("content-vectors.cs-written.json");
|
|
committed.Select(v => (v.Label, v.Kind)).Should().Equal(fresh.Select(v => (v.Label, v.Kind)));
|
|
foreach (var (expected, actual) in fresh.Zip(committed))
|
|
actual.Bytes.Should().Equal(expected.Bytes, $"vector '{actual.Label}' drifted from the reference's encode");
|
|
}
|
|
|
|
[Test]
|
|
public void RustVectorsDecodeInTheReference()
|
|
{
|
|
BuildVectors(out var cache, out var messages);
|
|
var rust = ReadVectors("content-vectors.rs-written.json");
|
|
rust.Should().NotBeEmpty();
|
|
rust.Select(v => v.Label).Should().Equal(messages.Keys, "both runtimes write the same labelled vectors");
|
|
foreach (var vector in rust)
|
|
{
|
|
var expected = messages[vector.Label];
|
|
switch (vector.Kind)
|
|
{
|
|
case "request":
|
|
var request = MessagePackSerializer.Deserialize<CultMeshContentChunkRequestMessage>(vector.Bytes, CultNetSchemaMessageSerialization.Options);
|
|
request.Should().BeEquivalentTo((CultMeshContentChunkRequestMessage)expected);
|
|
Reencode(request).Should().Equal(vector.Bytes, vector.Label);
|
|
break;
|
|
case "response":
|
|
var response = MessagePackSerializer.Deserialize<CultMeshContentChunkResponseMessage>(vector.Bytes, CultNetSchemaMessageSerialization.Options);
|
|
response.Should().BeEquivalentTo((CultMeshContentChunkResponseMessage)expected);
|
|
Reencode(response).Should().Equal(vector.Bytes, vector.Label);
|
|
break;
|
|
case "manifest":
|
|
var manifest = MessagePackSerializer.Deserialize<CultMeshCdnArtifactManifest>(vector.Bytes, CultNetSchemaMessageSerialization.Options);
|
|
manifest.Should().BeEquivalentTo((CultMeshCdnArtifactManifest)expected);
|
|
Reencode(manifest).Should().Equal(vector.Bytes, vector.Label);
|
|
break;
|
|
default:
|
|
Assert.Fail($"unknown vector kind '{vector.Kind}'");
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Rust's answers to Rust's requests must be what the reference answers to the same bytes.
|
|
foreach (var vector in rust.Where(v => v.Label.EndsWith(".request", StringComparison.Ordinal)))
|
|
{
|
|
var request = MessagePackSerializer.Deserialize<CultMeshContentChunkRequestMessage>(vector.Bytes, CultNetSchemaMessageSerialization.Options);
|
|
var rustAnswer = rust.Single(v => v.Label == vector.Label[..^".request".Length] + ".response");
|
|
Reencode(Answer(cache, request)).Should().Equal(rustAnswer.Bytes, vector.Label);
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void PackArtifactOfTheFixtureBodyEqualsTheFixtureManifest()
|
|
{
|
|
var artifact = CultMeshCdn.PackArtifact(ArtifactId, Body(), Options());
|
|
var committed = ReadVectors("content-vectors.cs-written.json").Single(v => v.Label == "manifest");
|
|
Reencode(artifact.Manifest).Should().Equal(committed.Bytes);
|
|
var rust = ReadVectors("content-vectors.rs-written.json").Single(v => v.Label == "manifest");
|
|
rust.Bytes.Should().Equal(committed.Bytes, "Rust's pack_content and the reference pack the same bytes to the same manifest");
|
|
}
|
|
|
|
[Test]
|
|
public void CommittedRecordKeyVectorsEqualAFreshDerivation()
|
|
{
|
|
var path = VectorPath("cdn-record-key-vectors.json");
|
|
File.Exists(path).Should().BeTrue($"{path} is written by this fixture under CULTNET_WRITE_VECTORS=1");
|
|
Lines(File.ReadAllText(path)).Should().Be(Lines(RecordKeyVectorsToJson(BuildRecordKeyVectors())), "the key grammar drifted from the committed vectors");
|
|
}
|
|
|
|
[Test]
|
|
public void TypeScriptManifestVectorsMatchTheReference()
|
|
{
|
|
var path = VectorPath("content-vectors.ts-written.json");
|
|
File.Exists(path).Should().BeTrue($"{path} is written by scripts/write-cultmesh-content-vectors.mjs");
|
|
using var document = JsonDocument.Parse(File.ReadAllText(path));
|
|
var vectors = document.RootElement.GetProperty("vectors").EnumerateArray().ToList();
|
|
vectors.Should().NotBeEmpty();
|
|
foreach (var vector in vectors)
|
|
{
|
|
var label = vector.GetProperty("label").GetString()!;
|
|
var bytes = Convert.FromBase64String(vector.GetProperty("messagePackBase64").GetString()!);
|
|
var manifest = MessagePackSerializer.Deserialize<CultMeshCdnArtifactManifest>(bytes, CultNetSchemaMessageSerialization.Options);
|
|
Reencode(manifest).Should().Equal(bytes, $"{label}: the reference reads and rewrites the TypeScript bytes unchanged");
|
|
CultMeshCdnArtifactManifest.CreateRecordKey(manifest).Value.Should().Be(vector.GetProperty("recordKey").GetString(), label);
|
|
|
|
var input = vector.GetProperty("input");
|
|
var body = input.GetProperty("body");
|
|
var multiplier = body.GetProperty("multiplier").GetInt64();
|
|
var modulus = body.GetProperty("modulus").GetInt64();
|
|
var packed = CultMeshCdn.PackArtifact(
|
|
input.GetProperty("artifactId").GetString()!,
|
|
Enumerable.Range(0, body.GetProperty("length").GetInt32()).Select(i => (byte)(i * multiplier % modulus)).ToArray(),
|
|
new CultMeshCdnPackOptions
|
|
{
|
|
Kind = input.GetProperty("kind").GetString()!,
|
|
Version = input.GetProperty("version").GetString()!,
|
|
MimeType = input.GetProperty("mimeType").GetString()!,
|
|
CreatedAtUtc = input.GetProperty("createdAtUtc").GetString()!,
|
|
ChunkSizeBytes = input.GetProperty("chunkSizeBytes").GetInt32(),
|
|
Tags = input.GetProperty("tags").EnumerateArray().Select(t => t.GetString()!).ToArray(),
|
|
Metadata = input.GetProperty("metadata").EnumerateArray()
|
|
.Aggregate(new Dictionary<string, string>(), (map, pair) =>
|
|
{
|
|
map[pair[0].GetString()!] = pair[1].GetString()!;
|
|
return map;
|
|
})
|
|
});
|
|
Reencode(packed.Manifest).Should().Equal(bytes, $"{label}: the reference packs the declared input to the TypeScript bytes");
|
|
}
|
|
}
|
|
|
|
private static byte[] Body() =>
|
|
Enumerable.Range(0, BodyLength).Select(i => (byte)(i % 251)).ToArray();
|
|
|
|
private static CultMeshCdnPackOptions Options() => new()
|
|
{
|
|
ChunkSizeBytes = ChunkSize,
|
|
Version = "1",
|
|
MimeType = "application/x-cultnet-vector",
|
|
CreatedAtUtc = CreatedAtUtc
|
|
};
|
|
|
|
private static byte[] Reencode(CultMeshContentChunkRequestMessage message) =>
|
|
CultNetSchemaMessageSerialization.Serialize(message);
|
|
|
|
private static byte[] Reencode(CultMeshContentChunkResponseMessage message) =>
|
|
CultNetSchemaMessageSerialization.Serialize(message);
|
|
|
|
private static byte[] Reencode(CultMeshCdnArtifactManifest manifest) =>
|
|
MessagePackSerializer.Serialize(manifest, CultNetSchemaMessageSerialization.Options);
|
|
|
|
private static CultMeshContentChunkRequestMessage Request(string id, string hash, string key, int size) => new()
|
|
{
|
|
MessageId = id,
|
|
ChunkHash = hash,
|
|
RecordKey = key,
|
|
ExpectedSizeBytes = size
|
|
};
|
|
|
|
/// <summary>The cs-written file's vectors, in file order. Messages are keyed by label.</summary>
|
|
private static List<Vector> BuildVectors(out CultCache cache, out Dictionary<string, object> messages)
|
|
{
|
|
var artifact = CultMeshCdn.PackArtifact(ArtifactId, Body(), Options());
|
|
var content = new CultCache(CultMesh.CreateCultCacheDocumentRegistry(
|
|
typeof(CultMeshCdnArtifactManifest), typeof(CultMeshCdnArtifactChunk)));
|
|
CultMeshCdn.PublishAsync(content, artifact).GetAwaiter().GetResult();
|
|
cache = content;
|
|
|
|
var last = artifact.Chunks[^1];
|
|
var key = "mesh:cdn:chunk:" + last.ChunkHash;
|
|
var lastDigit = last.ChunkHash[^1] == '0' ? '1' : '0';
|
|
var vectors = new List<Vector>();
|
|
var map = new Dictionary<string, object>(StringComparer.Ordinal);
|
|
messages = map;
|
|
|
|
void AddRequest(string label, CultMeshContentChunkRequestMessage message)
|
|
{
|
|
map[label] = message;
|
|
vectors.Add(new Vector(label, "request", Reencode(message)));
|
|
}
|
|
|
|
void AddResponse(string label, CultMeshContentChunkResponseMessage message)
|
|
{
|
|
map[label] = message;
|
|
vectors.Add(new Vector(label, "response", Reencode(message)));
|
|
}
|
|
|
|
void AddAnswer(string name, CultMeshContentChunkRequestMessage request)
|
|
{
|
|
AddRequest(name + ".request", request);
|
|
AddResponse(name + ".response", Answer(content, request));
|
|
}
|
|
|
|
AddAnswer("answer_found", Request("vector-found", "SHA256:" + last.ChunkHash, key, last.SizeBytes));
|
|
AddAnswer("answer_not_found", Request("vector-missing", new string('0', 64), string.Empty, 100));
|
|
AddAnswer("answer_record_key_disagrees", Request("vector-key", last.ChunkHash, "mesh:cdn:chunk:other", last.SizeBytes));
|
|
AddAnswer("answer_size_mismatch", Request("vector-size", last.ChunkHash, key, last.SizeBytes - 1));
|
|
// Requests the reference refuses in its handler, never at deserialisation: each is answered.
|
|
AddAnswer("answer_blank_message_id", Request(string.Empty, last.ChunkHash, key, last.SizeBytes));
|
|
AddAnswer("answer_whitespace_message_id", Request(" ", last.ChunkHash, key, last.SizeBytes));
|
|
AddAnswer("answer_blank_hash", Request("vector-blank-hash", string.Empty, string.Empty, 100));
|
|
AddAnswer("answer_whitespace_hash", Request("vector-space-hash", " ", string.Empty, 100));
|
|
AddAnswer("answer_prefix_only_hash", Request("vector-prefix-hash", "sha256: ", string.Empty, 100));
|
|
AddAnswer("answer_negative_size", Request("vector-negative", last.ChunkHash, key, -1));
|
|
AddAnswer("answer_record_key_case", Request("vector-key-case", last.ChunkHash, key.ToUpperInvariant(), last.SizeBytes));
|
|
AddAnswer("answer_blank_record_key", Request("vector-key-blank", last.ChunkHash, " ", last.SizeBytes));
|
|
AddAnswer("answer_double_prefix", Request("vector-double", "sha256:sha256:" + last.ChunkHash, string.Empty, last.SizeBytes));
|
|
|
|
// A caller that leaves a string null writes msgpack nil; the reference reads it back as null and
|
|
// answers. These are judged by Rust decoding the reference's bytes (Rust never writes nil), so
|
|
// they stay out of the label-matched mirror set.
|
|
void AddNilAnswer(string name, CultMeshContentChunkRequestMessage request)
|
|
{
|
|
vectors.Add(new Vector(name + ".request", "request", Reencode(request)));
|
|
vectors.Add(new Vector(name + ".response", "response", Reencode(Answer(content, request))));
|
|
}
|
|
|
|
AddNilAnswer("nil_message_id", Request(null, last.ChunkHash, key, last.SizeBytes));
|
|
AddNilAnswer("nil_hash", Request("vector-nil-hash", null, string.Empty, 100));
|
|
AddNilAnswer("nil_record_key", Request("vector-nil-key", new string('0', 64), null, 100));
|
|
AddResponse("response_hash_last_digit", new CultMeshContentChunkResponseMessage
|
|
{
|
|
MessageId = "vector-digit",
|
|
Found = true,
|
|
ChunkHash = last.ChunkHash[..^1] + lastDigit,
|
|
SizeBytes = last.SizeBytes,
|
|
Payload = last.Payload,
|
|
Error = string.Empty
|
|
});
|
|
|
|
void AddManifest(string label, CultMeshCdnArtifactManifest manifest)
|
|
{
|
|
map[label] = manifest;
|
|
vectors.Add(new Vector(label, "manifest", Reencode(manifest)));
|
|
}
|
|
|
|
AddManifest("manifest", artifact.Manifest);
|
|
var shuffled = CultMeshCdn.PackArtifact(ArtifactId, Body(), Options()).Manifest;
|
|
shuffled.Chunks = new[] { 3, 0, 5, 1, 4, 2 }.Select(i => shuffled.Chunks[i]).ToArray();
|
|
AddManifest("manifest_out_of_order", shuffled);
|
|
var tagged = CultMeshCdn.PackArtifact(ArtifactId, Body(), Options()).Manifest;
|
|
tagged.Metadata = new Dictionary<string, string> { ["zeta"] = "1", ["alpha"] = "2", ["mid"] = "3" };
|
|
AddManifest("manifest_metadata_order", tagged);
|
|
AddManifest("manifest_empty_body", CultMeshCdn.PackArtifact(ArtifactId, Array.Empty<byte>(), Options()).Manifest);
|
|
return vectors;
|
|
}
|
|
|
|
private static CultMeshContentChunkResponseMessage Answer(CultCache cache, CultMeshContentChunkRequestMessage request)
|
|
{
|
|
var host = new ReferenceHost();
|
|
using var server = new CultMeshLegacyRudpContentServer(host, cache);
|
|
var peer = new ReferencePeer();
|
|
host.Dispatch(request, peer).GetAwaiter().GetResult();
|
|
return peer.Sent ?? throw new InvalidOperationException("The reference server answered with silence.");
|
|
}
|
|
|
|
private sealed class ReferenceHost : ICultNetSchemaServer
|
|
{
|
|
private Func<CultMeshContentChunkRequestMessage, ICultNetSchemaServerPeer, Task>? _handler;
|
|
|
|
public void OnCultNet<TMessage>(Func<TMessage, ICultNetSchemaServerPeer, Task> callback)
|
|
where TMessage : ICultNetSchemaMessage =>
|
|
_handler = (Func<CultMeshContentChunkRequestMessage, ICultNetSchemaServerPeer, Task>)(object)callback;
|
|
|
|
public void RemoveCultNetMessageListener<TMessage>(Delegate callback)
|
|
where TMessage : ICultNetSchemaMessage => _handler = null;
|
|
|
|
public Task Dispatch(CultMeshContentChunkRequestMessage request, ICultNetSchemaServerPeer peer) =>
|
|
_handler!(request, peer);
|
|
}
|
|
|
|
private sealed class ReferencePeer : ICultNetSchemaServerPeer
|
|
{
|
|
public CultMeshContentChunkResponseMessage? Sent { get; private set; }
|
|
|
|
public void SendCultNet<TMessage>(TMessage message) where TMessage : ICultNetSchemaMessage =>
|
|
Sent = (CultMeshContentChunkResponseMessage)(object)message;
|
|
}
|
|
|
|
private static string ToJson(List<Vector> vectors) => JsonSerializer.Serialize(
|
|
new
|
|
{
|
|
vectors = vectors.Select(v => new
|
|
{
|
|
label = v.Label,
|
|
kind = v.Kind,
|
|
messagePackBase64 = Convert.ToBase64String(v.Bytes)
|
|
})
|
|
},
|
|
new JsonSerializerOptions { WriteIndented = true });
|
|
|
|
private static List<Vector> ReadVectors(string name)
|
|
{
|
|
var path = VectorPath(name);
|
|
if (!File.Exists(path))
|
|
Assert.Inconclusive($"{path} is missing - write it first (CULTNET_WRITE_VECTORS=1).");
|
|
using var document = JsonDocument.Parse(File.ReadAllText(path));
|
|
return document.RootElement.GetProperty("vectors").EnumerateArray()
|
|
.Select(v => new Vector(
|
|
v.GetProperty("label").GetString()!,
|
|
v.GetProperty("kind").GetString()!,
|
|
Convert.FromBase64String(v.GetProperty("messagePackBase64").GetString()!)))
|
|
.ToList();
|
|
}
|
|
|
|
private static string Lines(string text) => text.Replace("\r\n", "\n").TrimEnd('\n');
|
|
|
|
private static string[] KeyHashes()
|
|
{
|
|
var empty = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
|
return new[]
|
|
{
|
|
empty, "SHA256:" + empty.ToUpperInvariant(), " " + empty + " ", " sha256:" + empty,
|
|
"sha256:sha256:" + empty, "Sha256:\u0130\u03A3\U00010400", "sha256:"
|
|
};
|
|
}
|
|
|
|
private sealed record KeyVectors(
|
|
List<(string Id, string Version, string Hash, string Key)> Artifacts,
|
|
List<(string Hash, string Key)> Chunks);
|
|
|
|
/// <summary>Every key is derived by the reference's public CreateRecordKey, never spelled by hand.</summary>
|
|
private static KeyVectors BuildRecordKeyVectors()
|
|
{
|
|
var ids = new[]
|
|
{
|
|
"eureka/content-vector", " Mixed Case/Path ", "\u0130stanbul", "\u0391\u03A3", "\uFEFFbom", "\u0085nel\u0085",
|
|
"\u65E5\u672C\u8A9E/\u8CC7\u7523", "\U0001F642 smile", "---", "a\uD800b", "\uD800", "x\u00B2y", "\u0663d", "\U00010400A"
|
|
};
|
|
var versions = new[] { string.Empty, " ", "1.0.0", "V2 Beta", "\u00FC" };
|
|
var hashes = KeyHashes();
|
|
var artifacts = ids.Select((id, i) => (id, versions[i % versions.Length], hashes[i % hashes.Length]))
|
|
.Concat(versions.Select(version => (ids[0], version, hashes[0])))
|
|
.Select(t => (t.Item1, t.Item2, t.Item3, CultMeshCdnArtifactManifest.CreateRecordKey(t.Item1, t.Item2, t.Item3).Value))
|
|
.ToList();
|
|
var chunks = hashes.Select(hash => (hash, CultMeshCdnArtifactChunk.CreateRecordKey(hash).Value)).ToList();
|
|
return new KeyVectors(artifacts, chunks);
|
|
}
|
|
|
|
/// <summary>String inputs are stored as UTF-16 code-unit arrays, so a lone surrogate survives the JSON file.</summary>
|
|
private static string RecordKeyVectorsToJson(KeyVectors vectors) =>
|
|
JsonSerializer.Serialize(
|
|
new
|
|
{
|
|
artifacts = vectors.Artifacts.Select(a => new
|
|
{
|
|
artifactIdUnits = a.Id.Select(c => (int)c),
|
|
versionUnits = a.Version.Select(c => (int)c),
|
|
contentHash = a.Hash,
|
|
recordKey = a.Key
|
|
}),
|
|
chunks = vectors.Chunks.Select(c => new { chunkHash = c.Hash, recordKey = c.Key })
|
|
},
|
|
new JsonSerializerOptions { WriteIndented = true });
|
|
|
|
private static string VectorPath(string name) => Path.Combine(RepoRoot(), "contracts", "cultmesh", name);
|
|
|
|
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.");
|
|
}
|
|
}
|