CultLib/tests/GameCult.Caching.Tests/RestampOnWriteTests.cs
Meta Vens 10d012186a Test: owned drift variants run on the single-file store with a codec
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 06:15:10 +01:00

509 lines
27 KiB
C#

#nullable enable
using System;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Threading.Tasks;
using GameCult.Caching.MessagePack;
using MessagePack;
using NUnit.Framework;
namespace GameCult.Caching.Tests
{
// variants:ruling:drift-record-write-ownership: a writer owns a record stored under id X when it declares X, or when the type
// that reads X reads it whole; it then writes, relabels and removes it, and a write stores it under the type's own id. A record
// a type reads only by dropping persisted slots stays read-only until its id is declared compatible.
public class RestampOnWriteTests
{
// One schema name at three versions of its type and nothing declared: Grown reads Narrow's records whole (it adds a slot),
// and reads Wide's records only by dropping slot 3.
[CultDocument("tests.restamp_note", "tests.restamp_note.v1")]
[MessagePackObject(AllowPrivate = true)]
internal sealed class NoteNarrow
{
[Key(0)] [CultName] public string Name = string.Empty;
[Key(1)] public int Value;
}
[CultDocument("tests.restamp_note", "tests.restamp_note.v2")]
[MessagePackObject(AllowPrivate = true)]
internal sealed class NoteGrown
{
[Key(0)] [CultName] public string Name = string.Empty;
[Key(1)] public int Value;
[Key(2)] public string Colour = string.Empty;
}
[CultDocument("tests.restamp_note", "tests.restamp_note.v3")]
[MessagePackObject(AllowPrivate = true)]
internal sealed class NoteWide
{
[Key(0)] [CultName] public string Name = string.Empty;
[Key(1)] public int Value;
[Key(2)] public string Colour = string.Empty;
[Key(3)] public string Extra = string.Empty;
}
// Another schema with Narrow's exact shape: never the same type, whatever the members say.
[CultDocument("tests.restamp_other", "tests.restamp_other.v1")]
[MessagePackObject(AllowPrivate = true)]
internal sealed class NoteOther
{
[Key(0)] [CultName] public string Name = string.Empty;
[Key(1)] public int Value;
}
// Grown with the lossy id declared: declaration is the consent to the loss.
[CultDocument("tests.restamp_note", "tests.restamp_note.v4", CompatibleSchemaIds = new[] { WideId })]
[MessagePackObject(AllowPrivate = true)]
internal sealed class NoteGrownDeclaring
{
[Key(0)] [CultName] public string Name = string.Empty;
[Key(1)] public int Value;
[Key(2)] public string Colour = string.Empty;
}
private const string WideId = "sha256:82c2b721aceb56e00288baab590fabcdd82c3bda44400f7530fa9e29c4e96252";
private static readonly CultDocumentRegistry NarrowRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(NoteNarrow) });
private static readonly CultDocumentRegistry GrownRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(NoteGrown) });
private static readonly CultDocumentRegistry WideRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(NoteWide) });
private static readonly CultDocumentRegistry DeclaringRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(NoteGrownDeclaring) });
private static readonly CultRecordKey Note = new("note");
private static readonly CultRecordKey Other = new("other");
private string _directory = string.Empty;
[SetUp]
public void CreateDirectory()
{
_directory = Path.Combine(Path.GetTempPath(), $"cultlib-restamp-{Guid.NewGuid():N}");
Directory.CreateDirectory(_directory);
}
[TearDown]
public void DeleteDirectory()
{
if (Directory.Exists(_directory))
Directory.Delete(_directory, recursive: true);
}
public static readonly object[] StoreKinds = { false, true };
private CacheBackingStore Store(string path, bool directory) =>
directory ? new DirectoryMessagePackBackingStore(path) : new SingleFileMessagePackBackingStore(path);
private CultCache Open(string path, bool directory, CultDocumentRegistry registry)
{
var cache = new CultCache(registry);
cache.AddBackingStore(Store(path, directory));
return cache;
}
// The schema id each key is stored under, read back through a fresh cache that declares everything it sees.
private string StoredIdOf(string path, bool directory, CultDocumentRegistry registry, CultRecordKey key)
{
using var cache = Open(path, directory, registry);
return cache.GetStored(key)!.StoredSchemaId;
}
private static string Fingerprint(string path)
{
using var sha = SHA256.Create();
if (File.Exists(path))
return Convert.ToHexString(sha.ComputeHash(File.ReadAllBytes(path)));
var parts = Directory.GetFiles(path, "*", SearchOption.AllDirectories)
.OrderBy(file => file, StringComparer.Ordinal)
.Select(file => Path.GetRelativePath(path, file) + ":" + Convert.ToHexString(sha.ComputeHash(File.ReadAllBytes(file))));
return string.Join("|", parts);
}
// Two records written by a writer that knew Narrow: the one under test and a bystander.
private string SeedNarrow(bool directory)
{
var path = Path.Combine(_directory, directory ? "store" : "store.cc");
using var cache = Open(path, directory, NarrowRegistry);
Assert.That(cache.Commit(batch =>
{
batch.Upsert(new NoteNarrow { Name = "note", Value = 1 }, new CultRecordHandle<NoteNarrow>(Note));
batch.Upsert(new NoteNarrow { Name = "other", Value = 7 }, new CultRecordHandle<NoteNarrow>(Other));
}), Is.True);
return path;
}
private string SeedWide(bool directory)
{
var path = Path.Combine(_directory, directory ? "store" : "store.cc");
using var cache = Open(path, directory, WideRegistry);
Assert.That(cache.Commit(batch => batch.Upsert(new NoteWide { Name = "note", Value = 1, Extra = "kept?" }, new CultRecordHandle<NoteWide>(Note))), Is.True);
return path;
}
[TestCaseSource(nameof(StoreKinds))]
public void ADriftRecordReadWholeIsWrittenUnderItsTypesId(bool directory)
{
var path = SeedNarrow(directory);
var narrowId = NarrowRegistry.GetRequired<NoteNarrow>().SchemaId;
var grownId = GrownRegistry.GetRequired<NoteGrown>().SchemaId;
Assert.That(StoredIdOf(path, directory, NarrowRegistry, Note), Is.EqualTo(narrowId));
using (var cache = Open(path, directory, GrownRegistry))
{
Assert.That(cache.Get<NoteGrown>(Note)!.Value, Is.EqualTo(1));
Assert.That(cache.Commit(batch => batch.Upsert(new NoteGrown { Name = "note", Value = 2, Colour = "red" }, new CultRecordHandle<NoteGrown>(Note))), Is.True);
}
Assert.That(StoredIdOf(path, directory, GrownRegistry, Note), Is.EqualTo(grownId), "the written record moved to its type's id");
Assert.That(StoredIdOf(path, directory, NarrowRegistry, Other), Is.EqualTo(narrowId), "the bystander kept its persisted id (write set)");
using var reopened = Open(path, directory, GrownRegistry);
Assert.That(reopened.Get<NoteGrown>(Note)!.Colour, Is.EqualTo("red"));
Assert.That(reopened.Get<NoteGrown>(Other)!.Value, Is.EqualTo(7));
}
[TestCaseSource(nameof(StoreKinds))]
public void RemovingADriftRecordReadWholeSucceeds(bool directory)
{
var path = SeedNarrow(directory);
using (var cache = Open(path, directory, GrownRegistry))
Assert.That(cache.Commit(batch => batch.Remove(Note)), Is.True);
using var reopened = Open(path, directory, GrownRegistry);
Assert.That(reopened.Get<NoteGrown>(Note), Is.Null);
Assert.That(reopened.Get<NoteGrown>(Other), Is.Not.Null);
}
[TestCaseSource(nameof(StoreKinds))]
public void ADriftRecordReadOnlyByDroppingASlotStaysReadOnly(bool directory)
{
var path = SeedWide(directory);
var before = Fingerprint(path);
using var cache = Open(path, directory, GrownRegistry);
Assert.That(cache.Get<NoteGrown>(Note), Is.Not.Null, "a read-only record is still served");
var upsert = Assert.Throws<CultSchemaConflictException>(() =>
cache.Commit(batch => batch.Upsert(new NoteGrown { Name = "note", Value = 2 }, new CultRecordHandle<NoteGrown>(Note))))!;
Assert.That(upsert.RecordKey, Is.EqualTo("note"));
Assert.That(upsert.SchemaId, Is.EqualTo(WideRegistry.GetRequired<NoteWide>().SchemaId));
Assert.That(upsert.Message, Does.Contain("would drop persisted slots").And.Contain("to accept the loss."), "the refusal names the narrowed rule");
var remove = Assert.Throws<CultSchemaConflictException>(() => cache.Commit(batch => batch.Remove(Note)))!;
Assert.That(remove.RecordKey, Is.EqualTo("note"));
Assert.That(Fingerprint(path), Is.EqualTo(before), "a refused write leaves the store as it was");
}
// A held record read only by dropping a slot is refused before anything is staged: with the store's lock held by another
// writer, the write is refused typed instead of waiting on the lock and reporting contention.
[TestCaseSource(nameof(StoreKinds))]
public void ALossyDriftRecordIsRefusedBeforeTheStoresLockIsAsked(bool directory)
{
var path = SeedWide(directory);
using var cache = Open(path, directory, GrownRegistry);
using var held = directory
? new FileStream(Path.Combine(DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(path), ".commit.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None)
: new FileStream(path + ".lock", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
Assert.Throws<CultSchemaConflictException>(() =>
cache.TryCommit(batch => batch.Upsert(new NoteGrown { Name = "note", Value = 2 }, new CultRecordHandle<NoteGrown>(Note))));
}
[TestCaseSource(nameof(StoreKinds))]
public void ADeclaredLossyDriftRecordIsWritable(bool directory)
{
var path = SeedWide(directory);
var wideId = WideRegistry.GetRequired<NoteWide>().SchemaId;
Assert.That(wideId, Is.EqualTo(WideId), "the declaration names the lossy record's id: " + wideId);
using (var cache = Open(path, directory, DeclaringRegistry))
Assert.That(cache.Commit(batch => batch.Upsert(new NoteGrownDeclaring { Name = "note", Value = 2 }, new CultRecordHandle<NoteGrownDeclaring>(Note))), Is.True);
Assert.That(StoredIdOf(path, directory, DeclaringRegistry, Note), Is.EqualTo(DeclaringRegistry.GetRequired<NoteGrownDeclaring>().SchemaId));
}
// Another writer rewrites the record after this cache loaded it: ownership is judged again under the store's lock.
[TestCaseSource(nameof(StoreKinds))]
public void AnotherWritersDriftRecordIsJudgedUnderTheLock(bool directory)
{
var path = SeedNarrow(directory);
using var cache = Open(path, directory, GrownRegistry);
Assert.That(cache.Get<NoteGrown>(Note), Is.Not.Null);
// A writer that knows Wide replaces the record: this cache's loaded copy was read whole, the durable one is not.
using (var other = Open(path, directory, WideRegistry))
{
Assert.That(other.Commit(batch => batch.Upsert(new NoteWide { Name = "note", Value = 3, Extra = "x" }, new CultRecordHandle<NoteWide>(Note))), Is.True);
}
var refusal = Assert.Throws<CultSchemaConflictException>(() =>
cache.Commit(batch => batch.Upsert(new NoteGrown { Name = "note", Value = 4 }, new CultRecordHandle<NoteGrown>(Note))))!;
Assert.That(refusal.RecordKey, Is.EqualTo("note"));
Assert.That(StoredIdOf(path, directory, WideRegistry, Note), Is.EqualTo(WideRegistry.GetRequired<NoteWide>().SchemaId));
}
[Test]
public void ACachelessWriteRestampsADriftRecordOfItsSchema()
{
var path = SeedNarrow(directory: false);
new SingleFileMessagePackBackingStore(path).WriteDocument(Note, new NoteGrown { Name = "note", Value = 2 }, registry: GrownRegistry);
Assert.That(StoredIdOf(path, false, GrownRegistry, Note), Is.EqualTo(GrownRegistry.GetRequired<NoteGrown>().SchemaId));
Assert.That(StoredIdOf(path, false, NarrowRegistry, Other), Is.EqualTo(NarrowRegistry.GetRequired<NoteNarrow>().SchemaId));
}
[Test]
public void ACachelessWriteOverALossyDriftRecordIsRefusedAndTheFileIsUntouched()
{
var path = SeedWide(directory: false);
var before = Fingerprint(path);
var refusal = Assert.Throws<CultSchemaConflictException>(() =>
new SingleFileMessagePackBackingStore(path).WriteDocument(Note, new NoteGrown { Name = "note", Value = 2 }, registry: GrownRegistry))!;
Assert.That(refusal.Message, Does.Contain("reads it whole").And.Contain("to accept the loss of its unread slots."), "the refusal names the narrowed rule");
Assert.That(Fingerprint(path), Is.EqualTo(before));
}
// A cacheless writer's entry that lists the record's id compatible consents to the loss, as a cache's declaration does.
[Test]
public void ACachelessWriteByAnEntryListingTheIdCompatibleOverwritesALossyDriftRecord()
{
var path = SeedWide(directory: false);
new SingleFileMessagePackBackingStore(path).WriteDocument(Note, new NoteGrownDeclaring { Name = "note", Value = 2 }, registry: DeclaringRegistry);
Assert.That(StoredIdOf(path, false, DeclaringRegistry, Note), Is.EqualTo(DeclaringRegistry.GetRequired<NoteGrownDeclaring>().SchemaId));
}
// The type that would read the id cannot be named (two registered versions share the schema name and the id declares neither),
// so the record is not owned and a write over it is refused typed, not as the resolver's own error.
[TestCaseSource(nameof(StoreKinds))]
public void ARecordNoRegisteredTypeCanBeNamedForIsNotOwned(bool directory)
{
var path = SeedNarrow(directory);
var both = CultDocumentRegistry.ForTypes(new[] { typeof(NoteNarrow), typeof(NoteGrown) });
using var cache = Open(path, directory, both);
using (var other = Open(path, directory, WideRegistry))
Assert.That(other.Commit(batch => batch.Upsert(new NoteWide { Name = "note", Value = 3 }, new CultRecordHandle<NoteWide>(Note))), Is.True);
Assert.Throws<CultSchemaConflictException>(() =>
cache.Commit(batch => batch.Upsert(new NoteGrown { Name = "note", Value = 4 }, new CultRecordHandle<NoteGrown>(Note))));
}
[TestCaseSource(nameof(StoreKinds))]
public void ExpectAgreesWithRestamp(bool directory)
{
var path = SeedNarrow(directory);
var third = new CultRecordKey("third");
using var cache = Open(path, directory, GrownRegistry);
Assert.That(cache.Commit(batch =>
{
batch.Expect(Note, cache.Get(Note));
batch.ExpectUnchanged();
batch.Upsert(new NoteGrown { Name = "third" }, new CultRecordHandle<NoteGrown>(third));
}), Is.True, "an unchanged drift record satisfies Expect and ExpectUnchanged");
using var second = Open(path, directory, GrownRegistry);
Assert.That(cache.Commit(batch =>
{
batch.Expect(Note, cache.Get(Note));
batch.Upsert(new NoteGrown { Name = "note", Value = 2 }, new CultRecordHandle<NoteGrown>(Note));
}), Is.True, "a batch that Expects the drift record and upserts it commits");
Assert.That(StoredIdOf(path, directory, GrownRegistry, Note), Is.EqualTo(GrownRegistry.GetRequired<NoteGrown>().SchemaId), "and restamps it");
Assert.That(cache.Commit(batch =>
{
batch.ExpectUnchanged();
batch.Upsert(new NoteGrown { Name = "fourth" }, new CultRecordHandle<NoteGrown>(new CultRecordKey("fourth")));
}), Is.True, "the writer's later ExpectUnchanged holds");
Assert.That(second.Commit(batch =>
{
batch.Expect(Note, second.Get(Note));
batch.Upsert(new NoteGrown { Name = "note", Value = 3 }, new CultRecordHandle<NoteGrown>(Note));
}), Is.False, "a cache loaded before the restamp fails its Expect: the bytes changed");
}
private static readonly CultRecordKey Base = new("base");
private static readonly CultRecordKey Delta = new("delta");
private CultCache OpenWithCodec(string path, bool directory, CultDocumentRegistry registry)
{
var cache = new CultCache(registry, CultCacheMessagePack.CreateCodec(registry));
cache.AddBackingStore(Store(path, directory));
return cache;
}
private string StoredIdOfVariantStore(string path, CultDocumentRegistry registry, CultRecordKey key)
{
using var cache = OpenWithCodec(path, false, registry);
return cache.GetStored(key)!.StoredSchemaId;
}
// A base and a variant of it, written by a writer that knew Narrow; Grown then reads the variant whole as compatible drift.
private string SeedNarrowVariant()
{
var path = Path.Combine(_directory, "store.cc");
using var cache = OpenWithCodec(path, false, NarrowRegistry);
Assert.That(cache.Commit(batch => batch.Upsert(new NoteNarrow { Name = "base", Value = 1 }, new CultRecordHandle<NoteNarrow>(Base))), Is.True);
Assert.That(cache.Commit(batch => batch.UpsertVariant(Delta, Base, new[] { cache.Override<NoteNarrow>("Name", "delta"), cache.Override<NoteNarrow>("Value", 5) })), Is.True);
return path;
}
// Variants live in a single file (the directory store refuses them). The staging half asks the flag ToStoredDocument set on a loaded variant, and Resolved and Flatten carry it on: an owned
// drift variant is rewritten, flattened and removed like a plain drift record, restamping only what is written.
[Test]
public async Task AnOwnedDriftVariantIsRewritten()
{
var path = SeedNarrowVariant();
using (var cache = OpenWithCodec(path, false, GrownRegistry))
{
await cache.PullAllBackingStoresAsync();
Assert.That(cache.Commit(batch => batch.UpsertVariant(Delta, Base, new[] { cache.Override<NoteGrown>("Name", "delta"), cache.Override<NoteGrown>("Value", 6) })), Is.True);
}
Assert.That(StoredIdOfVariantStore(path, GrownRegistry, Delta), Is.EqualTo(GrownRegistry.GetRequired<NoteGrown>().SchemaId));
Assert.That(StoredIdOfVariantStore(path, NarrowRegistry, Base), Is.EqualTo(NarrowRegistry.GetRequired<NoteNarrow>().SchemaId), "the base was not written");
}
[Test]
public async Task AnOwnedDriftVariantIsFlattened()
{
var path = SeedNarrowVariant();
using (var cache = OpenWithCodec(path, false, GrownRegistry))
{
await cache.PullAllBackingStoresAsync();
Assert.That(cache.Commit(batch => batch.Flatten(Delta)), Is.True);
}
Assert.That(StoredIdOfVariantStore(path, GrownRegistry, Delta), Is.EqualTo(GrownRegistry.GetRequired<NoteGrown>().SchemaId));
}
[Test]
public async Task AnOwnedDriftVariantIsRemoved()
{
var path = SeedNarrowVariant();
using (var cache = OpenWithCodec(path, false, GrownRegistry))
{
await cache.PullAllBackingStoresAsync();
Assert.That(cache.Commit(batch => batch.Remove(Delta)), Is.True);
}
using var reopened = OpenWithCodec(path, false, GrownRegistry);
await reopened.PullAllBackingStoresAsync();
Assert.That(reopened.GetStored(Delta), Is.Null);
Assert.That(reopened.GetStored(Base), Is.Not.Null);
}
// A store whose entry is a newer writer's rename of Narrow's schema, with identical members and the given compatible ids.
private string SeedRenamed(bool listsNarrow)
{
var path = SeedNarrow(directory: false);
var snapshot = Snapshot(path);
var narrowId = NarrowRegistry.GetRequired<NoteNarrow>().SchemaId;
var entry = snapshot.SchemaCatalog.Single();
entry.SchemaId = entry.ContentHash = "tests.restamp.renamed";
entry.SchemaName = "tests.restamp_note_renamed";
entry.SchemaVersion = "tests.restamp_note_renamed.v2";
entry.CompatibleSchemaIds = listsNarrow ? new[] { "tests.restamp.renamed", narrowId } : new[] { "tests.restamp.renamed" };
foreach (var record in snapshot.Records)
record.SchemaId = entry.SchemaId;
File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
return path;
}
// One writer of NoteNarrow, with a cache or without one: the same Owns answer for the same record.
private void WriteNarrow(string path, bool cacheless)
{
if (cacheless)
{
new SingleFileMessagePackBackingStore(path).WriteDocument(Note, new NoteNarrow { Name = "note", Value = 2 }, registry: NarrowRegistry);
return;
}
using var cache = Open(path, false, NarrowRegistry);
cache.PullAllBackingStoresAsync().GetAwaiter().GetResult();
Assert.That(cache.Commit(batch => batch.Upsert(new NoteNarrow { Name = "note", Value = 2 }, new CultRecordHandle<NoteNarrow>(Note))), Is.True);
}
[TestCase(false)]
[TestCase(true)]
public void ARenamedEntryListingTheWritersIdIsOwnedByCacheAndCachelessWriterAlike(bool cacheless)
{
var path = SeedRenamed(listsNarrow: true);
WriteNarrow(path, cacheless);
Assert.That(Snapshot(path).Records.Single(record => record.Key == "note").SchemaId, Is.EqualTo(NarrowRegistry.GetRequired<NoteNarrow>().SchemaId));
}
[TestCase(false)]
[TestCase(true)]
public void ARenamedEntryNotListingTheWritersIdIsRefusedByCacheAndCachelessWriterAlike(bool cacheless)
{
var path = SeedRenamed(listsNarrow: false);
var before = Fingerprint(path);
Assert.Throws<CultSchemaConflictException>(() => WriteNarrow(path, cacheless));
Assert.That(Fingerprint(path), Is.EqualTo(before));
}
// A writer of one schema never overwrites and retypes a record of another schema, however alike their members.
[TestCase(false)]
[TestCase(true)]
public void AWriterOfOneSchemaNeverRetypesASameShapeRecordOfAnother(bool cacheless)
{
var path = Path.Combine(_directory, "store.cc");
var otherRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(NoteOther) });
using (var seed = Open(path, false, otherRegistry))
Assert.That(seed.Commit(batch => batch.Upsert(new NoteOther { Name = "note", Value = 1 }, new CultRecordHandle<NoteOther>(Note))), Is.True);
var before = Fingerprint(path);
Assert.Throws<CultSchemaConflictException>(() => WriteNarrow(path, cacheless));
Assert.That(Fingerprint(path), Is.EqualTo(before));
}
private static string VectorPath(string name)
{
for (var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); dir != null; dir = dir.Parent)
{
var vectors = Path.Combine(dir.FullName, "tests", "vectors", "ref-list-drift");
if (Directory.Exists(vectors)) return Path.Combine(vectors, name);
}
throw new DirectoryNotFoundException($"tests/vectors/ref-list-drift not found above {TestContext.CurrentContext.TestDirectory}.");
}
// The store the 1.0.60 writer wrote: its 'list' holder compares compatible with RefListHolder, gaining reference semantics
// on a slot, and ignores no persisted slot, so RefListHolder reads it whole and may write it.
[Test]
public async Task TheCommitted1060VectorsListRecordIsWrittenUnderTheHoldersOwnId()
{
var path = Path.Combine(_directory, "vector.cc");
File.Copy(VectorPath("v1-0-60-written.cc"), path);
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(RefListDriftTests.RefListTarget), typeof(RefListDriftTests.RefListHolder) });
var holderId = registry.GetRequired<RefListDriftTests.RefListHolder>().SchemaId;
var before = Snapshot(path);
Assert.That(before.Records.Single(record => record.Key == "list").SchemaId, Is.Not.EqualTo(holderId), "the vector's list is under an older id");
using (var cache = new CultCache(registry))
{
cache.AddBackingStore(new SingleFileMessagePackBackingStore(path));
await cache.PullAllBackingStoresAsync();
var holder = cache.GetAll<RefListDriftTests.RefListHolder>().Single();
Assert.That(cache.Commit(batch => batch.Upsert(
new RefListDriftTests.RefListHolder { Name = holder.Name, Items = holder.Items.Reverse().ToArray() },
new CultRecordHandle<RefListDriftTests.RefListHolder>(new CultRecordKey("list")))), Is.True);
}
var after = Snapshot(path);
Assert.That(after.Records.Single(record => record.Key == "list").SchemaId, Is.EqualTo(holderId));
Assert.That(after.SchemaCatalog.Single(entry => entry.SchemaId == holderId).Members.Single(member => member.Slot == 1).IsReference, Is.True);
foreach (var kept in new[] { "alpha", "beta" })
{
Assert.That(after.Records.Single(record => record.Key == kept).SchemaId, Is.EqualTo(before.Records.Single(record => record.Key == kept).SchemaId), kept + " keeps its persisted id");
Assert.That(after.Records.Single(record => record.Key == kept).Payload, Is.EqualTo(before.Records.Single(record => record.Key == kept).Payload), kept + " keeps its bytes");
}
using var reopened = new CultCache(registry);
reopened.AddBackingStore(new SingleFileMessagePackBackingStore(path));
await reopened.PullAllBackingStoresAsync();
Assert.That(reopened.GetAll<RefListDriftTests.RefListHolder>().Single().Items.Select(item => item.Key.Value).ToArray(), Is.EqualTo(new[] { "beta", "alpha" }));
}
private static CultPersistedStoreSnapshot Snapshot(string path) =>
CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
}
}