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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-13 17:57:58 +02:00
commit ffdecf7e26

View file

@ -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<RoutingNote>(), 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<InvalidOperationException>(() =>
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<InvalidOperationException>(() =>
cache.AddBackingStore(new SingleFileMessagePackBackingStore(routedPath), typeof(RoutingNote)));
Assert.That(refusal!.Message, Does.Contain(untypedPath).And.Contain(routedPath));
Assert.That(cache.Get<RoutingNote>(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<RoutingNote>(key)?.Text, Is.EqualTo("admitted"));
Assert.That(ordered.Get<RoutingOther>(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<RoutingBase>("alpha"),
Throws.InvalidOperationException.With.Message.Contains(leaf.Value).And.Message.Contains(twin.Value));
Assert.That(cache.GetByName<RoutingLeaf>("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<RoutingNote>(new CultRecordKey("a"))));
var before = File.ReadAllBytes(path);
var changes = 0;
using var subscription = cache.Watch<RoutingNote>().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<RoutingNote>(new CultRecordKey("b1")));
batch.Upsert(new RoutingNote { Name = "b2" }, new CultRecordHandle<RoutingNote>(new CultRecordKey("b2")));
}), Throws.InstanceOf<IOException>().Or.InstanceOf<UnauthorizedAccessException>());
}
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<RoutingNote>(new CultRecordKey("n")));
batch.Upsert(new RoutingOther { Name = "o" }, new CultRecordHandle<RoutingOther>(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<RoutingNote>().Subscribe(_ => changes++);
cache.Commit(batch =>
{
batch.Upsert(new RoutingNote { Name = "one" }, new CultRecordHandle<RoutingNote>(new CultRecordKey("one")));
batch.Upsert(new RoutingNote { Name = "two" }, new CultRecordHandle<RoutingNote>(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<RoutingNote>(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<RoutingNote>(key)?.Text, Is.EqualTo("committed"));
});
Assert.That(cache.Get<RoutingNote>(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<InvalidOperationException>(() =>
cache.UpsertAsync(typeof(RoutingGlobal), new RoutingGlobal { Value = "second" }, second));
var batched = Assert.Throws<InvalidOperationException>(() =>
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<RoutingNote>(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<RoutingNote>(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<RoutingNote>(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<CultRecordKey> 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<CultRecordKey> 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("<IsGlobal>k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic)!
.SetValue(registry.GetRequired<RoutingGlobal>(), true);
Assert.That(registry.GetRequired<RoutingGlobal>().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)]