#nullable enable using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using GameCult.Caching; using GameCult.Caching.MessagePack; using MessagePack; using NUnit.Framework; using R3; namespace GameCult.Caching.Tests { // Cut 1 of docs/cultcache-publication-cut.md: an admission reads every landing record's name and index values once, // before land and before any sequence is minted. A getter that throws refuses the whole change set. [NonParallelizable] public class CultCacheProjectionTests { private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(ProjectedRecord) }); private string _directory = string.Empty; [SetUp] public void Prepare() { _directory = Path.Combine(Path.GetTempPath(), $"cultlib-projection-{Guid.NewGuid():N}"); Directory.CreateDirectory(_directory); ProjectedRecord.Armed = false; ProjectedRecord.Reads = 0; } [TearDown] public void Cleanup() { ProjectedRecord.Armed = false; if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true); } [CultDocument("tests.projected_record", "tests.projected_record.v1")] [MessagePackObject(AllowPrivate = true)] internal sealed class ProjectedRecord { public static long Reads; public static volatile bool Armed; [IgnoreMember] private string _name = string.Empty; [IgnoreMember] private string _code = string.Empty; [Key(0)] [CultName] public string Name { get { Interlocked.Increment(ref Reads); if (Armed && _name == "boom") throw new InvalidOperationException("name getter boom"); return _name; } set => _name = value; } [Key(1)] [CultIndex("code")] public string Code { get { Interlocked.Increment(ref Reads); if (Armed && _code == "boom") throw new InvalidOperationException("index getter boom"); return _code; } set => _code = value; } } private static readonly CultRecordKey OkKey = new("projected:ok"); private static readonly CultRecordKey BoomKey = new("projected:boom"); private static ProjectedRecord Ok() => new() { Name = "ok", Code = "ok-code" }; private static ProjectedRecord Boom(bool name) => name ? new ProjectedRecord { Name = "boom", Code = "boom-code" } : new ProjectedRecord { Name = "boom-name", Code = "boom" }; private string PathOf(string name) => Path.Combine(_directory, name); private static CultCache Open(string path) => CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }); private static void Put(CultCache cache, ProjectedRecord document, CultRecordKey key) => cache.Commit(batch => batch.Upsert(typeof(ProjectedRecord), document, key)); private static long SequenceOf(CultCache cache) => cache.GetWithSequence(OkKey).Sequence; private static void StageBoth(CultCacheBatch batch, ProjectedRecord boom) { batch.Upsert(typeof(ProjectedRecord), Ok(), OkKey); batch.Upsert(typeof(ProjectedRecord), boom, BoomKey); } [TestCase(true, "[CultName] member Name")] [TestCase(false, "index 'code' member Code")] public void ARefusedGetterMintsNothingAndChangesNothing(bool nameThrows, string member) { using var cache = new CultCache(Registry); var seen = new List(); using var watch = cache.Watch().Subscribe(change => seen.Add(change.Sequence)); Put(cache, new ProjectedRecord { Name = "seed", Code = "seed-code" }, new CultRecordKey("projected:seed")); var before = SequenceOf(cache); seen.Clear(); ProjectedRecord.Armed = true; var refusal = Assert.Throws(() => cache.Commit(batch => StageBoth(batch, Boom(nameThrows))))!; ProjectedRecord.Armed = false; Assert.Multiple(() => { Assert.That(refusal.Message, Does.Contain(BoomKey.Value).And.Contain(member).And.Contain("getter boom")); Assert.That(SequenceOf(cache), Is.EqualTo(before), "a refused admission mints nothing"); Assert.That(cache.Get(OkKey), Is.Null, "the good record did not land"); Assert.That(cache.Get(BoomKey), Is.Null); Assert.That(cache.GetByName("ok"), Is.Null, "the name index holds nothing of the refused set"); Assert.That(cache.GetByIndex("code", "ok-code"), Is.Null); Assert.That(seen, Is.Empty, "nothing was published"); }); Put(cache, Ok(), OkKey); Assert.That(SequenceOf(cache), Is.EqualTo(before + 1), "the next write is previous + 1"); Assert.That(seen, Is.EqualTo(new[] { before + 1 })); } [Test] public void ARefusedSingleWriteMintsNothing() { using var cache = new CultCache(Registry); ProjectedRecord.Armed = true; var refusal = Assert.Throws(() => Put(cache, Boom(true), BoomKey))!; ProjectedRecord.Armed = false; Assert.That(refusal.Message, Does.Contain(BoomKey.Value)); Assert.That(SequenceOf(cache), Is.EqualTo(0)); Assert.That(cache.Get(BoomKey), Is.Null); } [Test] public void ARefusedBatchLeavesAStoreByteIdenticalAndReopensWithNeitherKey() { var path = PathOf("projected.cc"); using (var seed = Open(path)) Put(seed, new ProjectedRecord { Name = "seed", Code = "seed-code" }, new CultRecordKey("projected:seed")); var before = File.ReadAllBytes(path); using (var cache = Open(path)) { ProjectedRecord.Armed = true; Assert.Throws(() => cache.Commit(batch => StageBoth(batch, Boom(true)))); ProjectedRecord.Armed = false; Assert.That(File.ReadAllBytes(path), Is.EqualTo(before)); } using var reopened = Open(path); Assert.Multiple(() => { Assert.That(reopened.Get(OkKey), Is.Null); Assert.That(reopened.Get(BoomKey), Is.Null); Assert.That(File.ReadAllBytes(path), Is.EqualTo(before)); }); } [Test] public void ALoadWhoseGetterThrowsRefusesTheOpenAndLeavesTheFileByteIdentical() { var path = PathOf("poisoned.cc"); using (var seed = Open(path)) Put(seed, Boom(true), BoomKey); var before = File.ReadAllBytes(path); ProjectedRecord.Armed = true; var refusal = Assert.Throws(() => Open(path))!; ProjectedRecord.Armed = false; Assert.That(refusal.Message, Does.Contain(BoomKey.Value).And.Contain("[CultName] member Name")); Assert.That(File.ReadAllBytes(path), Is.EqualTo(before)); } // A counting getter: each landing record is read once for its name and once for its index, and never again by the // variant rules or the indexer. A variant re-resolved by a base edit lands too, and is read once. [Test] public void EachLandedRecordIsReadOncePerGetterIncludingAVariantDependent() { var codec = CultCacheMessagePack.CreateCodec(Registry); using var cache = new CultCache(Registry, codec); var baseKey = new CultRecordKey("projected:base"); Put(cache, new ProjectedRecord { Name = "base", Code = "base-code" }, baseKey); cache.Commit(batch => batch.UpsertVariant(new CultRecordKey("projected:variant"), baseKey, new[] { cache.Override(nameof(ProjectedRecord.Name), "variant"), cache.Override(nameof(ProjectedRecord.Code), "variant-code") })); // Resolving a variant serializes its base, and the serializer reads the same getters: count those reads apart. ProjectedRecord.Reads = 0; codec.Serialize(new ProjectedRecord(), typeof(ProjectedRecord), typeof(ProjectedRecord)); var serializerReads = Interlocked.Read(ref ProjectedRecord.Reads); ProjectedRecord.Reads = 0; Put(cache, new ProjectedRecord { Name = "base", Code = "base-code-2" }, baseKey); // Two landing records (the base, and the variant re-resolved against it), two getters each, plus the one serialization. Assert.That(Interlocked.Read(ref ProjectedRecord.Reads), Is.EqualTo(4 + serializerReads)); Assert.That(cache.GetByIndex("code", "base-code-2"), Is.Not.Null); Assert.That(cache.GetByName("variant"), Is.Not.Null); } // The index rule counts the holders a commit leaves behind: a record removed by the same commit no longer holds its value. [Test] public void AVariantMayTakeAnIndexedValueFromARecordTheSameCommitRemoves() { using var cache = new CultCache(Registry, CultCacheMessagePack.CreateCodec(Registry)); var baseKey = new CultRecordKey("projected:base"); var holderKey = new CultRecordKey("projected:holder"); Put(cache, new ProjectedRecord { Name = "base", Code = "base-code" }, baseKey); Put(cache, new ProjectedRecord { Name = "holder", Code = "shared" }, holderKey); var landed = cache.Commit(batch => { batch.Remove(holderKey); batch.UpsertVariant(new CultRecordKey("projected:variant"), baseKey, new[] { cache.Override(nameof(ProjectedRecord.Name), "variant"), cache.Override(nameof(ProjectedRecord.Code), "shared") }); }); Assert.That(landed, Is.True); Assert.That(cache.GetByIndex("code", "shared"), Is.Not.Null); Assert.That(cache.Get(holderKey), Is.Null); } // A blank name is no name: a variant and its base that are both blank do not shadow each other. [Test] public void ABlankNameIsNoNameSoBlankVariantAndBaseDoNotCollide() { using var cache = new CultCache(Registry, CultCacheMessagePack.CreateCodec(Registry)); var baseKey = new CultRecordKey("projected:base"); Put(cache, new ProjectedRecord { Name = "", Code = "base-code" }, baseKey); cache.Commit(batch => batch.UpsertVariant(new CultRecordKey("projected:variant"), baseKey, new[] { cache.Override(nameof(ProjectedRecord.Name), ""), cache.Override(nameof(ProjectedRecord.Code), "variant-code") })); Assert.That(cache.Get(new CultRecordKey("projected:variant")), Is.Not.Null); } } }