2026-09-30 01:59:28 +02:00
|
|
|
using System;
|
|
|
|
|
using System.Collections.Generic;
|
|
|
|
|
using System.IO;
|
|
|
|
|
using System.Linq;
|
|
|
|
|
using FluentAssertions;
|
|
|
|
|
using GameCult.Caching;
|
|
|
|
|
using GameCult.Caching.MessagePack;
|
|
|
|
|
using NUnit.Framework;
|
|
|
|
|
|
|
|
|
|
#nullable enable
|
|
|
|
|
|
|
|
|
|
namespace GameCult.Mesh.Tests;
|
|
|
|
|
|
|
|
|
|
// A single-file write replaces a file exactly when the runtime's own reader opens it, as a cache's flush does. The bytes and
|
|
|
|
|
// the verdict are shared with every runtime: tests/vectors/document-variants-c2a/readability.
|
|
|
|
|
public sealed class CultMeshSingleFileReadabilityTests
|
|
|
|
|
{
|
|
|
|
|
private const int CSharp = 1;
|
|
|
|
|
private string _root = null!;
|
|
|
|
|
|
|
|
|
|
[SetUp]
|
|
|
|
|
public void SetUp()
|
|
|
|
|
{
|
|
|
|
|
_root = Path.Combine(Path.GetTempPath(), "cultmesh-single-file-readability", Guid.NewGuid().ToString("N"));
|
|
|
|
|
Directory.CreateDirectory(_root);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TearDown]
|
|
|
|
|
public void TearDown()
|
|
|
|
|
{
|
|
|
|
|
if (Directory.Exists(_root)) Directory.Delete(_root, true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string VectorRoot()
|
|
|
|
|
{
|
|
|
|
|
for (var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); dir != null; dir = dir.Parent)
|
|
|
|
|
{
|
|
|
|
|
var candidate = Path.Combine(dir.FullName, "tests", "vectors", "document-variants-c2a", "readability");
|
|
|
|
|
if (Directory.Exists(candidate)) return candidate;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw new DirectoryNotFoundException($"Shared readability vectors not found above {TestContext.CurrentContext.TestDirectory}.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static IEnumerable<TestCaseData> Vectors() =>
|
|
|
|
|
File.ReadAllLines(Path.Combine(VectorRoot(), "manifest.txt"))
|
|
|
|
|
.Where(line => line.Length > 0 && line[0] != '#')
|
|
|
|
|
.Select(line => line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries))
|
|
|
|
|
.Select(cells => new TestCaseData(cells[0], cells[CSharp] == "reads").SetName($"{cells[0]} {cells[CSharp]}"));
|
|
|
|
|
|
|
|
|
|
private static CultMeshBodyPublicationDocument Publication() => new()
|
|
|
|
|
{
|
|
|
|
|
BodyId = "aetheria:entities",
|
|
|
|
|
ProducerId = "aetheria",
|
|
|
|
|
SchemaId = "eve.entity_soa.v1"
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
[TestCaseSource(nameof(Vectors))]
|
2026-09-30 02:05:56 +02:00
|
|
|
public void AWriteReplacesAFileExactlyWhenItReads(string vector, bool reads)
|
2026-09-30 01:59:28 +02:00
|
|
|
{
|
2026-09-30 02:05:56 +02:00
|
|
|
var raw = new CultMeshSingleFileDocumentSchema("raw:schema", "RawSchema", "1");
|
|
|
|
|
var writes = new (string Name, Action<string> Write)[]
|
2026-09-30 01:59:28 +02:00
|
|
|
{
|
2026-09-30 02:05:56 +02:00
|
|
|
("typed", path => CultMesh.WriteSingleFileDocument(path, new CultRecordKey("publication"), Publication())),
|
|
|
|
|
("raw payload", path => CultMesh.WriteSingleFileDocumentPayload(path, new CultRecordKey("publication"), raw, null, new byte[] { 0x90 }))
|
|
|
|
|
};
|
|
|
|
|
foreach (var (name, write) in writes)
|
|
|
|
|
{
|
|
|
|
|
var path = Path.Combine(_root, name.Replace(' ', '-') + ".cc");
|
|
|
|
|
var bytes = File.ReadAllBytes(Path.Combine(VectorRoot(), vector));
|
|
|
|
|
File.WriteAllBytes(path, bytes);
|
2026-09-30 01:59:28 +02:00
|
|
|
|
2026-09-30 02:05:56 +02:00
|
|
|
if (reads)
|
|
|
|
|
{
|
C# write set: tests for copy-forward, raw catalog carry, header rule and the inverted lay-back rules
New WriteSetTests (single-file and directory stores, flush and commit): another writer's write and
removal survive, a store that appeared after open is kept, an unstaged in-place change is not
written, a copied record keeps the catalog entry the store holds now, an entry's unknown trailing
fields survive byte for byte, conditional and unconditional commits write the same bytes, the
header drops only when every copied record is known id-free, v2 exactly with a variant. Mesh: a
write copies neighbours forward and replaces only its key. The lay-back and whole-view tests and
the flush-restamp tests are rewritten or removed with the rules they pinned.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 03:16:49 +02:00
|
|
|
var before = CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes);
|
2026-10-03 05:11:17 +02:00
|
|
|
if (before.Records.Any(record => record.Variant != null))
|
|
|
|
|
{
|
|
|
|
|
Assert.That(() => write(path), Throws.TypeOf<CultWriteConflictException>(), name);
|
|
|
|
|
File.ReadAllBytes(path).Should().Equal(bytes, name + " wrote into a store holding a variant it cannot resolve");
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-30 02:05:56 +02:00
|
|
|
write(path);
|
C# write set: tests for copy-forward, raw catalog carry, header rule and the inverted lay-back rules
New WriteSetTests (single-file and directory stores, flush and commit): another writer's write and
removal survive, a store that appeared after open is kept, an unstaged in-place change is not
written, a copied record keeps the catalog entry the store holds now, an entry's unknown trailing
fields survive byte for byte, conditional and unconditional commits write the same bytes, the
header drops only when every copied record is known id-free, v2 exactly with a variant. Mesh: a
write copies neighbours forward and replaces only its key. The lay-back and whole-view tests and
the flush-restamp tests are rewritten or removed with the rules they pinned.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 03:16:49 +02:00
|
|
|
var after = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
|
|
|
|
|
after.Records.Where(record => record.Key == "publication").Should().ContainSingle(name);
|
|
|
|
|
var others = before.Records.Where(record => record.Key != "publication").ToArray();
|
|
|
|
|
after.Records.Should().HaveCount(others.Length + 1, name);
|
|
|
|
|
foreach (var record in others)
|
|
|
|
|
{
|
|
|
|
|
var kept = after.Records.Single(candidate => candidate.Key == record.Key);
|
|
|
|
|
kept.SchemaId.Should().Be(record.SchemaId, name);
|
|
|
|
|
kept.StoredAt.Should().Be(record.StoredAt, name);
|
|
|
|
|
kept.Payload.Should().Equal(record.Payload, name + " copies the file's other records as they are");
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-30 02:05:56 +02:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
Store readability: one typed refusal, one full reader, one store shape
CultStoreUnreadableException (a NotSupportedException) is the one refusal for a store file this runtime cannot read, thrown
by the single-file reader, the rewriting paths and CultMesh's reads, with the cause as the inner exception. The reader is exactly
one array of three slots (or an empty array): a header alone and an extra top-level slot are refused instead of read as empty or
skipped. CultMesh's older-catalog fallback is the same reader with the older entry reader, so it also requires one array.
Vectors: bad-body, header-only, missing-schema, variant-slot, extra-top-slot in the readability set, and a legacy-catalog store
with and without bytes after it for CultMesh. The persistence contract says what is true.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 04:06:44 +02:00
|
|
|
Assert.That(() => write(path), Throws.TypeOf<CultStoreUnreadableException>(), name);
|
2026-09-30 02:05:56 +02:00
|
|
|
File.ReadAllBytes(path).Should().Equal(bytes, name + " rewrote a file this runtime cannot read");
|
|
|
|
|
}
|
2026-09-30 01:59:28 +02:00
|
|
|
}
|
Store readability: one typed refusal, one full reader, one store shape
CultStoreUnreadableException (a NotSupportedException) is the one refusal for a store file this runtime cannot read, thrown
by the single-file reader, the rewriting paths and CultMesh's reads, with the cause as the inner exception. The reader is exactly
one array of three slots (or an empty array): a header alone and an extra top-level slot are refused instead of read as empty or
skipped. CultMesh's older-catalog fallback is the same reader with the older entry reader, so it also requires one array.
Vectors: bad-body, header-only, missing-schema, variant-slot, extra-top-slot in the readability set, and a legacy-catalog store
with and without bytes after it for CultMesh. The persistence contract says what is true.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 04:06:44 +02:00
|
|
|
|
2026-10-02 20:49:12 +02:00
|
|
|
// A read asks the same reader as a write: a file this runtime cannot read, a zero-byte one included, is refused as unreadable
|
|
|
|
|
// and never reported as a store that lacks the record.
|
|
|
|
|
[Test]
|
|
|
|
|
public void AReadOfAZeroByteFileIsRefusedAsUnreadableNotAsAMissingRecord()
|
|
|
|
|
{
|
|
|
|
|
var path = Path.Combine(_root, "zero.cc");
|
|
|
|
|
File.WriteAllBytes(path, Array.Empty<byte>());
|
|
|
|
|
|
|
|
|
|
Assert.That(() => CultMesh.ReadSingleFileDocumentPayload(path, new CultRecordKey("publication"), "raw:schema"), Throws.TypeOf<CultStoreUnreadableException>());
|
|
|
|
|
}
|
|
|
|
|
|
Store readability: one typed refusal, one full reader, one store shape
CultStoreUnreadableException (a NotSupportedException) is the one refusal for a store file this runtime cannot read, thrown
by the single-file reader, the rewriting paths and CultMesh's reads, with the cause as the inner exception. The reader is exactly
one array of three slots (or an empty array): a header alone and an extra top-level slot are refused instead of read as empty or
skipped. CultMesh's older-catalog fallback is the same reader with the older entry reader, so it also requires one array.
Vectors: bad-body, header-only, missing-schema, variant-slot, extra-top-slot in the readability set, and a legacy-catalog store
with and without bytes after it for CultMesh. The persistence contract says what is true.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 04:06:44 +02:00
|
|
|
[Test]
|
|
|
|
|
public void EveryVectorInTheFolderHasAManifestRow()
|
|
|
|
|
{
|
|
|
|
|
var rows = Vectors().Select(row => (string)row.Arguments[0]).Where(name => !name.StartsWith("..", StringComparison.Ordinal)).OrderBy(name => name, StringComparer.Ordinal);
|
|
|
|
|
var files = Directory.GetFiles(VectorRoot(), "*.bin").Select(Path.GetFileName).OrderBy(name => name, StringComparer.Ordinal);
|
|
|
|
|
rows.Should().Equal(files);
|
|
|
|
|
}
|
2026-09-30 01:59:28 +02:00
|
|
|
}
|