From ffdecf7e269b0f0629152e14a5db63369946d652 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 13 Sep 2026 17:57:58 +0200 Subject: [PATCH] Turn the store-routing tests green and cover routing, batches and loads Cut 1's fixtures are rewritten as the cut map names: LoadingNeverWritesASecondStore attaches its second store with a home type, and the global fixture is a real [CultGlobal] type instead of a reflected backing field. New: RoutedStoresWriteTheSameBytesAsSingleStores, CatalogRecordNeverLandsInRunStore, AttachAfterDirtyThrows, SecondUntypedStoreThrows, DuplicateHomeTypeThrows, WriteWithoutHomeThrows, ForeignRecordIsRefusedOnLoad, LateRouteOverAdmittedTypeThrows, ReadOnlyStoreRefusesWrites, ReadOnlyStoreIsNotFlushed, GlobalSingletonIsEnforced, AmbiguousAssignableLookupThrows, BatchIsAllOrNothingOnStoreFailure, BatchAcrossTwoHomesThrows, BatchObserversSeeOnlyCommittedRecords, BatchReadsSeeCommittedStateOnly, SingleUpsertAndBatchShareOneAdmissionPath, PullAllAdmissionFailureLeavesStoreAndCacheConsistent, StoreAttachedToSecondCacheThrows. Two readings where the map could not be followed literally: - LateRouteOverAdmittedTypeThrows: "the reverse order succeeds" cannot hold with the same untyped file holding a Note, because a Note loaded from the untyped store when a Note route exists is a foreign record. The reverse case puts the Note in the routed store and a RoutingOther in the untyped one. - BatchIsAllOrNothingOnStoreFailure: the fault is the store file held open without delete sharing, so the atomic replace fails, instead of a PushAll override; the single-file commit no longer routes through PushAll. Co-Authored-By: Claude Opus 5 --- .../StoreRoutingTests.cs | 465 +++++++++++++++++- 1 file changed, 450 insertions(+), 15 deletions(-) diff --git a/tests/GameCult.Caching.Tests/StoreRoutingTests.cs b/tests/GameCult.Caching.Tests/StoreRoutingTests.cs index d792fc38..62ac176d 100644 --- a/tests/GameCult.Caching.Tests/StoreRoutingTests.cs +++ b/tests/GameCult.Caching.Tests/StoreRoutingTests.cs @@ -2,7 +2,6 @@ using System; using System.IO; using System.Linq; -using System.Reflection; using System.Threading.Tasks; using GameCult.Caching; using GameCult.Caching.MessagePack; @@ -34,7 +33,7 @@ namespace GameCult.Caching.Tests [Test] public async Task LoadingNeverWritesASecondStore() { - var registry = CultDocumentRegistry.ForTypes(new[] { typeof(RoutingNote) }); + var registry = Registry(); var first = PathOf("a.cc"); var second = PathOf("b.cc"); await Seed(first, registry, new RoutingNote { Name = "note", Text = "seeded" }); @@ -42,7 +41,7 @@ namespace GameCult.Caching.Tests using var cache = new CultCache(registry); cache.AddBackingStore(new SingleFileMessagePackBackingStore(first)); await cache.PullAllBackingStoresAsync(); - cache.AddBackingStore(new SingleFileMessagePackBackingStore(second)); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(second), typeof(RoutingOther)); await cache.FlushAsync(); Assert.That(File.Exists(second), Is.False, "attaching a store and flushing without a mutation wrote it"); @@ -148,27 +147,426 @@ namespace GameCult.Caching.Tests Assert.That(changes, Is.Zero, "the store refused the write but the cache published a change"); } + [Test] + public async Task RoutedStoresWriteTheSameBytesAsSingleStores() + { + var registry = Registry(); + var note = new CultRecordKey("note"); + var other = new CultRecordKey("other"); + + using (var routed = new CultCache(registry)) + { + routed.AddBackingStore(new SingleFileMessagePackBackingStore(PathOf("catalog.cc")), typeof(RoutingNote)); + routed.AddBackingStore(new SingleFileMessagePackBackingStore(PathOf("run.cc")), typeof(RoutingOther)); + await routed.UpsertAsync(typeof(RoutingNote), new RoutingNote { Name = "n", Text = "catalog" }, note); + await routed.UpsertAsync(typeof(RoutingOther), new RoutingOther { Name = "o", Text = "run" }, other); + await routed.FlushAsync(); + } + + await Seed(PathOf("catalog-alone.cc"), registry, new RoutingNote { Name = "n", Text = "catalog" }, note); + await Seed(PathOf("run-alone.cc"), registry, new RoutingOther { Name = "o", Text = "run" }, other); + + Assert.That(Normalized(PathOf("catalog.cc")), Is.EqualTo(Normalized(PathOf("catalog-alone.cc")))); + Assert.That(Normalized(PathOf("run.cc")), Is.EqualTo(Normalized(PathOf("run-alone.cc")))); + } + + [Test] + public async Task CatalogRecordNeverLandsInRunStore() + { + var registry = Registry(); + using var cache = new CultCache(registry); + var catalog = new SingleFileMessagePackBackingStore(PathOf("catalog.cc")); + var run = new SingleFileMessagePackBackingStore(PathOf("run.cc")); + cache.AddBackingStore(catalog, typeof(RoutingNote)); + cache.AddBackingStore(run, typeof(RoutingOther)); + + await cache.UpsertAsync(new RoutingNote { Name = "n", Text = "catalog" }); + + Assert.That(catalog.IsDirty, Is.True); + Assert.That(run.IsDirty, Is.False); + await cache.FlushAsync(); + Assert.That(File.Exists(PathOf("run.cc")), Is.False); + Assert.That(Snapshot(PathOf("catalog.cc")).Records, Has.Length.EqualTo(1)); + } + + [Test] + public void AttachAfterDirtyThrows() + { + var registry = Registry(); + using var cache = new CultCache(registry); + var store = new SingleFileMessagePackBackingStore(PathOf("dirty.cc")); + store.Push(new CultStoredDocument(new CultRecordKey("staged"), FixedStoredAt, registry.GetRequired(), new RoutingNote())); + + Assert.That(() => cache.AddBackingStore(store), Throws.InvalidOperationException.With.Message.Contains("staged writes")); + Assert.That(cache.BackingStores, Is.Empty); + } + + [Test] + public void SecondUntypedStoreThrows() + { + using var cache = new CultCache(Registry()); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(PathOf("a.cc"))); + + Assert.That(() => cache.AddBackingStore(new SingleFileMessagePackBackingStore(PathOf("b.cc"))), + Throws.InvalidOperationException.With.Message.Contains("second untyped store")); + Assert.That(cache.BackingStores, Has.Count.EqualTo(1)); + } + + [Test] + public void DuplicateHomeTypeThrows() + { + using var cache = new CultCache(Registry()); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(PathOf("a.cc")), typeof(RoutingNote)); + + Assert.That(() => cache.AddBackingStore(new SingleFileMessagePackBackingStore(PathOf("b.cc")), typeof(RoutingNote)), + Throws.InvalidOperationException.With.Message.Contains("already routed")); + Assert.That(cache.BackingStores, Has.Count.EqualTo(1)); + } + + [Test] + public void WriteWithoutHomeThrows() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(RoutingNote), typeof(RoutingOther), typeof(RoutingGear) }); + using var cache = new CultCache(registry); + var notes = new SingleFileMessagePackBackingStore(PathOf("notes.cc")); + var gear = new SingleFileMessagePackBackingStore(PathOf("gear.cc")); + cache.AddBackingStore(notes, typeof(RoutingNote)); + cache.AddBackingStore(gear, typeof(RoutingGear)); + var key = new CultRecordKey("homeless"); + + Assert.That(async () => await cache.UpsertAsync(typeof(RoutingOther), new RoutingOther(), key), + Throws.InvalidOperationException.With.Message.Contains("No backing store is home")); + Assert.That(notes.IsDirty, Is.False); + Assert.That(gear.IsDirty, Is.False); + Assert.That(cache.Get(key), Is.Null); + } + + [Test] + public async Task ForeignRecordIsRefusedOnLoad() + { + var registry = Registry(); + var runPath = PathOf("run.cc"); + var key = await Seed(runPath, registry, new RoutingNote { Name = "n", Text = "misplaced" }); + using var cache = new CultCache(registry); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(PathOf("catalog.cc")), typeof(RoutingNote)); + + var refusal = Assert.Throws(() => + cache.AddBackingStore(new SingleFileMessagePackBackingStore(runPath), typeof(RoutingOther))); + + Assert.That(refusal!.Message, Does.Contain(key.Value).And.Contain(runPath).And.Contain(PathOf("catalog.cc"))); + Assert.That(cache.AllEntries, Is.Empty); + Assert.That(cache.BackingStores, Has.Count.EqualTo(1)); + } + + [Test] + public async Task LateRouteOverAdmittedTypeThrows() + { + var registry = Registry(); + var untypedPath = PathOf("untyped.cc"); + var routedPath = PathOf("notes.cc"); + var key = await Seed(untypedPath, registry, new RoutingNote { Name = "n", Text = "admitted" }); + + using (var cache = new CultCache(registry)) + { + var untyped = new SingleFileMessagePackBackingStore(untypedPath); + cache.AddBackingStore(untyped); + + var refusal = Assert.Throws(() => + cache.AddBackingStore(new SingleFileMessagePackBackingStore(routedPath), typeof(RoutingNote))); + + Assert.That(refusal!.Message, Does.Contain(untypedPath).And.Contain(routedPath)); + Assert.That(cache.Get(key)?.Text, Is.EqualTo("admitted")); + Assert.That(cache.AllEntries.Count(), Is.EqualTo(1)); + Assert.That(untyped.IsDirty, Is.False); + } + + // Routed stores first, the untyped store last. The untyped store holds a type no route claims; a Note in it + // would be a foreign record (ForeignRecordIsRefusedOnLoad). + File.Move(untypedPath, routedPath); + var otherKey = await Seed(untypedPath, registry, new RoutingOther { Name = "o", Text = "untyped" }); + using var ordered = new CultCache(registry); + ordered.AddBackingStore(new SingleFileMessagePackBackingStore(routedPath), typeof(RoutingNote)); + Assert.That(() => ordered.AddBackingStore(new SingleFileMessagePackBackingStore(untypedPath)), Throws.Nothing); + Assert.That(ordered.Get(key)?.Text, Is.EqualTo("admitted")); + Assert.That(ordered.Get(otherKey)?.Text, Is.EqualTo("untyped")); + } + + [Test] + public async Task ReadOnlyStoreRefusesWrites() + { + var registry = Registry(); + var path = PathOf("catalog.cc"); + await Seed(path, registry, new RoutingNote { Name = "n", Text = "authored" }); + var before = File.ReadAllBytes(path); + using var cache = new CultCache(registry); + var store = new SingleFileMessagePackBackingStore(path, readOnly: true); + cache.AddBackingStore(store); + var key = new CultRecordKey("new"); + + Assert.That(async () => await cache.UpsertAsync(typeof(RoutingNote), new RoutingNote(), key), + Throws.InvalidOperationException.With.Message.Contains("read-only")); + Assert.That(() => store.PushAll(), Throws.InvalidOperationException.With.Message.Contains(path)); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before)); + Assert.That(cache.Get(key), Is.Null); + Assert.That(cache.IsDirty, Is.False); + Assert.That(File.Exists(path + ".lock"), Is.False); + } + + [Test] + public async Task ReadOnlyStoreIsNotFlushed() + { + var registry = Registry(); + var catalogPath = PathOf("catalog.cc"); + await Seed(catalogPath, registry, new RoutingNote { Name = "n", Text = "authored" }); + var before = File.ReadAllBytes(catalogPath); + using var cache = new CultCache(registry); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalogPath, readOnly: true), typeof(RoutingNote)); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(PathOf("run.cc")), typeof(RoutingOther)); + + await cache.UpsertAsync(new RoutingOther { Name = "o", Text = "run" }); + await cache.FlushAsync(); + + Assert.That(File.ReadAllBytes(catalogPath), Is.EqualTo(before)); + Assert.That(File.Exists(catalogPath + ".lock"), Is.False); + Assert.That(Snapshot(PathOf("run.cc")).Records, Has.Length.EqualTo(1)); + Assert.That(cache.IsDirty, Is.False); + } + + [Test] + public async Task GlobalSingletonIsEnforced() + { + var registry = GlobalRegistry(); + using (var cache = new CultCache(registry)) + { + await cache.UpsertAsync(new RoutingGlobal { Value = "first" }); + + Assert.That(async () => await cache.UpsertAsync(typeof(RoutingGlobal), new RoutingGlobal { Value = "second" }, new CultRecordKey("second")), + Throws.InvalidOperationException.With.Message.Contains("is a global")); + Assert.That(cache.AllEntries.Count(), Is.EqualTo(1)); + } + + var path = PathOf("two-globals.cc"); + WriteRecords(path, registry, + ("global:one", FixedStoredAt, new RoutingGlobal { Value = "one" }), + ("global:two", FixedStoredAt, new RoutingGlobal { Value = "two" })); + using var reader = new CultCache(registry); + Assert.That(() => reader.AddBackingStore(new SingleFileMessagePackBackingStore(path)), + Throws.InvalidOperationException.With.Message.Contains("is a global")); + Assert.That(reader.AllEntries, Is.Empty); + } + + [Test] + public async Task AmbiguousAssignableLookupThrows() + { + using var cache = new CultCache(CultDocumentRegistry.ForTypes(new[] { typeof(RoutingLeaf), typeof(RoutingLeafTwin) })); + var leaf = await cache.UpsertAsync(typeof(RoutingLeaf), new RoutingLeaf { Name = "alpha" }); + var twin = await cache.UpsertAsync(typeof(RoutingLeafTwin), new RoutingLeafTwin { Name = "alpha" }); + + Assert.That(() => cache.GetByName("alpha"), + Throws.InvalidOperationException.With.Message.Contains(leaf.Value).And.Message.Contains(twin.Value)); + Assert.That(cache.GetByName("alpha"), Is.Not.Null); + } + + [Test] + public void BatchIsAllOrNothingOnStoreFailure() + { + var registry = Registry(); + var path = PathOf("batch.cc"); + using var cache = new CultCache(registry); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(path)); + cache.Commit(batch => batch.Upsert(new RoutingNote { Name = "a" }, new CultRecordHandle(new CultRecordKey("a")))); + var before = File.ReadAllBytes(path); + var changes = 0; + using var subscription = cache.Watch().Subscribe(_ => changes++); + + using (new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + Assert.That(() => cache.Commit(batch => + { + batch.Upsert(new RoutingNote { Name = "b1" }, new CultRecordHandle(new CultRecordKey("b1"))); + batch.Upsert(new RoutingNote { Name = "b2" }, new CultRecordHandle(new CultRecordKey("b2"))); + }), Throws.InstanceOf().Or.InstanceOf()); + } + + Assert.That(cache.Get(new CultRecordKey("b1")), Is.Null); + Assert.That(cache.Get(new CultRecordKey("b2")), Is.Null); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before)); + Assert.That(cache.IsDirty, Is.False); + Assert.That(changes, Is.Zero); + } + + [Test] + public void BatchAcrossTwoHomesThrows() + { + var registry = Registry(); + using var cache = new CultCache(registry); + var catalog = new SingleFileMessagePackBackingStore(PathOf("catalog.cc")); + var run = new SingleFileMessagePackBackingStore(PathOf("run.cc")); + cache.AddBackingStore(catalog, typeof(RoutingNote)); + cache.AddBackingStore(run, typeof(RoutingOther)); + + Assert.That(() => cache.Commit(batch => + { + batch.Upsert(new RoutingNote { Name = "n" }, new CultRecordHandle(new CultRecordKey("n"))); + batch.Upsert(new RoutingOther { Name = "o" }, new CultRecordHandle(new CultRecordKey("o"))); + }), Throws.InvalidOperationException.With.Message.Contains("one home store")); + + Assert.That(catalog.IsDirty, Is.False); + Assert.That(run.IsDirty, Is.False); + Assert.That(cache.Get(new CultRecordKey("n")), Is.Null); + Assert.That(cache.Get(new CultRecordKey("o")), Is.Null); + Assert.That(Directory.GetFiles(_directory), Is.Empty); + } + + [Test] + public void BatchObserversSeeOnlyCommittedRecords() + { + var registry = Registry(); + using var cache = new CultCache(registry); + var changes = 0; + var changesWhenStoreCommitted = -1; + cache.AddBackingStore(new ObservingStore(PathOf("observed.cc"), () => changesWhenStoreCommitted = changes)); + using var subscription = cache.Watch().Subscribe(_ => changes++); + + cache.Commit(batch => + { + batch.Upsert(new RoutingNote { Name = "one" }, new CultRecordHandle(new CultRecordKey("one"))); + batch.Upsert(new RoutingNote { Name = "two" }, new CultRecordHandle(new CultRecordKey("two"))); + Assert.That(changes, Is.Zero); + }); + + Assert.That(changesWhenStoreCommitted, Is.Zero, "a change was published before the store committed"); + Assert.That(changes, Is.EqualTo(2)); + Assert.That(() => cache.Commit(batch => + { + batch.Upsert(new RoutingNote { Name = "three" }, new CultRecordHandle(new CultRecordKey("three"))); + throw new InvalidOperationException("abandoned"); + }), Throws.InvalidOperationException); + Assert.That(changes, Is.EqualTo(2)); + } + + [Test] + public async Task BatchReadsSeeCommittedStateOnly() + { + using var cache = new CultCache(Registry()); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(PathOf("reads.cc"))); + var key = await cache.UpsertAsync(typeof(RoutingNote), new RoutingNote { Name = "n", Text = "committed" }); + await cache.FlushAsync(); + + cache.Commit(batch => + { + batch.Upsert(typeof(RoutingNote), new RoutingNote { Name = "n", Text = "staged" }, key); + Assert.That(cache.Get(key)?.Text, Is.EqualTo("committed")); + }); + + Assert.That(cache.Get(key)?.Text, Is.EqualTo("staged")); + } + + [Test] + public async Task SingleUpsertAndBatchShareOneAdmissionPath() + { + using var cache = new CultCache(GlobalRegistry()); + await cache.UpsertAsync(new RoutingGlobal { Value = "first" }); + var second = new CultRecordKey("second"); + + var single = Assert.Throws(() => + cache.UpsertAsync(typeof(RoutingGlobal), new RoutingGlobal { Value = "second" }, second)); + var batched = Assert.Throws(() => + cache.Commit(batch => batch.Upsert(typeof(RoutingGlobal), new RoutingGlobal { Value = "second" }, second))); + + Assert.That(batched!.Message, Is.EqualTo(single!.Message)); + Assert.That(cache.Get(second), Is.Null); + } + + [Test] + public void PullAllAdmissionFailureLeavesStoreAndCacheConsistent() + { + var registry = GlobalRegistry(typeof(RoutingNote)); + var path = PathOf("pull.cc"); + WriteRecords(path, registry, + ("note:1", "2026-01-01T00:00:00.0000000+00:00", new RoutingNote { Name = "1", Text = "old" }), + ("global:one", FixedStoredAt, new RoutingGlobal { Value = "one" })); + using var cache = new CultCache(registry); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(path)); + + WriteRecords(path, registry, + ("note:1", "2026-01-02T00:00:00.0000000+00:00", new RoutingNote { Name = "1", Text = "new" }), + ("note:2", "2026-01-02T00:00:00.0000000+00:00", new RoutingNote { Name = "2", Text = "arrived" }), + ("global:one", FixedStoredAt, new RoutingGlobal { Value = "one" }), + ("global:two", FixedStoredAt, new RoutingGlobal { Value = "two" })); + Assert.That(async () => await cache.PullAllBackingStoresAsync(), Throws.InvalidOperationException); + Assert.That(cache.Get(new CultRecordKey("note:1"))?.Text, Is.EqualTo("old")); + Assert.That(cache.Get(new CultRecordKey("note:2")), Is.Null); + + WriteRecords(path, registry, + ("note:1", "2026-01-02T00:00:00.0000000+00:00", new RoutingNote { Name = "1", Text = "new" }), + ("note:2", "2026-01-02T00:00:00.0000000+00:00", new RoutingNote { Name = "2", Text = "arrived" }), + ("global:one", FixedStoredAt, new RoutingGlobal { Value = "one" })); + cache.PullAllBackingStoresAsync(); + Assert.That(cache.Get(new CultRecordKey("note:1"))?.Text, Is.EqualTo("new"), + "the refused pull left the store believing it already held the new record"); + Assert.That(cache.Get(new CultRecordKey("note:2"))?.Text, Is.EqualTo("arrived")); + } + + [Test] + public void StoreAttachedToSecondCacheThrows() + { + var registry = Registry(); + var store = new SingleFileMessagePackBackingStore(PathOf("shared.cc")); + using var first = new CultCache(registry); + using var second = new CultCache(registry); + first.AddBackingStore(store); + + Assert.That(() => second.AddBackingStore(store), Throws.InvalidOperationException.With.Message.Contains("already attached")); + Assert.That(second.BackingStores, Is.Empty); + } + + private const string FixedStoredAt = "2026-09-13T00:00:00.0000000+00:00"; + private string PathOf(string fileName) => Path.Combine(_directory, fileName); - private static async Task Seed(string path, CultDocumentRegistry registry, object document) + private static CultDocumentRegistry Registry() => + CultDocumentRegistry.ForTypes(new[] { typeof(RoutingNote), typeof(RoutingOther) }); + + private static CultDocumentRegistry GlobalRegistry(params Type[] others) => + CultDocumentRegistry.ForTypes(new[] { typeof(RoutingGlobal) }.Concat(others)); + + private static async Task Seed(string path, CultDocumentRegistry registry, object document, CultRecordKey? key = null) { using var cache = new CultCache(registry); cache.AddBackingStore(new SingleFileMessagePackBackingStore(path)); - var key = await cache.UpsertAsync(document.GetType(), document); + var written = await cache.UpsertAsync(document.GetType(), document, key); cache.FlushAllBackingStores(); - return key; + File.Delete(path + ".lock"); + return written; } - // A [CultGlobal] type in this assembly would be discovered by CultDocumentRegistry.Shared, and today's - // constructor would invent it inside every other test's cache. The flag lives on a private registry instead. - private static CultDocumentRegistry GlobalRegistry(params Type[] others) + private static void WriteRecords(string path, CultDocumentRegistry registry, params (string Key, string StoredAt, object Document)[] records) { - var registry = CultDocumentRegistry.ForTypes(new[] { typeof(RoutingGlobal) }.Concat(others)); - typeof(CultDocumentDescriptor) - .GetField("k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic)! - .SetValue(registry.GetRequired(), true); - Assert.That(registry.GetRequired().IsGlobal, Is.True, "precondition: the global flag did not take"); - return registry; + var descriptors = records.Select(record => registry.GetRequired(record.Document.GetType())).ToArray(); + var snapshot = new CultPersistedStoreSnapshot + { + SchemaCatalog = descriptors.Distinct().Select(descriptor => descriptor.ToCatalogEntry()).ToArray(), + Records = records.Select((record, index) => new CultPersistedRecord + { + Key = record.Key, + SchemaId = descriptors[index].SchemaId, + StoredAt = record.StoredAt, + Payload = CultDocumentMessagePackSerialization.SerializeUntyped(record.Document, record.Document.GetType(), registry) + }).ToArray() + }; + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + } + + private static CultPersistedStoreSnapshot Snapshot(string path) => + CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + + private static byte[] Normalized(string path) + { + var snapshot = Snapshot(path); + foreach (var record in snapshot.Records) + record.StoredAt = FixedStoredAt; + return CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot); } private sealed class RefusingStore : SingleFileMessagePackBackingStore @@ -181,6 +579,23 @@ namespace GameCult.Caching.Tests throw new InvalidOperationException("RefusingStore refuses every write."); } + private sealed class ObservingStore : SingleFileMessagePackBackingStore + { + private readonly Action _committed; + + public ObservingStore(string filePath, Action committed) : base(filePath) + { + _committed = committed; + } + + public override CultCommitOutcome CommitBatch(CultCommitRequest request, bool wait) + { + var outcome = base.CommitBatch(request, wait); + _committed(); + return outcome; + } + } + [CultDocument("tests.routing_note", "tests.routing_note.v1")] internal sealed class RoutingNote { @@ -192,6 +607,17 @@ namespace GameCult.Caching.Tests public string Text = string.Empty; } + [CultDocument("tests.routing_other", "tests.routing_other.v1")] + internal sealed class RoutingOther + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + public string Text = string.Empty; + } + // RoutingWeapon's own slots are not dense, so it gets no generated codec and serializes through // MessagePack's dynamic resolver, which needs a public [MessagePackObject]. [CultDocument("tests.routing_gear", "tests.routing_gear.v1")] @@ -222,11 +648,20 @@ namespace GameCult.Caching.Tests public string Name = string.Empty; } + [CultDocument("tests.routing_leaf_twin", "tests.routing_leaf_twin.v1")] + internal sealed class RoutingLeafTwin : RoutingBase + { + [Key(0)] + [CultName] + public string Name = string.Empty; + } + internal abstract class RoutingGlobalBase { } [CultDocument("tests.routing_global", "tests.routing_global.v1")] + [CultGlobal] internal sealed class RoutingGlobal : RoutingGlobalBase { [Key(0)]