#nullable enable using System; using System.Collections.Generic; using System.IO; using System.Linq; using GameCult.Caching.MessagePack; using MessagePack; using NUnit.Framework; using static GameCult.Caching.Tests.ElementIdTests; namespace GameCult.Caching.Tests { // A file is replaced by a flush or a commit exactly when this runtime's own reader opens it: one verdict per file, asked by // open, flush and commit alike. The bytes and the verdict of every runtime are shared: tests/vectors/document-variants-c2a/readability. public class StoreReadabilityTests { private const int CSharp = 1; private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck), typeof(VectorItem) }); private string _directory = ""; [SetUp] public void SetUp() { _directory = Path.Combine(Path.GetTempPath(), "cultlib-readability-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_directory); } [TearDown] public void TearDown() { if (Directory.Exists(_directory)) Directory.Delete(_directory, 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 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 string Header(string path) => CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).FormatVersion; private string Seed(string name) { var path = Path.Combine(_directory, name); using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }); cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "d" }, new CultRecordKey("d"))); return path; } [TestCaseSource(nameof(Vectors))] public void OpenReadsExactlyTheFilesTheVectorsSayItReads(string vector, bool reads) { var path = Path.Combine(_directory, "open.cc"); File.WriteAllBytes(path, File.ReadAllBytes(Path.Combine(VectorRoot(), vector))); void Open() => CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }).Dispose(); if (reads) Assert.DoesNotThrow(Open); else Assert.That(Open, Throws.TypeOf().With.Property(nameof(CultStoreUnreadableException.Path)).EqualTo(path)); } [TestCaseSource(nameof(Vectors))] public void AFlushReplacesAFileExactlyWhenItReads(string vector, bool reads) { var path = Seed("flush.cc"); using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }); var bytes = File.ReadAllBytes(Path.Combine(VectorRoot(), vector)); File.WriteAllBytes(path, bytes); var store = cache.BackingStores[0]; if (reads) { store.PushAll(); // A whole-store flush writes what the store holds: one deck with no element ids is v1, whatever the file was. Assert.That(Header(path), Is.EqualTo("cultcache.store.v1")); return; } Assert.That(() => store.PushAll(), Throws.TypeOf()); Assert.That(File.ReadAllBytes(path), Is.EqualTo(bytes), "a file this runtime cannot read was rewritten"); } [TestCaseSource(nameof(Vectors))] public void AnUnconditionalCommitReplacesAFileExactlyWhenItReads(string vector, bool reads) { var path = Seed("commit.cc"); using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }); var bytes = File.ReadAllBytes(Path.Combine(VectorRoot(), vector)); File.WriteAllBytes(path, bytes); if (reads) { cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e"))); Assert.That(Header(path), Is.EqualTo("cultcache.store.v1"), "an unconditional commit writes the store's whole view, which holds no element id"); return; } Assert.That(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e"))), Throws.TypeOf()); Assert.That(File.ReadAllBytes(path), Is.EqualTo(bytes), "a file this runtime cannot read was rewritten"); } // A commit that lands onto the file as it is keeps the header the file carries: a store already v3 stays v3. [TestCase("../v3-base.msgpack", "cultcache.store.v3")] [TestCase("compatible-id-only-v3.bin", "cultcache.store.v3")] [TestCase("zero-byte.bin", "cultcache.store.v1")] [TestCase("empty-array.bin", "cultcache.store.v1")] public void AConditionalCommitOntoAReadableFileKeepsItsHeader(string vector, string header) { var path = Seed("conditional.cc"); using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }); File.WriteAllBytes(path, File.ReadAllBytes(Path.Combine(VectorRoot(), vector))); Assert.That(cache.Commit(batch => { batch.Expect(new CultRecordKey("e"), null); batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e")); }), Is.True); Assert.That(Header(path), Is.EqualTo(header)); } // Every runtime's tests walk the manifest, so a vector without a row would go untested. [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); Assert.That(rows, Is.EqualTo(files)); } // The refusal carries what the reader choked on. [Test] public void ARefusalKeepsItsCauseAsTheInnerException() { var error = Assert.Throws(() => CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(Path.Combine(VectorRoot(), "truncated.bin"))))!; Assert.That(error.InnerException, Is.Not.Null); } // The record type of the shared valid store (v3-base.msgpack: alpha and beta), so a cache can open it. [CultDocument("vectors.item", "vectors.item.v1")] [MessagePackObject] public sealed class VectorItem { [Key(0)] [CultName] public string Name { get; set; } = ""; [Key(1)] public int Count { get; set; } } } }