CultLib/tests/GameCult.Mesh.Tests/CultMeshSingleFileElementIdTests.cs
Meta Vens 41d0dd9bfb C# header: one function decides it for every writer; a store's own ids are the only source
CacheBackingStore.HeaderFor(holdsIds, existingHeader, wholeStore, directoryStore) replaces FormatFor,
DirectoryFormatFor and the writers' private marker logic. A writer that rewrites the whole store
decides by content; a writer that touches part of it keeps a header already marked. Flush,
unconditional and conditional commit, the directory store and CultMesh's single-file write all call
it. A typed CultMesh write replaces the file with a document it can see, so it decides by content;
a raw payload is opaque and keeps a marked file marked.

A record's HoldsIds is what its stored form persists: a load keeps a variant's delta as read, so
the ids a load mints in an override exist only in memory and no longer mark the store. A
conditional commit onto the file no longer reads this cache's view of what the file held, so a
stale view cannot over-mark it. IdPlan.HoldsIds is derived from HeldIds and the fills.

Reverts InternalsVisibleTo GameCult.Caching.MessagePack and the public DirectoryFormatV4/V5
constants: the directory store reaches the decision through the protected member it inherits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:54:01 +02:00

153 lines
6.7 KiB
C#

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<CultMeshBodyPublicationDocument>(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");
}
[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<CultElementIdException>(() => CultMesh.WriteSingleFileDocument(path, Key, duplicate))!;
refusal.ElementId.Should().Be(HexA);
var once = Descriptor();
var twice = Publication(once, once);
Assert.Throws<CultElementIdException>(() => 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<CultElementIdException>(() => CultMesh.ReadSingleFileDocument<CultMeshBodyPublicationDocument>(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<CultMeshBodyPublicationDocument>(path, Key).Representations.Select(entry => entry.Id).ToArray();
first.Should().OnlyContain(id => id.Length == 12);
CultMesh.ReadSingleFileDocument<CultMeshBodyPublicationDocument>(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<CultMeshBodyPublicationDocument>(Key)!.Representations.Select(entry => entry.Id).Should().Equal(first);
}
}