using System; using System.IO; using System.Linq; using FluentAssertions; using GameCult.Caching; using GameCult.Caching.MessagePack; using MessagePack; using NUnit.Framework; #nullable enable namespace GameCult.Mesh.Tests; // C2a: a single-file document written or read through CultMesh is judged as a cache judges it: ids minted, a list that // cannot hold ids refused, the header decided by what the file holds. public sealed class CultMeshSingleFileElementIdTests { private const string HexA = "aaaaaaaaaaaa"; private static readonly CultRecordKey Key = new("publication"); private string _root = null!; [SetUp] public void SetUp() { _root = Path.Combine(Path.GetTempPath(), "cultmesh-single-file-ids", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_root); } [TearDown] public void TearDown() { if (Directory.Exists(_root)) Directory.Delete(_root, true); } private static CultMeshBodyPublicationDocument Publication(params CultMeshBodyDescriptor[] representations) => new() { BodyId = "aetheria:entities", ProducerId = "aetheria", SchemaId = "eve.entity_soa.v1", Representations = representations }; private static string HeaderOf(string path) => CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).FormatVersion; private static CultMeshBodyDescriptor Descriptor(string id = "") => new() { Id = id, BodyId = "body" }; private string Path_(string name) => Path.Combine(_root, name); [Test] public void TypedWriteMintsTheElementIdsAndMarksTheFileThatHoldsThem() { var path = Path_("ids.cc"); var publication = Publication(Descriptor(), Descriptor()); CultMesh.WriteSingleFileDocument(path, Key, publication); publication.Representations.Select(entry => entry.Id).Should().OnlyContain(id => id.Length == 12).And.OnlyHaveUniqueItems(); HeaderOf(path).Should().Be("cultcache.store.v3"); CultMesh.ReadSingleFileDocument(path, Key).Representations.Select(entry => entry.Id) .Should().Equal(publication.Representations.Select(entry => entry.Id)); } // A typed write replaces the whole file with a document it can see, so the ids that document holds decide the header. [Test] public void TypedWriteDecidesTheHeaderByTheDocumentItWritesEvenOverAMarkedFile() { var path = Path_("none.cc"); CultMesh.WriteSingleFileDocument(path, Key, Publication()); HeaderOf(path).Should().Be("cultcache.store.v1"); CultMesh.WriteSingleFileDocument(path, Key, Publication(Descriptor())); HeaderOf(path).Should().Be("cultcache.store.v3"); CultMesh.WriteSingleFileDocument(path, Key, Publication()); HeaderOf(path).Should().Be("cultcache.store.v1", "the whole file is replaced by a document that holds no id"); } // A raw payload is opaque: the writer cannot see what it holds, so a marked file stays marked. [Test] public void RawPayloadWriteOverAMarkedFileKeepsItMarkedAndOverAnUnmarkedOneStaysV1() { var path = Path_("raw.cc"); var schema = new CultMeshSingleFileDocumentSchema("raw:schema", "RawSchema", "1"); var payload = new byte[] { 0x90 }; CultMesh.WriteSingleFileDocumentPayload(path, Key, schema, null, payload); HeaderOf(path).Should().Be("cultcache.store.v1"); CultMesh.WriteSingleFileDocument(path, Key, Publication(Descriptor())); HeaderOf(path).Should().Be("cultcache.store.v3"); CultMesh.WriteSingleFileDocumentPayload(path, Key, schema, null, payload); HeaderOf(path).Should().Be("cultcache.store.v3", "the payload's ids are not the writer's to see"); } // A raw write does not read the store's records, but it does not overwrite a file it cannot read either: the runtimes agree. [Test] public void RawPayloadWriteRefusesAFileItCannotReadAndLeavesItUntouched() { var schema = new CultMeshSingleFileDocumentSchema("raw:schema", "RawSchema", "1"); var marked = Path_("marked.cc"); CultMesh.WriteSingleFileDocument(marked, Key, Publication(Descriptor())); var whole = File.ReadAllBytes(marked); var truncated = Path_("truncated.cc"); File.WriteAllBytes(truncated, whole[..^1]); var unknown = Path_("v9.cc"); File.WriteAllBytes(unknown, MessagePackSerializer.Serialize(new object[] { "cultcache.store.v9", Array.Empty(), Array.Empty() })); // A file is a store only when it is exactly one MessagePack array whose first slot, if a string, is a store header. var trailing = Path_("trailing.cc"); File.WriteAllBytes(trailing, whole.Concat(new byte[] { 1, 2, 3 }).ToArray()); var scalar = Path_("scalar.cc"); File.WriteAllBytes(scalar, new byte[] { 0x01 }); var map = Path_("map.cc"); File.WriteAllBytes(map, new byte[] { 0x80 }); var stringFirst = Path_("string-first.cc"); File.WriteAllBytes(stringFirst, MessagePackSerializer.Serialize(new object[] { "hello", 1 })); foreach (var path in new[] { truncated, unknown, trailing, scalar, map, stringFirst }) { var before = File.ReadAllBytes(path); Assert.Throws(() => CultMesh.WriteSingleFileDocumentPayload(path, Key, schema, null, new byte[] { 0x90 }), path); File.ReadAllBytes(path).Should().Equal(before, path + " was rewritten"); } // An empty file and a legacy envelope array (first slot not a string, empty included) carry no header and are written. foreach (var (name, bytes) in new[] { ("empty.cc", Array.Empty()), ("empty-array.cc", new byte[] { 0x90 }), ("legacy.cc", new byte[] { 0x91, 0x80 }) }) { var path = Path_(name); File.WriteAllBytes(path, bytes); CultMesh.WriteSingleFileDocumentPayload(path, Key, schema, null, new byte[] { 0x90 }); HeaderOf(path).Should().Be("cultcache.store.v1", name); } CultMesh.WriteSingleFileDocumentPayload(marked, Key, schema, null, new byte[] { 0x90 }); HeaderOf(marked).Should().Be("cultcache.store.v3", "a whole marked file is still written and stays marked"); } [Test] public void TypedWriteRefusesADuplicateIdAndAnElementObjectTwiceAndLeavesTheFileAlone() { var path = Path_("refused.cc"); CultMesh.WriteSingleFileDocument(path, Key, Publication(Descriptor())); var before = File.ReadAllBytes(path); var duplicate = Publication(Descriptor(HexA), Descriptor(HexA)); var refusal = Assert.Throws(() => CultMesh.WriteSingleFileDocument(path, Key, duplicate))!; refusal.ElementId.Should().Be(HexA); var once = Descriptor(); var twice = Publication(once, once); Assert.Throws(() => CultMesh.WriteSingleFileDocument(path, Key, twice))!.Message.Should().Contain("twice"); once.Id.Should().BeEmpty("a refusal mints nothing"); File.ReadAllBytes(path).Should().Equal(before); } [Test] public void AFailedTypedWriteGivesTheMintedIdsBack() { var blocked = Path_("blocked.cc"); Directory.CreateDirectory(blocked); var element = Descriptor(); Assert.That(() => CultMesh.WriteSingleFileDocument(blocked, Key, Publication(element)), Throws.Exception); element.Id.Should().BeEmpty("a write that does not land gives its ids back"); } [Test] public void TypedReadRefusesWhatALoadRefusesAndMintsWhatALoadMints() { var path = Path_("disk.cc"); CultMesh.WriteSingleFileDocument(path, Key, Publication(Descriptor())); var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); var duplicate = Publication(Descriptor(HexA), Descriptor(HexA)); snapshot.Records[0].Payload = MessagePackSerializer.Serialize(duplicate); File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); var refusal = Assert.Throws(() => CultMesh.ReadSingleFileDocument(path, Key))!; refusal.ElementId.Should().Be(HexA); // A payload written before ids: two reads, and a cache load, agree on the ids minted. snapshot.Records[0].Payload = MessagePackSerializer.Serialize(Publication(Descriptor(), Descriptor())); File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); var first = CultMesh.ReadSingleFileDocument(path, Key).Representations.Select(entry => entry.Id).ToArray(); first.Should().OnlyContain(id => id.Length == 12); CultMesh.ReadSingleFileDocument(path, Key).Representations.Select(entry => entry.Id).Should().Equal(first); using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = CultDocumentRegistry.ForTypes(new[] { typeof(CultMeshBodyPublicationDocument) }), ReadOnly = true }); cache.Get(Key)!.Representations.Select(entry => entry.Id).Should().Equal(first); } }