#nullable enable using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; using GameCult.Caching; using GameCult.Caching.MessagePack; using MessagePack; using NUnit.Framework; using R3; namespace GameCult.Caching.Tests { // The contract is src/GameCult.Caching/Contracts/cultcache-persistence-format.md (variant records) and // docs/document-variants-cut.md C1. Every input goes through the cache's own write API and the real single-file store. public class VariantTests { private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(VariantGear), typeof(VariantOther), typeof(VariantGlobal) }); private static readonly CultRecordKey BaseKey = new("laser"); private static readonly CultRecordKey BigKey = new("laser-big"); private string _directory = string.Empty; [SetUp] public void CreateDirectory() { _directory = Path.Combine(Path.GetTempPath(), $"cultlib-variants-{Guid.NewGuid():N}"); Directory.CreateDirectory(_directory); } [TearDown] public void DeleteDirectory() { if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true); } private string PathOf(string name) => Path.Combine(_directory, name); private static CultCache Open(string path, bool readOnly = false, CultDocumentRegistry? registry = null) => CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = registry ?? Registry, ReadOnly = readOnly }); private static VariantGear Laser() => new() { Name = "laser", Power = 10, Code = "l1", Tags = { "beam" } }; private static void SeedBase(CultCache cache) => cache.Commit(batch => batch.Upsert(typeof(VariantGear), Laser(), BaseKey)); // laser-big: a variant of laser that overrides its name, power and code (an indexed value must be its own) and inherits the rest. private static void SeedBig(CultCache cache, CultRecordKey? key = null, CultRecordKey? baseKey = null, string name = "laser big", int power = 25) => cache.Commit(batch => batch.UpsertVariant(key ?? BigKey, baseKey ?? BaseKey, new[] { cache.Override(nameof(VariantGear.Name), name), cache.Override(nameof(VariantGear.Power), power), cache.Override(nameof(VariantGear.Code), name) })); private static void EditBase(CultCache cache, Action edit) => cache.Commit(batch => { var edited = Laser(); edit(edited); batch.Upsert(typeof(VariantGear), edited, BaseKey); }); private static InvalidOperationException Refused(TestDelegate write) => Assert.Throws(write)!; // ---- resolution ---- [Test] public void ALoadedVariantResolvesAgainstItsBase() { var path = PathOf("gear.cc"); using (var writer = Open(path)) { SeedBase(writer); SeedBig(writer); } using var cache = Open(path); var big = cache.Get(BigKey)!; Assert.That(big.Name, Is.EqualTo("laser big")); Assert.That(big.Power, Is.EqualTo(25)); Assert.That(big.Code, Is.EqualTo("laser big"), "the override the unique index requires"); Assert.That(big.Tags, Is.EqualTo(new[] { "beam" }), "an inherited list"); Assert.That(cache.Get(BaseKey)!.Power, Is.EqualTo(10)); Assert.That(cache.GetStored(BigKey)!.Variant!.BaseKey, Is.EqualTo(BaseKey.Value)); Assert.That(cache.GetStored(BigKey)!.Variant!.Overrides, Has.Count.EqualTo(3)); } [Test] public void AVariantOfAVariantResolvesBaseFirstAndFollowsTheRoot() { using var cache = Open(PathOf("gear.cc")); SeedBase(cache); SeedBig(cache); var hugeKey = new CultRecordKey("laser-huge"); SeedBig(cache, hugeKey, BigKey, name: "laser huge", power: 40); Assert.That(cache.Get(hugeKey)!.Tags, Is.EqualTo(new[] { "beam" })); EditBase(cache, gear => gear.Tags.Add("pierce")); Assert.That(cache.Get(hugeKey)!.Tags, Is.EqualTo(new[] { "beam", "pierce" })); Assert.That(cache.Get(hugeKey)!.Power, Is.EqualTo(40)); } [Test] public void ABaseEditReachesItsVariantsThroughGetNameAndIndexAndOneWatchPublication() { using var cache = Open(PathOf("gear.cc")); SeedBase(cache); SeedBig(cache); cache.Commit(batch => batch.UpsertVariant(new CultRecordKey("laser-coded"), BaseKey, new[] { cache.Override(nameof(VariantGear.Name), "laser coded"), cache.Override(nameof(VariantGear.Code), "l9") })); var seen = new List<(string Key, long Sequence, string BigTagsAtThatMoment, string BigNameLookupTags)>(); using var subscription = cache.Watch().Subscribe(change => seen.Add(( change.Key.Value, change.Sequence, string.Join(",", cache.Get(BigKey)!.Tags), string.Join(",", cache.GetByName("laser big")!.Tags)))); EditBase(cache, gear => { gear.Tags.Add("pierce"); gear.Code = "l2"; }); Assert.That(cache.Get(BigKey)!.Tags, Is.EqualTo(new[] { "beam", "pierce" })); Assert.That(cache.GetByName("laser big")!.Tags, Is.EqualTo(new[] { "beam", "pierce" })); Assert.That(cache.GetByName("laser big")!.Power, Is.EqualTo(25), "the override survives the base edit"); Assert.That(cache.GetByIndex("code", "l9")!.Tags, Is.EqualTo(new[] { "beam", "pierce" })); // the base's old code entry must be gone, not shadowing, and its new one must find the base itself. Assert.That(cache.GetByIndex("code", "l1"), Is.Null); Assert.That(cache.GetByIndex("code", "l2")!.Name, Is.EqualTo("laser")); Assert.That(cache.GetByIndex("code", "laser big")!.Name, Is.EqualTo("laser big")); Assert.That(seen.Select(entry => entry.Key), Is.EquivalentTo(new[] { BaseKey.Value, BigKey.Value, "laser-coded" })); Assert.That(seen.Select(entry => entry.Sequence), Is.EqualTo(Enumerable.Range((int)seen[0].Sequence, seen.Count).Select(n => (long)n)), "one commit, one run of sequences with no gap"); Assert.That(seen.Select(entry => entry.BigTagsAtThatMoment), Is.All.EqualTo("beam,pierce"), "a subscriber observing the base change already reads the new variant"); Assert.That(seen.Select(entry => entry.BigNameLookupTags), Is.All.EqualTo("beam,pierce")); } [Test] public void AReResolvedDependentKeepsItsStoredAtAndIsAnUpdatedChange() { using var cache = Open(PathOf("gear.cc")); SeedBase(cache); SeedBig(cache); var before = cache.GetStored(BigKey)!; var changes = new List>(); using var subscription = cache.WatchRecord(BigKey).Subscribe(changes.Add); EditBase(cache, gear => gear.Tags.Add("pierce")); Assert.That(cache.GetStored(BigKey)!.StoredAt, Is.EqualTo(before.StoredAt), "a base change stamps no dependent"); Assert.That(changes, Has.Count.EqualTo(1)); Assert.That(changes[0].Kind, Is.EqualTo(CultCacheDocumentChangeKind.Updated)); Assert.That(changes[0].PreviousDocument!.Tags, Is.EqualTo(new[] { "beam" })); Assert.That(changes[0].Document!.Tags, Is.EqualTo(new[] { "beam", "pierce" })); } [Test] public void ExpectOnAVariantSurvivesABaseChangeButNotAChangeToItsDelta() { var path = PathOf("gear.cc"); using (var seed = Open(path)) { SeedBase(seed); SeedBig(seed); } using var a = Open(path); using var b = Open(path); var seen = a.Get(BigKey)!; EditBase(b, gear => gear.Tags.Add("pierce")); // Expect tests the variant's stored delta only, and it did not change, so the condition holds and the write goes on: the // store holds a variant and moved, so it is refused and reloaded as a write conflict. It is not a mismatch (false). Assert.Throws(() => a.Commit(batch => { batch.Expect(BigKey, seen); batch.Upsert(typeof(VariantOther), new VariantOther { Name = "marker" }, new CultRecordKey("marker")); })); seen = a.Get(BigKey)!; Assert.That(a.Commit(batch => { batch.Expect(BigKey, seen); batch.Upsert(typeof(VariantOther), new VariantOther { Name = "marker" }, new CultRecordKey("marker")); }), Is.True, "the base changed; the variant's stored delta did not"); a.PullAllBackingStoresAsync(); seen = a.Get(BigKey)!; b.PullAllBackingStoresAsync(); SeedBig(b, power: 26); Assert.That(a.Commit(batch => { batch.Expect(BigKey, seen); batch.Upsert(typeof(VariantOther), new VariantOther { Name = "second" }, new CultRecordKey("second")); }), Is.False, "another writer changed the variant's own delta"); } // ---- refusals, each naming the keys ---- [Test] public void ACycleOfBasesIsRefusedNamingEveryKey() { using var cache = Open(PathOf("gear.cc")); SeedBase(cache); var a = new CultRecordKey("cycle-a"); var b = new CultRecordKey("cycle-b"); var staged = Refused(() => cache.Commit(batch => { batch.UpsertVariant(a, b); batch.UpsertVariant(b, a); })); Assert.That(staged.Message, Does.Contain("cycle").And.Contain(a.Value).And.Contain(b.Value)); SeedBig(cache); var huge = new CultRecordKey("laser-huge"); SeedBig(cache, huge, BigKey, name: "laser huge"); var rebased = Refused(() => cache.Commit(batch => batch.UpsertVariant(BigKey, huge))); Assert.That(rebased.Message, Does.Contain("cycle").And.Contain(BigKey.Value).And.Contain(huge.Value)); Assert.That(cache.GetStored(BigKey)!.Variant!.BaseKey, Is.EqualTo(BaseKey.Value), "nothing landed"); } [Test] public void AMissingBaseIsRefusedNamingBoth() { using var cache = Open(PathOf("gear.cc")); var error = Refused(() => cache.UpsertVariantAsync(BigKey, new CultRecordKey("no-such-base")).GetAwaiter().GetResult()); Assert.That(error.Message, Does.Contain(BigKey.Value).And.Contain("no-such-base")); Assert.That(cache.Get(BigKey), Is.Null); } [Test] public void ABaseInAnotherTypeIsRefusedOnRebaseAndOnLoad() { var path = PathOf("gear.cc"); using (var cache = Open(path)) { SeedBase(cache); SeedBig(cache); cache.Commit(batch => batch.Upsert(typeof(VariantOther), new VariantOther { Name = "other" }, new CultRecordKey("other"))); var rebase = Refused(() => cache.Commit(batch => batch.UpsertVariant(BigKey, new CultRecordKey("other")))); Assert.That(rebase.Message, Does.Contain(BigKey.Value).And.Contain("VariantOther")); } // A store whose variant record names another type than its base's: the loader refuses it naming both keys. var bad = PathOf("bad.cc"); var gear = Registry.GetRequired(); var other = Registry.GetRequired(); File.WriteAllBytes(bad, CultDocumentMessagePackSerialization.SerializeSnapshot(new CultPersistedStoreSnapshot { FormatVersion = CultPersistedStoreSnapshot.FormatV2, SchemaCatalog = new[] { gear.ToCatalogEntry(), other.ToCatalogEntry() }, Records = new[] { new CultPersistedRecord { Key = "gear-base", SchemaId = gear.SchemaId, StoredAt = "2026-01-01T00:00:00.0000000Z", Payload = CultDocumentMessagePackSerialization.SerializeUntyped(Laser(), typeof(VariantGear), Registry) }, new CultPersistedRecord { Key = "other-variant", SchemaId = other.SchemaId, StoredAt = "2026-01-01T00:00:00.0000000Z", Variant = new CultVariantDelta("gear-base", Array.Empty()) } } })); var load = Refused(() => Open(bad).Dispose()); Assert.That(load.Message, Does.Contain("other-variant").And.Contain("gear-base")); } [Test] public void AVariantOfAGlobalIsRefused() { using var cache = Open(PathOf("gear.cc")); var key = cache.UpsertAsync(new VariantGlobal { Value = "one" }).GetAwaiter().GetResult().Key; var error = Refused(() => cache.UpsertVariantAsync(BigKey, key).GetAwaiter().GetResult()); Assert.That(error.Message, Does.Contain(BigKey.Value).And.Contain("global")); } [Test] public void RemovingABaseThatKeepsVariantsIsRefusedUnlessTheSameBatchRemovesOrRebasesThem() { using var cache = Open(PathOf("gear.cc")); SeedBase(cache); var spare = new CultRecordKey("spare"); cache.Commit(batch => batch.Upsert(typeof(VariantGear), new VariantGear { Name = "spare", Power = 3 }, spare)); SeedBig(cache); var refused = Refused(() => cache.Commit(batch => batch.Remove(BaseKey))); Assert.That(refused.Message, Does.Contain(BaseKey.Value).And.Contain(BigKey.Value)); Assert.That(cache.Get(BaseKey), Is.Not.Null); Assert.That(cache.Get(BigKey), Is.Not.Null); Assert.That(cache.Commit(batch => { batch.UpsertVariant(BigKey, spare, cache.GetStored(BigKey)!.Variant!.Overrides); batch.Remove(BaseKey); }), Is.True, "rebased in the same batch"); Assert.That(cache.Get(BigKey)!.Tags, Is.Empty, "now resolved from spare"); Assert.That(cache.Commit(batch => { batch.Remove(spare); batch.Remove(BigKey); }), Is.True, "removed in the same batch"); SeedBase(cache); var removedInTheBatch = Refused(() => cache.Commit(batch => { batch.Remove(BaseKey); batch.UpsertVariant(BigKey, BaseKey); })); Assert.That(removedInTheBatch.Message, Does.Contain(BigKey.Value).And.Contain(BaseKey.Value)); } [Test] public void AVariantThatKeepsItsBasesNameIsRefused() { using var cache = Open(PathOf("gear.cc")); SeedBase(cache); var error = Refused(() => cache.UpsertVariantAsync(BigKey, BaseKey).GetAwaiter().GetResult()); Assert.That(error.Message, Does.Contain(BigKey.Value).And.Contain(BaseKey.Value).And.Contain("laser")); var sameName = Refused(() => SeedBig(cache, name: "laser")); Assert.That(sameName.Message, Does.Contain(BigKey.Value).And.Contain(BaseKey.Value)); SeedBig(cache); var grand = new CultRecordKey("laser-grand"); var grandOfBase = Refused(() => SeedBig(cache, grand, BigKey, name: "laser")); Assert.That(grandOfBase.Message, Does.Contain(grand.Value).And.Contain(BaseKey.Value), "the whole chain, not only the parent"); } [Test] public void ABaseEditThatWouldGiveAVariantItsBasesNameIsRefused() { using var cache = Open(PathOf("gear.cc")); SeedBase(cache); SeedBig(cache); var error = Refused(() => EditBase(cache, gear => gear.Name = "laser big")); Assert.That(error.Message, Does.Contain(BigKey.Value).And.Contain(BaseKey.Value)); Assert.That(cache.Get(BaseKey)!.Name, Is.EqualTo("laser")); } [Test] public void APlainWriteAtAVariantsKeyIsRefusedByEveryPathAndFlattenIsTheExplicitWay() { var path = PathOf("gear.cc"); using var cache = Open(path); SeedBase(cache); SeedBig(cache); var plain = new VariantGear { Name = "laser big", Power = 99 }; Assert.That(Refused(() => cache.UpsertAsync(typeof(VariantGear), plain, BigKey).GetAwaiter().GetResult()).Message, Does.Contain(BigKey.Value)); Assert.That(Refused(() => cache.Commit(batch => batch.Upsert(typeof(VariantGear), plain, BigKey))).Message, Does.Contain(BigKey.Value)); Assert.That(cache.GetStored(BigKey)!.Variant, Is.Not.Null); Assert.That(cache.Get(BigKey)!.Power, Is.EqualTo(25)); cache.FlattenAsync(BigKey).GetAwaiter().GetResult(); var flat = cache.GetStored(BigKey)!; Assert.That(flat.Variant, Is.Null); Assert.That(cache.Get(BigKey)!.Power, Is.EqualTo(25), "flatten keeps the resolved values"); EditBase(cache, gear => gear.Tags.Add("pierce")); Assert.That(cache.Get(BigKey)!.Tags, Is.EqualTo(new[] { "beam" }), "a flattened record no longer follows its base"); cache.UpsertAsync(typeof(VariantGear), plain, BigKey).GetAwaiter().GetResult(); Assert.That(cache.Get(BigKey)!.Power, Is.EqualTo(99), "and is an ordinary record again"); cache.FlushAsync().GetAwaiter().GetResult(); Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"), "no variant left in the store"); } [Test] public void FlattenRefusesARecordThatIsNotAVariantAndAPlainRecordCannotBecomeOne() { using var cache = Open(PathOf("gear.cc")); SeedBase(cache); Assert.That(Refused(() => cache.FlattenAsync(BaseKey).GetAwaiter().GetResult()).Message, Does.Contain(BaseKey.Value)); var spare = new CultRecordKey("spare"); cache.Commit(batch => batch.Upsert(typeof(VariantGear), new VariantGear { Name = "spare" }, spare)); var named = new[] { cache.Override(nameof(VariantGear.Name), "spare variant") }; Assert.That(Refused(() => cache.UpsertVariantAsync(spare, BaseKey, named).GetAwaiter().GetResult()).Message, Does.Contain(spare.Value)); Assert.That(cache.GetStored(spare)!.Variant, Is.Null, "the plain record is still plain"); } [Test] public void ACacheWithoutACodecHoldsNoVariants() { using var cache = new CultCache(Registry); cache.Commit(batch => batch.Upsert(typeof(VariantGear), Laser(), BaseKey)); var error = Refused(() => cache.UpsertVariantAsync(BigKey, BaseKey).GetAwaiter().GetResult()); Assert.That(error.Message, Does.Contain(BigKey.Value).And.Contain("codec")); } [Test] public void OnlyTopLevelSetOverridesResolveInThisCut() { using var cache = Open(PathOf("gear.cc")); SeedBase(cache); var element = new CultVariantOverride( CultOverrideOp.Set, new[] { new CultPathStep(3, "el-1"), new CultPathStep(0) }, string.Empty, new byte[] { 0x01 }); var error = Refused(() => cache.UpsertVariantAsync(BigKey, BaseKey, new[] { element }).GetAwaiter().GetResult()); Assert.That(error.Message, Does.Contain(BigKey.Value).And.Contain("top-level")); var insert = new CultVariantOverride(CultOverrideOp.Insert, new[] { new CultPathStep(3) }, "new", new byte[] { 0xa1, 0x78 }); Assert.That(Refused(() => cache.UpsertVariantAsync(BigKey, BaseKey, new[] { insert }).GetAwaiter().GetResult()).Message, Does.Contain(BigKey.Value).And.Contain("top-level")); } // ---- soft drift: an override drifts exactly as the same member would on a plain record ---- [Test] public void AnOverrideOfASlotTheTypeNoLongerHasIsIgnoredWithAWarningNamingVariantAndMember() { var path = PathOf("drift.cc"); var wide = CultDocumentRegistry.ForTypes(new[] { typeof(DriftWide) }); using (var writer = Open(path, registry: wide)) { writer.Commit(batch => { batch.Upsert(typeof(DriftWide), new DriftWide { Name = "drift", Power = 1, Wing = "base wing" }, new CultRecordKey("drift-base")); batch.UpsertVariant(new CultRecordKey("drift-variant"), new CultRecordKey("drift-base"), new[] { writer.Override(nameof(DriftWide.Name), "drift variant"), writer.Override(nameof(DriftWide.Power), 7), writer.Override(nameof(DriftWide.Wing), "variant wing") }); }); } var narrow = CultDocumentRegistry.ForTypes(new[] { typeof(DriftNarrow) }); using var cache = Open(path, registry: narrow); var variant = cache.Get(new CultRecordKey("drift-variant"))!; Assert.That(variant.Power, Is.EqualTo(7), "the override the type still has applies"); Assert.That(variant.Name, Is.EqualTo("drift variant")); // The same decision, on the same surface, that the plain base record gets from the store's schema resolution. var reports = cache.BackingStores[0].LastSchemaMigrationReports; Assert.That(reports, Has.Count.EqualTo(2), "one per record: the base and the variant"); Assert.That(reports.Select(report => report.Kind), Is.All.EqualTo(CultSchemaMigrationKind.CompatibleDrift)); Assert.That(reports.Select(report => string.Join(",", report.IgnoredExtraSlots)), Is.All.EqualTo("2")); Assert.That(reports.Select(report => report.Warnings.Select(warning => warning.Code)), Has.All.Contain("ignored_extra_slot")); } private static readonly byte[] NotAnInt = { 0xa3, (byte)'a', (byte)'b', (byte)'c' }; private static CultVariantOverride TextOverride(int slot, string value) { var bytes = new byte[value.Length + 1]; bytes[0] = (byte)(0xa0 | value.Length); for (var index = 0; index < value.Length; index++) bytes[index + 1] = (byte)value[index]; return CultVariantOverride.Set(slot, bytes); } private static CultPersistedRecord OverridingRecord(string key, string baseKey, params CultVariantOverride[] overrides) => new() { Key = key, SchemaId = Registry.GetRequired().SchemaId, StoredAt = "2026-01-01T00:00:00.0000000Z", Payload = Array.Empty(), Variant = new CultVariantDelta(baseKey, overrides) }; [Test] public void AnOverrideThatNoLongerDecodesRefusesTheLoadAsAPlainRecordHoldingItDoes() { // VariantGear.Power is [Key(1)] int; a string there is the nested-type-change case with the schema id unchanged. var plainRecord = new CultPersistedRecord { Key = "hand-plain", SchemaId = Registry.GetRequired().SchemaId, StoredAt = "2026-01-01T00:00:00.0000000Z", Payload = new byte[] { 0x94, 0xa1, (byte)'x', 0xa3, (byte)'a', (byte)'b', (byte)'c', 0xa0, 0x90 } }; Assert.That(Assert.Catch(() => Open(WriteStore(plainRecord)).Dispose()), Is.Not.Null, "the plain-record outcome is a refusal"); var path = WriteStore(PlainGear("hand-base"), OverridingRecord("hand-variant", "hand-base", CultVariantOverride.Set(1, NotAnInt))); var refused = Assert.Catch(() => Open(path).Dispose())!; Assert.That(refused.Message, Does.Contain("hand-variant").And.Contain("Power")); } [Test] public void AnOverrideOfASlotTheTypeNeverHadIsIgnoredOnLoadAsAPlainRecordIgnoresAnExtraSlot() { var path = WriteStore(PlainGear("hand-base"), OverridingRecord("hand-variant", "hand-base", CultVariantOverride.Set(99, new byte[] { 0x01 }), TextOverride(0, "hand variant"), TextOverride(2, "hand-code"))); using var cache = Open(path); Assert.That(cache.Get(new CultRecordKey("hand-variant"))!.Name, Is.EqualTo("hand variant")); Assert.That(cache.Get(new CultRecordKey("hand-variant"))!.Power, Is.EqualTo(10), "inherits the base"); } // ---- a bad write is refused; drift is for type changes after the fact ---- [Test] public void AnInvalidOverrideIsRefusedAtWriteNamingVariantSlotAndReasonAndNothingLands() { var path = PathOf("gear.cc"); using var cache = Open(path); SeedBase(cache); var unknown = Refused(() => cache.Commit(batch => batch.UpsertVariant(BigKey, BaseKey, new[] { CultVariantOverride.Set(99, new byte[] { 0x01 }) }))); Assert.That(unknown.Message, Does.Contain(BigKey.Value).And.Contain("99").And.Contain("does not have")); var wrongType = Refused(() => cache.UpsertVariantAsync(BigKey, BaseKey, new[] { CultVariantOverride.Set(1, NotAnInt) }).GetAwaiter().GetResult()); Assert.That(wrongType.Message, Does.Contain(BigKey.Value).And.Contain("Power").And.Contain("slot 1")); Assert.That(cache.Get(BigKey), Is.Null); cache.FlushAsync().GetAwaiter().GetResult(); Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"), "no variant was persisted"); } [Test] public void TwoOverridesOfOneSlotAreRefusedAtWriteAndAnUnknownMemberNameIsAnArgumentException() { using var cache = Open(PathOf("gear.cc")); SeedBase(cache); var twice = Refused(() => cache.Commit(batch => batch.UpsertVariant(BigKey, BaseKey, new[] { cache.Override(nameof(VariantGear.Power), 1), cache.Override(nameof(VariantGear.Power), 2) }))); Assert.That(twice.Message, Does.Contain(BigKey.Value).And.Contain("slot 1").And.Contain("more than once")); Assert.That(cache.Get(BigKey), Is.Null); var unknownName = Assert.Throws(() => cache.Override("Nope", 1))!; Assert.That(unknownName.Message, Does.Contain("Nope")); } // ---- a variant may not share an indexed value with another record ---- [Test] public void AVariantThatInheritsAnIndexedValueIsRefusedNamingBothKeysAndTheIndex() { using var cache = Open(PathOf("gear.cc")); SeedBase(cache); var inherited = Refused(() => cache.Commit(batch => batch.UpsertVariant(BigKey, BaseKey, new[] { cache.Override(nameof(VariantGear.Name), "laser big") }))); Assert.That(inherited.Message, Does.Contain(BigKey.Value).And.Contain(BaseKey.Value).And.Contain("code").And.Contain("l1")); var explicitly = Refused(() => cache.Commit(batch => batch.UpsertVariant(BigKey, BaseKey, new[] { cache.Override(nameof(VariantGear.Name), "laser big"), cache.Override(nameof(VariantGear.Code), "l1") }))); Assert.That(explicitly.Message, Does.Contain(BigKey.Value).And.Contain(BaseKey.Value)); Assert.That(cache.Get(BigKey), Is.Null, "nothing landed"); Assert.That(cache.GetByIndex("code", "l1")!.Name, Is.EqualTo("laser")); SeedBig(cache); Assert.That(cache.GetByIndex("code", "l1")!.Name, Is.EqualTo("laser")); Assert.That(cache.GetByIndex("code", "laser big")!.Name, Is.EqualTo("laser big")); } [Test] public void ABaseEditThatWouldGiveItsVariantsIndexedValueToTheBaseIsRefused() { using var cache = Open(PathOf("gear.cc")); SeedBase(cache); SeedBig(cache); var refused = Refused(() => EditBase(cache, gear => gear.Code = "laser big")); Assert.That(refused.Message, Does.Contain(BigKey.Value).And.Contain(BaseKey.Value).And.Contain("code")); Assert.That(cache.Get(BaseKey)!.Code, Is.EqualTo("l1"), "nothing landed"); } [Test] public void ALoadedVariantThatInheritsAnIndexedValueRefusesTheLoadNamingBothKeys() { var path = WriteStore(PlainGear("hand-base"), OverridingRecord("hand-variant", "hand-base", TextOverride(0, "hand variant"))); var refused = Assert.Throws(() => Open(path).Dispose())!; Assert.That(refused.Message, Does.Contain("hand-variant").And.Contain("hand-base").And.Contain("code")); } // ---- the index rule holds for the commit's result, in either write order, and the store always reopens ---- private static VariantGear Other(string code) => new() { Name = "other", Power = 1, Code = code }; [Test] public async Task APlainWriteThatWouldShareAVariantsIndexedValueIsRefusedNamingBothKeysAndTheStoreReopens() { var path = PathOf("gear.cc"); var other = new CultRecordKey("other"); using (var cache = Open(path)) { SeedBase(cache); SeedBig(cache); var refused = Refused(() => cache.Commit(batch => batch.Upsert(typeof(VariantGear), Other("laser big"), other))); Assert.That(refused.Message, Does.Contain(other.Value).And.Contain(BigKey.Value).And.Contain("code").And.Contain("laser big")); var single = Assert.ThrowsAsync( () => cache.UpsertAsync(Other("laser big"), new CultRecordHandle(other)))!; Assert.That(single.Message, Does.Contain(other.Value).And.Contain(BigKey.Value)); Assert.That(cache.Get(other), Is.Null, "nothing landed"); Assert.That(cache.GetByIndex("code", "laser big")!.Name, Is.EqualTo("laser big"), "the variant is still found by its value"); EditBase(cache, gear => gear.Power = 11); Assert.That(cache.Get(BigKey)!.Power, Is.EqualTo(25), "the variant is still editable through its base"); await Task.CompletedTask; } using var reopened = Open(path); Assert.That(reopened.GetByIndex("code", "laser big")!.Name, Is.EqualTo("laser big")); Assert.That(reopened.Get(other), Is.Null); } [Test] public void ABaseEditOrANewPlainRecordFirstThenAVariantOnTheSameValueIsRefusedAndTheStoreReopens() { var path = PathOf("gear.cc"); var other = new CultRecordKey("other"); using (var cache = Open(path)) { SeedBase(cache); cache.Commit(batch => batch.Upsert(typeof(VariantGear), Other("laser big"), other)); var refused = Refused(() => SeedBig(cache)); Assert.That(refused.Message, Does.Contain(BigKey.Value).And.Contain(other.Value).And.Contain("code").And.Contain("laser big")); Assert.That(cache.Get(BigKey), Is.Null, "nothing landed"); // The same batch, either staging order: the result is what is judged. var sameBatchPlainFirst = Refused(() => cache.Commit(batch => { batch.Upsert(typeof(VariantGear), Other("taken"), new CultRecordKey("taker")); batch.UpsertVariant(BigKey, BaseKey, new[] { cache.Override(nameof(VariantGear.Name), "laser big"), cache.Override(nameof(VariantGear.Code), "taken") }); })); Assert.That(sameBatchPlainFirst.Message, Does.Contain(BigKey.Value).And.Contain("taker")); var sameBatchVariantFirst = Refused(() => cache.Commit(batch => { batch.UpsertVariant(BigKey, BaseKey, new[] { cache.Override(nameof(VariantGear.Name), "laser big"), cache.Override(nameof(VariantGear.Code), "taken") }); batch.Upsert(typeof(VariantGear), Other("taken"), new CultRecordKey("taker")); })); Assert.That(sameBatchVariantFirst.Message, Does.Contain(BigKey.Value).And.Contain("taker")); // Plain-vs-plain sharing is not this rule's business. Assert.DoesNotThrow(() => cache.Commit(batch => batch.Upsert(typeof(VariantGear), Other("l1"), new CultRecordKey("twin")))); } using var reopened = Open(path); Assert.That(reopened.Get(BigKey), Is.Null); Assert.That(reopened.Get(other), Is.Not.Null); } // A store holding a variant is written only from what its writer last read. Another writer adds a variant whose indexed value // this cache's staged edit of its base now takes: the write is refused whole, the cache reloads what it refused and what moved, // and the retry is judged by the cache's own rules. [Test] public void AWriteIntoAVariantStoreAnotherWriterChangedIsRefusedReloadedAndJudgedOnRetry([Values] bool viaCommit) { var path = PathOf(viaCommit ? "judged-commit.cc" : "judged-flush.cc"); using var cache = Open(path); SeedBase(cache); using (var other = Open(path)) SeedBig(other); var edited = Laser(); edited.Code = "laser big"; cache.UpsertAsync(typeof(VariantGear), edited, BaseKey).GetAwaiter().GetResult(); var before = File.ReadAllBytes(path); var conflict = Assert.Throws(() => { if (viaCommit) cache.Commit(batch => batch.Upsert(typeof(VariantGear), Other("n"), new CultRecordKey("n"))); else cache.FlushAllBackingStores(); })!; Assert.That(conflict.RecordKeys, Does.Contain(BaseKey.Value)); Assert.That(conflict.ChangedKeys, Does.Contain(BigKey.Value)); Assert.That(cache.Get(BaseKey)!.Code, Is.EqualTo("l1"), "the refused edit is not served"); Assert.That(cache.Get(BigKey), Is.Not.Null, "the moved record is loaded"); Assert.That(cache.IsDirty, Is.False); Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), "nothing was written"); Assert.That(Refused(() => cache.UpsertAsync(typeof(VariantGear), edited, BaseKey).GetAwaiter().GetResult()).Message, Does.Contain(BigKey.Value), "the retry is judged by the cache's own index rule"); } // A refused base removal leaves the cache serving the store, writable, and judging the next attempt itself. [Test] public void ARefusedBaseRemovalReloadsAndLeavesTheCacheWritable() { var path = PathOf("gear.cc"); var n = new CultRecordKey("n"); using var a = Open(path); SeedBase(a); using (var b = Open(path)) SeedBig(b); a.Remove(BaseKey); var conflict = Assert.Throws(() => a.FlushAllBackingStores())!; Assert.That(conflict.RecordKeys, Is.EqualTo(new[] { BaseKey.Value })); Assert.That(conflict.ChangedKeys, Is.EqualTo(new[] { BigKey.Value })); Assert.That(a.Get(BaseKey), Is.Not.Null); Assert.That(a.Get(BigKey), Is.Not.Null); Assert.That(a.IsDirty, Is.False); Assert.DoesNotThrow(() => a.FlushAllBackingStores()); Assert.DoesNotThrow(() => a.Commit(batch => batch.Upsert(typeof(VariantGear), Named("n", "nn"), n))); Assert.That(Refused(() => a.Remove(BaseKey)).Message, Does.Contain(BigKey.Value), "a base with variants is refused by this cache's own rule"); using var reopened = Open(path); Assert.That(reopened.Get(n), Is.Not.Null); Assert.That(reopened.Get(BigKey), Is.Not.Null); Assert.That(reopened.Get(BaseKey), Is.Not.Null); } // A held record another writer changed is not trusted: the write is refused although this cache never staged it. [Test] public void AWriteIsRefusedWhenAnotherWriterChangedAVariantThisCacheHolds() { var path = PathOf("gear.cc"); using var a = Open(path); SeedBase(a); SeedBig(a); using (var b = Open(path)) b.Commit(batch => batch.UpsertVariant(BigKey, BaseKey, new[] { b.Override(nameof(VariantGear.Name), "laser big"), b.Override(nameof(VariantGear.Power), 99), b.Override(nameof(VariantGear.Code), "laser big") })); Assert.That(a.Get(BigKey)!.Power, Is.EqualTo(25)); var conflict = Assert.Throws(() => a.Commit(batch => batch.Upsert(typeof(VariantGear), Named("y", "yy"), new CultRecordKey("y"))))!; Assert.That(conflict.ChangedKeys, Is.EqualTo(new[] { BigKey.Value })); Assert.That(a.Get(BigKey)!.Power, Is.EqualTo(99), "the cache serves the other writer's override after the refusal"); } // A write that would add a variant is judged like one into a variant store: a plain store another writer changed refuses it. [Test] public void AVariantWrittenIntoAPlainStoreAnotherWriterChangedIsRefusedThenLandsOnRetry() { var path = PathOf("plain-then-variant.cc"); var z = new CultRecordKey("z"); using (var seed = Open(path)) SeedBase(seed); using (var a = Open(path)) { using (var b = Open(path)) b.Commit(batch => batch.Upsert(typeof(VariantGear), Other("zc"), z)); var conflict = Assert.Throws(() => SeedBig(a))!; Assert.That(conflict.RecordKeys, Does.Contain(BigKey.Value)); Assert.That(conflict.ChangedKeys, Is.EqualTo(new[] { z.Value })); Assert.That(a.Get(z), Is.Not.Null, "the refusal reloaded the record that moved"); Assert.DoesNotThrow(() => SeedBig(a)); } using var reopened = Open(path); Assert.That(reopened.Get(BigKey), Is.Not.Null); Assert.That(reopened.Get(z), Is.Not.Null); } // A plain write copies another writer's record forward without reading it: this cache has still not read it, so a variant // written next is refused. [Test] public void ARecordACachePlainWriteCopiedForwardIsStillUnreadWhenAVariantIsWrittenNext() { var path = PathOf("copied-unread.cc"); var z = new CultRecordKey("z"); using (var seed = Open(path)) SeedBase(seed); using var a = Open(path); using (var b = Open(path)) b.Commit(batch => batch.Upsert(typeof(VariantGear), Other("zc"), z)); a.Commit(batch => batch.Upsert(typeof(VariantGear), Other("yc"), new CultRecordKey("y"))); var conflict = Assert.Throws(() => SeedBig(a))!; Assert.That(conflict.ChangedKeys, Is.EqualTo(new[] { z.Value })); } // ---- a write copies what it did not stage, and re-encodes what it stages under the type's own id ---- private static readonly string DriftWideId = CultDocumentRegistry.ForTypes(new[] { typeof(DriftWide) }).GetRequired(typeof(DriftWide)).SchemaId; // The same schema name as Wide, a later version that lists Wide's id as compatible and has no Wing. private static readonly Type DriftDeclared = EmittedDocumentTypes.Emit( "DriftDeclared", "tests.variant_drift", "tests.variant_drift.v4", new[] { new EmittedDocumentTypes.Field("Name", typeof(string), 0, IsName: true), new EmittedDocumentTypes.Field("Power", typeof(int), 1) }, new[] { DriftWideId }); private static readonly CultRecordKey DriftBase = new("drift-base"); private static readonly CultRecordKey DriftVariant = new("drift-variant"); private static CultPersistedStoreSnapshot ReadStore(string path) => CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); private void SeedDrift(string path) { using var writer = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftWide) })); writer.Commit(batch => { batch.Upsert(typeof(DriftWide), new DriftWide { Name = "drift", Power = 1, Wing = "base wing" }, DriftBase); batch.UpsertVariant(DriftVariant, DriftBase, new[] { writer.Override(nameof(DriftWide.Name), "drift variant"), writer.Override(nameof(DriftWide.Wing), "stale wing") }); }); } // Narrow reads Wide's records through the schema name and neither owns nor declares Wide's id. A write of another record // copies them as Wide stored them: bytes, id, storedAt, the override of the slot Narrow lacks, and the entry that publishes // Wide's id. [Test] public void AWriteOfAnotherRecordLeavesAVariantItsTypeHasNotDeclaredAsStored([Values] bool viaFlush) { var path = PathOf(viaFlush ? "drift-untouched-flush.cc" : "drift-untouched-commit.cc"); SeedDrift(path); var before = ReadStore(path); var wideEntryBefore = StoreSlices.CatalogEntry(path, DriftWideId); using (var narrow = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftNarrow) }))) { Assert.That(narrow.Get(DriftVariant)!.Name, Is.EqualTo("drift variant"), "Narrow reads the variant"); var other = new CultRecordKey("other"); if (viaFlush) { narrow.UpsertAsync(typeof(DriftNarrow), new DriftNarrow { Name = "other" }, other).GetAwaiter().GetResult(); narrow.FlushAllBackingStores(); } else narrow.Commit(batch => batch.Upsert(typeof(DriftNarrow), new DriftNarrow { Name = "other" }, other)); } var after = ReadStore(path); foreach (var key in new[] { DriftBase.Value, DriftVariant.Value }) { var was = before.Records.Single(record => record.Key == key); var is_ = after.Records.Single(record => record.Key == key); Assert.That((is_.SchemaId, is_.StoredAt, is_.Payload), Is.EqualTo((was.SchemaId, was.StoredAt, was.Payload)), key); } Assert.That(OverriddenSlotsOn(path, DriftVariant.Value).OrderBy(slot => slot), Is.EqualTo(new[] { 0, 2 }), "the dropped slot's override is still stored"); Assert.That(StoreSlices.CatalogEntry(path, DriftWideId), Is.EqualTo(wideEntryBefore)); } // A type that lists Wide's id as compatible owns Wide's records: staging one writes it under the type's own id, at a later // storedAt, without the member the type dropped, so a later type reusing that slot does not resurrect it. [Test] public void AStagedWriteOfADeclaredCompatibleRecordIsReEncodedUnderTheTypesOwnIdAndShedsTheDroppedSlot() { var path = PathOf("drift-declared.cc"); SeedDrift(path); var wideBase = ReadStore(path).Records.Single(record => record.Key == DriftBase.Value); var ownId = CultDocumentRegistry.ForTypes(new[] { DriftDeclared }).GetRequired(DriftDeclared).SchemaId; using (var declared = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { DriftDeclared }))) { declared.Commit(batch => { batch.Upsert(DriftDeclared, declared.Get(DriftBase)!, DriftBase); batch.UpsertVariant(DriftVariant, DriftBase, new[] { declared.Override(DriftDeclared, "Name", "drift variant") }); }); } var written = ReadStore(path); Assert.That(written.Records.Select(record => record.SchemaId), Is.All.EqualTo(ownId)); Assert.That(string.CompareOrdinal(written.Records.Single(record => record.Key == DriftBase.Value).StoredAt, wideBase.StoredAt), Is.GreaterThan(0)); Assert.That(OverriddenSlotsOn(path, DriftVariant.Value), Is.EqualTo(new[] { 0 }), "the dropped slot's override is gone from the written record"); using var reused = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftReused) })); Assert.That(reused.Get(DriftBase)!.Colour, Is.Empty); Assert.That(reused.Get(DriftVariant)!.Colour, Is.Empty, "the variant inherits its base's colour, not the old wing"); } public enum UndeclaredWrite { UpsertBase, UpsertVariant, FlattenVariant, RemoveBase, RemoveVariant } // Narrow reads Wide's records through the schema name and does not list Wide's id, so they are read-only to it: a staged write, // flatten or removal of either is refused typed, and the store keeps them as Wide stored them, the dropped slot's override too. [Test] public void AnUndeclaredDroppedSlotRecordThatIsWrittenIsRefused([Values] UndeclaredWrite write) { var path = PathOf("drift-undeclared-" + write + ".cc"); SeedDrift(path); var before = File.ReadAllBytes(path); using (var narrow = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftNarrow) }))) { var key = write is UndeclaredWrite.UpsertBase or UndeclaredWrite.RemoveBase ? DriftBase : DriftVariant; var refusal = Assert.Throws(() => { switch (write) { case UndeclaredWrite.UpsertBase: narrow.Commit(batch => batch.Upsert(typeof(DriftNarrow), new DriftNarrow { Name = "changed" }, DriftBase)); break; case UndeclaredWrite.UpsertVariant: narrow.Commit(batch => batch.UpsertVariant(DriftVariant, DriftBase, new[] { narrow.Override(nameof(DriftNarrow.Name), "changed") })); break; case UndeclaredWrite.FlattenVariant: narrow.FlattenAsync(DriftVariant).GetAwaiter().GetResult(); break; case UndeclaredWrite.RemoveBase: narrow.Commit(batch => batch.Remove(DriftBase)); break; case UndeclaredWrite.RemoveVariant: narrow.Commit(batch => batch.Remove(DriftVariant)); break; } })!; Assert.That((refusal.RecordKey, refusal.SchemaId), Is.EqualTo((key.Value, DriftWideId))); narrow.FlushAllBackingStores(); } Assert.That(File.ReadAllBytes(path), Is.EqualTo(before)); Assert.That(OverriddenSlotsOn(path, DriftVariant.Value).OrderBy(slot => slot), Is.EqualTo(new[] { 0, 2 })); } // Only the read-only key is read-only: a variant of a read-only base, written at a new key, is a write of the variant alone and lands. [Test] public void AVariantOfAReadOnlyBaseLands() { var path = PathOf("drift-read-only-base.cc"); SeedDrift(path); var fresh = new CultRecordKey("fresh-variant"); var narrowRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(DriftNarrow) }); using (var narrow = Open(path, registry: narrowRegistry)) narrow.Commit(batch => batch.UpsertVariant(fresh, DriftBase, new[] { narrow.Override(nameof(DriftNarrow.Name), "fresh") })); using var reopened = Open(path, registry: narrowRegistry); Assert.That(reopened.Get(fresh)!.Name, Is.EqualTo("fresh")); Assert.That(ReadStore(path).Records.Single(record => record.Key == DriftBase.Value).SchemaId, Is.EqualTo(DriftWideId)); } [Test] public void ADriftedVariantSatisfiesExpectUnchanged() { var path = PathOf("drift.cc"); WriteWideVariant(path); using var narrow = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftNarrow) })); Assert.That(ExpectVariantCommit(narrow), Is.True, "an unchanged drifted variant failed Expect and ExpectUnchanged"); } [Test] public void AnOwnFlushKeepsADriftedVariantsExpectHolding() { var path = PathOf("drift.cc"); WriteWideVariant(path); using var narrow = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftNarrow) })); narrow.BackingStores[0].PushAll(); Assert.That(ExpectVariantCommit(narrow), Is.True, "Expect on a drifted variant failed after this cache's own flush rewrote it"); } private static void WriteWideVariant(string path) { using var writer = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftWide) })); writer.Commit(batch => { batch.Upsert(typeof(DriftWide), new DriftWide { Name = "drift", Power = 1, Wing = "base wing" }, new CultRecordKey("drift-base")); batch.UpsertVariant(new CultRecordKey("drift-variant"), new CultRecordKey("drift-base"), new[] { writer.Override(nameof(DriftWide.Name), "drift variant"), writer.Override(nameof(DriftWide.Wing), "stale wing") }); }); } private static bool ExpectVariantCommit(CultCache cache) { var variant = new CultRecordKey("drift-variant"); return cache.Commit(batch => { batch.Expect(variant, cache.Get(variant)); batch.ExpectUnchanged(); batch.Upsert(typeof(DriftNarrow), new DriftNarrow { Name = "other", Power = 2 }, new CultRecordKey("other")); }); } [Test] public void AnOverrideOfASlotTheTypeNeverHadIsReportedOnEveryLoadAndStaysStoredUntilTheVariantIsWritten() { var path = WriteStore(PlainGear("hand-base"), OverridingRecord("hand-variant", "hand-base", CultVariantOverride.Set(99, new byte[] { 0x01 }), TextOverride(0, "hand variant"), TextOverride(2, "hand-code"))); using (var cache = Open(path)) { var variantReport = cache.BackingStores[0].LastSchemaMigrationReports.Single(report => report.IgnoredExtraSlots.Length > 0); Assert.That(variantReport.IgnoredExtraSlots, Is.EqualTo(new[] { 99 })); Assert.That(variantReport.Warnings.Select(warning => warning.Code), Has.Some.EqualTo("ignored_extra_slot")); Assert.That(variantReport.Warnings.Select(warning => warning.Message), Has.Some.Contain("hand-variant")); cache.BackingStores[0].PushAll(); } Assert.That(OverriddenSlotsOn(path, "hand-variant").OrderBy(slot => slot), Is.EqualTo(new[] { 0, 2, 99 }), "a flush that staged nothing copies the variant as stored"); using (var reopened = Open(path)) { Assert.That(reopened.BackingStores[0].LastSchemaMigrationReports.SelectMany(report => report.IgnoredExtraSlots), Is.EqualTo(new[] { 99 })); Assert.That(reopened.Get(new CultRecordKey("hand-variant"))!.Name, Is.EqualTo("hand variant")); reopened.Commit(batch => batch.UpsertVariant(new CultRecordKey("hand-variant"), new CultRecordKey("hand-base"), new[] { reopened.Override(nameof(VariantGear.Name), "hand variant"), reopened.Override(nameof(VariantGear.Code), "hand-code") })); } Assert.That(OverriddenSlotsOn(path, "hand-variant").OrderBy(slot => slot), Is.EqualTo(new[] { 0, 2 }), "a write of the variant stores only what it was given"); } private static int[] OverriddenSlotsOn(string path, string key) => CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).Records .Single(record => record.Key == key).Variant!.Overrides.Select(entry => entry.Path[0].Slot).ToArray(); // ---- flatten resolves against the same batch ---- [Test] public void AFlattenAndABaseEditInOneBatchLandTheResolutionOfTheWholeBatch() { var path = PathOf("gear.cc"); using (var cache = Open(path)) { SeedBase(cache); SeedBig(cache); cache.Commit(batch => { var edited = Laser(); edited.Tags.Add("pierce"); batch.Upsert(typeof(VariantGear), edited, BaseKey); batch.Flatten(BigKey); }); Assert.That(cache.GetStored(BigKey)!.Variant, Is.Null); Assert.That(cache.Get(BigKey)!.Tags, Is.EqualTo(new[] { "beam", "pierce" }), "the edit made in the same batch"); Assert.That(cache.Get(BigKey)!.Power, Is.EqualTo(25)); } using var reloaded = Open(path); Assert.That(reloaded.GetStored(BigKey)!.Variant, Is.Null); Assert.That(reloaded.Get(BigKey)!.Tags, Is.EqualTo(new[] { "beam", "pierce" })); } // ---- the held index is truthful: every holder of a value, so no rule judges a variant against a lossy view ---- private static VariantGear Named(string name, string code) => new() { Name = name, Power = 1, Code = code }; private static CultRecordKey[] SharedCodePair(CultCache cache) { var p1 = new CultRecordKey("p1"); var p2 = new CultRecordKey("p2"); cache.Commit(batch => { batch.Upsert(typeof(VariantGear), Named("p1", "x"), p1); batch.Upsert(typeof(VariantGear), Named("p2", "x"), p2); }); return new[] { p1, p2 }; } private static void VariantOnCode(CultCache cache, string code) => cache.Commit(batch => batch.UpsertVariant(BigKey, BaseKey, new[] { cache.Override(nameof(VariantGear.Name), "laser big"), cache.Override(nameof(VariantGear.Code), code) })); [Test] public void RemovingTheHolderADuplicateValueFindsLeavesTheOtherFindable() { var path = PathOf("gear.cc"); using (var cache = Open(path)) { var pair = SharedCodePair(cache); Assert.That(cache.GetByIndex("code", "x")!.Name, Is.EqualTo("p1"), "the smallest key"); cache.Remove(pair[0]); Assert.That(cache.GetByIndex("code", "x")!.Name, Is.EqualTo("p2"), "removing the holder finds the other one"); cache.Commit(batch => batch.Upsert(typeof(VariantGear), Named("p1", "x"), pair[0])); Assert.That(cache.GetByIndex("code", "x")!.Name, Is.EqualTo("p1")); cache.Remove(pair[1]); Assert.That(cache.GetByIndex("code", "x")!.Name, Is.EqualTo("p1"), "removing the other changes nothing"); cache.Remove(pair[0]); Assert.That(cache.GetByIndex("code", "x"), Is.Null); cache.BackingStores[0].PushAll(); } using var reopened = Open(path); Assert.That(reopened.GetByIndex("code", "x"), Is.Null); } [Test] public void AVariantOnAValueAPlainRecordStillHoldsAfterItsTwinWasRemovedIsRefusedAndTheStoreReopens() { var path = PathOf("gear.cc"); using (var cache = Open(path)) { SeedBase(cache); var pair = SharedCodePair(cache); cache.Remove(pair[1]); var refused = Refused(() => VariantOnCode(cache, "x")); Assert.That(refused.Message, Does.Contain(BigKey.Value).And.Contain("p1").And.Contain("code").And.Contain("'x'")); Assert.That(cache.Get(BigKey), Is.Null, "nothing landed"); cache.BackingStores[0].PushAll(); } using var reopened = Open(path); Assert.That(reopened.Get(BigKey), Is.Null); Assert.That(reopened.GetByIndex("code", "x")!.Name, Is.EqualTo("p1")); } [Test] public void AVariantOnAValueAPlainRecordStillHoldsAfterItsTwinChangedItsValueIsRefusedAndTheStoreReopens() { var path = PathOf("gear.cc"); using (var cache = Open(path)) { SeedBase(cache); var pair = SharedCodePair(cache); cache.Commit(batch => batch.Upsert(typeof(VariantGear), Named("p2", "y"), pair[1])); var refused = Refused(() => VariantOnCode(cache, "x")); Assert.That(refused.Message, Does.Contain(BigKey.Value).And.Contain("p1").And.Contain("'x'")); Assert.DoesNotThrow(() => VariantOnCode(cache, "z"), "an unshared value is fine"); cache.BackingStores[0].PushAll(); } using var reopened = Open(path); Assert.That(reopened.GetByIndex("code", "z")!.Name, Is.EqualTo("laser big")); Assert.That(reopened.GetByIndex("code", "x")!.Name, Is.EqualTo("p1")); } // ---- a merge onto the file is judged on the set the file will hold ---- [Test] public void AConditionalCommitThatWouldShareAnIndexedValueWithAnotherWritersVariantIsRefusedAndTheStoreReopens() { var path = PathOf("gear.cc"); var other = new CultRecordKey("other"); using (var seed = Open(path)) SeedBase(seed); using (var a = Open(path)) using (var b = Open(path)) { a.Commit(batch => batch.UpsertVariant(BigKey, BaseKey, new[] { a.Override(nameof(VariantGear.Name), "laser big"), a.Override(nameof(VariantGear.Code), "shared") })); void Write() => b.Commit(batch => { batch.Expect(other, null); batch.Upsert(typeof(VariantGear), Named("other", "shared"), other); }); var conflict = Assert.Throws(Write)!; Assert.That(conflict.ChangedKeys, Does.Contain(BigKey.Value)); var refused = Refused(Write); Assert.That(refused.Message, Does.Contain(other.Value).And.Contain(BigKey.Value).And.Contain("code").And.Contain("shared"), "the retry is judged by Resolve"); Assert.That(b.Get(other), Is.Null, "nothing landed in memory"); } using var reopened = Open(path); Assert.That(reopened.Get(other), Is.Null, "nothing landed on the file"); Assert.That(reopened.GetByIndex("code", "shared")!.Name, Is.EqualTo("laser big")); } [Test] public void AConditionalCommitThatRemovesAnotherWritersVariantsBaseIsRefusedAndTheStoreReopens() { var path = PathOf("gear.cc"); using (var seed = Open(path)) SeedBase(seed); using (var a = Open(path)) using (var b = Open(path)) { var seen = b.Get(BaseKey)!; SeedBig(a); void Write() => b.Commit(batch => { batch.Expect(BaseKey, seen); batch.Remove(BaseKey); }); var conflict = Assert.Throws(Write)!; Assert.That(conflict.ChangedKeys, Does.Contain(BigKey.Value)); var refused = Refused(Write); Assert.That(refused.Message, Does.Contain(BaseKey.Value).And.Contain(BigKey.Value), "the retry is judged by Resolve"); Assert.That(b.Get(BaseKey), Is.Not.Null, "nothing landed in memory"); } using var reopened = Open(path); Assert.That(reopened.Get(BaseKey), Is.Not.Null); Assert.That(reopened.Get(BigKey)!.Name, Is.EqualTo("laser big")); } // The cache holds a copy of the base another writer has since renamed; the merge is judged on the renamed base. [Test] public void AConditionalCommitIsJudgedOnAnotherWritersChangeToARecordThisCacheHolds() { var path = PathOf("gear.cc"); var other = new CultRecordKey("other"); using (var seed = Open(path)) Assert.That(seed.Commit(batch => { batch.Upsert(typeof(VariantGear), Laser(), BaseKey); batch.Upsert(typeof(VariantGear), Named("other", "o1"), other); }), Is.True); using (var a = Open(path)) { using (var w = Open(path)) Assert.That(w.Commit(batch => batch.Upsert(typeof(VariantGear), Named("x", "l1"), BaseKey)), Is.True); var refused = Assert.Throws(() => a.Commit(batch => { batch.Expect(other, a.Get(other)); batch.UpsertVariant(BigKey, BaseKey, new[] { a.Override(nameof(VariantGear.Name), "x"), a.Override(nameof(VariantGear.Code), "big") }); }))!; Assert.That(refused.Message, Does.Contain(BigKey.Value)); Assert.That(refused.ChangedKeys, Is.EqualTo(new[] { BaseKey.Value }), "the refusal names the base another writer renamed"); } using var reopened = Open(path, readOnly: true); Assert.That(reopened.GetStored(BigKey), Is.Null, "nothing landed on the file"); } [Test] public void AConditionalCommitThatIsSoundOntoAVariantStoreAnotherWriterChangedIsRefusedThenLandsOnRetry() { var path = PathOf("gear.cc"); var other = new CultRecordKey("other"); using (var seed = Open(path)) SeedBase(seed); using (var a = Open(path)) using (var b = Open(path)) { SeedBig(a); bool Write() => b.Commit(batch => { batch.Expect(other, null); batch.Upsert(typeof(VariantGear), Named("other", "fine"), other); }); Assert.Throws(() => Write()); Assert.That(b.Get(BigKey), Is.Not.Null, "the refusal reloaded the other writer's variant"); Assert.That(Write(), Is.True); } using var reopened = Open(path); Assert.That(reopened.Get(other), Is.Not.Null); Assert.That(reopened.Get(BigKey), Is.Not.Null, "the other writer's variant survives the merge"); } [Test] public void AVariantLoadedUnderANarrowerTypeHoldsOnlyTheOverridesTheTypeHasAndReportsTheRest() { var path = PathOf("drift.cc"); using (var writer = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftWide) }))) { writer.Commit(batch => { batch.Upsert(typeof(DriftWide), new DriftWide { Name = "drift", Power = 1, Wing = "base wing" }, new CultRecordKey("drift-base")); batch.UpsertVariant(new CultRecordKey("drift-variant"), new CultRecordKey("drift-base"), new[] { writer.Override(nameof(DriftWide.Name), "drift variant"), writer.Override(nameof(DriftWide.Wing), "stale wing") }); }); } using var narrow = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftNarrow) })); Assert.That(narrow.GetStored(new CultRecordKey("drift-variant"))!.Variant!.Overrides.Select(entry => entry.Path[0].Slot), Is.EqualTo(new[] { 0 }), "the dropped slot's override is shed when the record is loaded, not decided again at flush"); Assert.That(narrow.BackingStores[0].LastSchemaMigrationReports.SelectMany(report => report.IgnoredExtraSlots), Does.Contain(2)); } // ---- batch 4: linear rule, judge only when a variant is involved, deterministic duplicates, one warning ---- [CultDocument("tests.variant_counted", "tests.variant_counted.v1")] [MessagePackObject(AllowPrivate = true)] internal sealed class CountedGear { public static long CodeReads; [IgnoreMember] private string _code = string.Empty; [Key(0)] [CultName] public string Name = string.Empty; [Key(1)] [CultIndex("code")] public string Code { get { System.Threading.Interlocked.Increment(ref CodeReads); return _code; } set => _code = value; } } // The pin is a count of index-accessor reads, not a clock: linear work doubles with the records, quadratic work quadruples. private long IndexReadsOpening(int records) { var registry = CultDocumentRegistry.ForTypes(new[] { typeof(CountedGear) }); var path = PathOf($"counted-{records}.cc"); using (var writer = Open(path, registry: registry)) { writer.Commit(batch => { for (var index = 0; index < records; index++) batch.Upsert(typeof(CountedGear), new CountedGear { Name = $"n{index}", Code = $"c{index}" }, new CultRecordKey($"k{index}")); }); writer.Commit(batch => batch.UpsertVariant(new CultRecordKey("kv"), new CultRecordKey("k0"), new[] { writer.Override(nameof(CountedGear.Name), "variant"), writer.Override(nameof(CountedGear.Code), "variant-code") })); } System.Threading.Interlocked.Exchange(ref CountedGear.CodeReads, 0); using (Open(path, registry: registry)) return System.Threading.Interlocked.Read(ref CountedGear.CodeReads); } [Test] public void OpeningAStoreWithAVariantReadsEachIndexedValueAFixedNumberOfTimes() { var small = IndexReadsOpening(1000); var large = IndexReadsOpening(2000); Assert.That(large, Is.LessThan(small * 3), $"index reads: {small} at 1000 records, {large} at 2000; doubling the records must not quadruple the work"); } [Test] public void ASchemaThisCacheDoesNotRegisterDoesNotFailAConditionalCommitThatInvolvesNoVariant() { var path = PathOf("gear.cc"); var other = new CultRecordKey("other"); var narrow = CultDocumentRegistry.ForTypes(new[] { typeof(VariantGear) }); using (var seed = Open(path)) SeedBase(seed); using (var a = Open(path)) using (var b = Open(path, registry: narrow)) { a.Commit(batch => batch.Upsert(typeof(VariantOther), new VariantOther { Name = "foreign" }, new CultRecordKey("foreign"))); Assert.That(b.Commit(batch => { batch.Expect(other, null); batch.Upsert(typeof(VariantGear), Named("other", "fine"), other); }), Is.True); } using var reopened = Open(path); Assert.That(reopened.Get(other), Is.Not.Null); Assert.That(reopened.Get(new CultRecordKey("foreign")), Is.Not.Null, "the other writer's record survives the merge"); } [Test] public void AVariantAnotherWriterLandedIsRefusedAsChangedWhenThisCacheHoldsNoVariant() { var path = PathOf("gear.cc"); var other = new CultRecordKey("other"); using (var seed = Open(path)) SeedBase(seed); using (var a = Open(path)) using (var b = Open(path)) { VariantOnCode(a, "shared"); Assert.Throws(() => b.Commit(batch => { batch.Expect(other, null); batch.Upsert(typeof(VariantGear), Named("other", "shared"), other); })); } } [Test] public void PlainDuplicatesFindTheSmallestKeyLiveAfterAFlushAndAfterAReopen() { var path = PathOf("gear.cc"); using (var cache = Open(path)) { cache.Commit(batch => batch.Upsert(typeof(VariantGear), Named("nb", "x"), new CultRecordKey("b"))); cache.Commit(batch => batch.Upsert(typeof(VariantGear), Named("na", "x"), new CultRecordKey("a"))); Assert.That(cache.GetByIndex("code", "x")!.Name, Is.EqualTo("na"), "live"); cache.BackingStores[0].PushAll(); Assert.That(cache.GetByIndex("code", "x")!.Name, Is.EqualTo("na"), "after a flush"); } using (var reopened = Open(path)) { Assert.That(reopened.GetByIndex("code", "x")!.Name, Is.EqualTo("na"), "after a reopen"); reopened.Remove(new CultRecordKey("a")); reopened.Commit(batch => batch.Upsert(typeof(VariantGear), Named("na", "x"), new CultRecordKey("a"))); Assert.That(reopened.GetByIndex("code", "x")!.Name, Is.EqualTo("na"), "rewriting the smaller key does not change the choice"); } } [Test] public void ADroppedSlotIsReportedOncePerRecord() { var path = PathOf("drift.cc"); using (var writer = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftWide) }))) { writer.Commit(batch => { batch.Upsert(typeof(DriftWide), new DriftWide { Name = "drift", Power = 1, Wing = "base wing" }, new CultRecordKey("drift-base")); batch.UpsertVariant(new CultRecordKey("drift-variant"), new CultRecordKey("drift-base"), new[] { writer.Override(nameof(DriftWide.Name), "drift variant"), writer.Override(nameof(DriftWide.Wing), "stale wing") }); }); } using var narrow = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftNarrow) })); var variantReport = narrow.BackingStores[0].LastSchemaMigrationReports.Last(); Assert.That(variantReport.Warnings.Count(warning => warning.Code == "ignored_extra_slot"), Is.EqualTo(1)); Assert.That(variantReport.IgnoredExtraSlots, Is.EqualTo(new[] { 2 })); } // ---- the directory store refuses a variant page on load as it does on write ---- [Test] public void TheDirectoryStoreRefusesToLoadAVariantPage() { var manifest = PathOf("dir-page.cc"); var records = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(manifest); Directory.CreateDirectory(records); var page = CultDocumentMessagePackSerialization.SerializePersistedRecord(OverridingRecord("hand-variant", "hand-base", TextOverride(0, "x"))); var hash = System.Security.Cryptography.SHA256.HashData(page); File.WriteAllBytes(Path.Combine(records, Convert.ToHexString(hash).ToLowerInvariant() + ".msgpack"), page); using var store = new DirectoryMessagePackBackingStore(manifest, records); var read = typeof(DirectoryMessagePackBackingStore).GetMethod( "ReadPersistedRecordPage", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; var refused = Assert.Throws( () => read.Invoke(store, new object?[] { new CultPersistedRecord { Key = "hand-variant", Payload = hash }, null }))!; Assert.That(refused.InnerException, Is.TypeOf()); Assert.That(refused.InnerException!.Message, Does.Contain("hand-variant").And.Contain("directory store")); } // ---- persisted format ---- private static string HeaderOf(string path) => CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).FormatVersion; [Test] public void TheHeaderIsV1WithoutVariantsAndV2WithThem() { var path = PathOf("gear.cc"); using var cache = Open(path); SeedBase(cache); Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1")); SeedBig(cache); Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v2")); var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); var variant = snapshot.Records.Single(record => record.Key == BigKey.Value); Assert.That(variant.Payload, Is.Empty, "the payload is the tripwire old readers refuse on"); Assert.That(variant.Variant!.BaseKey, Is.EqualTo(BaseKey.Value)); Assert.That(snapshot.Records.Single(record => record.Key == BaseKey.Value).Variant, Is.Null); cache.Commit(batch => batch.Remove(BigKey)); Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"), "the last variant gone, the store is v1 again"); } [Test] public async Task AStoreWithoutVariantsRewritesByteIdenticalToTheOneWrittenBeforeVariantsExisted() { var fixture = Path.Combine(TestContext.CurrentContext.TestDirectory, "Fixtures", "pre-cut2", "single-file-v1", "store.msgpack"); var copy = PathOf("store.msgpack"); File.Copy(fixture, copy); var before = File.ReadAllBytes(copy); using (var cache = new CultCache(CultDocumentRegistry.ForTypes(new[] { typeof(PreCut2FixtureItem), typeof(PreCut2FixtureNote) }))) { cache.AddBackingStore(new SingleFileMessagePackBackingStore(copy)); await cache.PullAllBackingStoresAsync(); cache.BackingStores[0].PushAll(); } Assert.That(File.ReadAllBytes(copy), Is.EqualTo(before)); } [Test] public void AVariantStoreRoundTripsByteIdenticalThroughAFlush() { var path = PathOf("gear.cc"); using (var cache = Open(path)) { SeedBase(cache); SeedBig(cache); } var written = File.ReadAllBytes(path); using (var cache = Open(path)) { cache.BackingStores[0].PushAll(); } Assert.That(File.ReadAllBytes(path), Is.EqualTo(written)); } [Test] public void AReadOnlyStoreResolvesItsVariants() { var path = PathOf("gear.cc"); using (var cache = Open(path)) { SeedBase(cache); SeedBig(cache); } using var readOnly = Open(path, readOnly: true); Assert.That(readOnly.Get(BigKey)!.Power, Is.EqualTo(25)); Assert.That(readOnly.Get(BigKey)!.Code, Is.EqualTo("laser big")); // A base edit another writer commits reaches the read-only cache's variant when it pulls: a re-resolution is no write. using var writer = Open(path); EditBase(writer, gear => gear.Tags.Add("pierce")); var before = File.ReadAllBytes(path); readOnly.PullAllBackingStoresAsync(); Assert.That(readOnly.Get(BigKey)!.Tags, Is.EqualTo(new[] { "beam", "pierce" })); Assert.That(File.ReadAllBytes(path), Is.EqualTo(before)); } [Test] public void ABaseEditFromAnotherWriterReachesVariantsWhenTheStoreIsPulled() { var path = PathOf("gear.cc"); using var a = Open(path); SeedBase(a); SeedBig(a); using var b = Open(path); Assert.That(b.Get(BigKey)!.Tags, Is.EqualTo(new[] { "beam" })); EditBase(a, gear => gear.Tags.Add("pierce")); b.PullAllBackingStoresAsync(); Assert.That(b.Get(BigKey)!.Tags, Is.EqualTo(new[] { "beam", "pierce" }), "the pull carried only the base; the variant was re-resolved in the admitting hold"); } [Test] public void AFlattenFromAnotherWriterReachesACacheHoldingTheVariant() { var path = PathOf("gear.cc"); using var a = Open(path); SeedBase(a); SeedBig(a); using var b = Open(path); Assert.That(b.GetStored(BigKey)!.Variant, Is.Not.Null); a.FlattenAsync(BigKey).GetAwaiter().GetResult(); a.FlushAsync().GetAwaiter().GetResult(); b.PullAllBackingStoresAsync(); Assert.That(b.GetStored(BigKey)!.Variant, Is.Null, "a load reports what is on disk; it is not a plain write refused at a variant key"); Assert.That(b.Get(BigKey)!.Power, Is.EqualTo(25)); } [Test] public void ARebaseResolvesFromTheNewBase() { using var cache = Open(PathOf("gear.cc")); SeedBase(cache); SeedBig(cache); var spare = new CultRecordKey("spare"); cache.Commit(batch => batch.Upsert(typeof(VariantGear), new VariantGear { Name = "spare", Code = "s1", Tags = { "arc" } }, spare)); cache.Commit(batch => batch.UpsertVariant(BigKey, spare, cache.GetStored(BigKey)!.Variant!.Overrides)); Assert.That(cache.Get(BigKey)!.Tags, Is.EqualTo(new[] { "arc" })); Assert.That(cache.Get(BigKey)!.Power, Is.EqualTo(25)); Assert.That(cache.GetStored(BigKey)!.Variant!.BaseKey, Is.EqualTo(spare.Value)); } // ---- readers ---- private string WriteStore(params CultPersistedRecord[] records) => WriteStore(CultPersistedStoreSnapshot.FormatV2, records); private string WriteStore(string formatVersion, params CultPersistedRecord[] records) { var path = PathOf($"hand-{Guid.NewGuid():N}.cc"); var gear = Registry.GetRequired(); File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(new CultPersistedStoreSnapshot { FormatVersion = formatVersion, SchemaCatalog = new[] { gear.ToCatalogEntry() }, Records = records })); return path; } private static CultPersistedRecord PlainGear(string key) => new() { Key = key, SchemaId = Registry.GetRequired().SchemaId, StoredAt = "2026-01-01T00:00:00.0000000Z", Payload = CultDocumentMessagePackSerialization.SerializeUntyped(Laser(), typeof(VariantGear), Registry) }; private static CultPersistedRecord VariantGearRecord(string key, string baseKey, byte[]? payload = null, CultOverrideOp op = CultOverrideOp.Set) => new() { Key = key, SchemaId = Registry.GetRequired().SchemaId, StoredAt = "2026-01-01T00:00:00.0000000Z", Payload = payload ?? Array.Empty(), Variant = new CultVariantDelta(baseKey, new[] { new CultVariantOverride(op, new[] { new CultPathStep(0) }, string.Empty, new byte[] { 0xa1, (byte)'x' }) }) }; [Test] public void AVariantRecordUnderAV1HeaderIsRefusedNamingTheRecord() { var path = WriteStore(CultPersistedStoreSnapshot.FormatV1, PlainGear("hand-base"), VariantGearRecord("hand-variant", "hand-base")); var error = Assert.Throws(() => Open(path).Dispose())!; Assert.That(error.Message, Does.Contain("hand-variant").And.Contain("cultcache.store.v2")); } [Test] public void AVariantRecordWithAPayloadIsRefusedNamingTheRecord() { var path = WriteStore(PlainGear("hand-base"), VariantGearRecord("hand-variant", "hand-base", payload: new byte[] { 0x90 })); var error = Assert.Throws(() => Open(path).Dispose())!; Assert.That(error.Message, Does.Contain("hand-variant").And.Contain("payload")); } [Test] public void AnOverrideWithAnUnknownOpIsRefusedNamingTheRecord() { var path = WriteStore(PlainGear("hand-base"), VariantGearRecord("hand-variant", "hand-base", op: (CultOverrideOp)7)); var error = Assert.Throws(() => Open(path).Dispose())!; Assert.That(error.Message, Does.Contain("hand-variant").And.Contain("op 7")); } // ---- the other stores refuse loudly until their own cuts ---- [Test] public void TheDirectoryStoreRefusesAVariantByPushAndByCommit() { using var cache = CultCacheMessagePack.Create(PathOf("dir.cc"), new CultCacheOpenOptions { Registry = Registry, UseDirectoryStore = true }); SeedBase(cache); var push = Assert.Throws(() => cache.UpsertVariantAsync( BigKey, BaseKey, new[] { cache.Override(nameof(VariantGear.Name), "laser big"), cache.Override(nameof(VariantGear.Code), "laser big") }).GetAwaiter().GetResult())!; Assert.That(push.Message, Does.Contain(BigKey.Value)); var commit = Assert.Throws(() => SeedBig(cache))!; Assert.That(commit.Message, Does.Contain(BigKey.Value)); Assert.That(cache.Get(BigKey), Is.Null); } // ---- shared vector ---- // Written once by the code under test through the production path; every runtime's tests read the committed bytes. // CULTLIB_WRITE_VARIANT_VECTOR=1 prints the store as base64 instead of asserting. [Test] public void TheCommittedC1VectorResolvesAndRewritesByteIdentical() { var vector = VectorPath("variant-store.msgpack"); var path = PathOf("gear.cc"); if (Environment.GetEnvironmentVariable("CULTLIB_WRITE_VARIANT_VECTOR") == "1") { using (var cache = Open(path)) { SeedBase(cache); SeedBig(cache); } Console.WriteLine("VARIANT-VECTOR-BASE64:" + Convert.ToBase64String(File.ReadAllBytes(path))); Assert.Pass("vector printed"); } File.Copy(vector, path); using (var cache = Open(path)) { Assert.That(cache.Get(BigKey)!.Power, Is.EqualTo(25)); Assert.That(cache.Get(BigKey)!.Code, Is.EqualTo("laser big")); cache.BackingStores[0].PushAll(); } Assert.That(File.ReadAllBytes(path), Is.EqualTo(File.ReadAllBytes(vector))); Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v2")); } private static string VectorPath(string name) { for (var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); dir != null; dir = dir.Parent) { var candidate = Path.Combine(dir.FullName, "tests", "vectors", "document-variants-c1", name); if (File.Exists(candidate) || Environment.GetEnvironmentVariable("CULTLIB_WRITE_VARIANT_VECTOR") == "1" && Directory.Exists(Path.GetDirectoryName(candidate))) return candidate; } if (Environment.GetEnvironmentVariable("CULTLIB_WRITE_VARIANT_VECTOR") == "1") return name; throw new FileNotFoundException($"Shared vector {name} not found above {TestContext.CurrentContext.TestDirectory}."); } [CultDocument("tests.variant_gear", "tests.variant_gear.v1")] [MessagePackObject(AllowPrivate = true)] internal sealed class VariantGear { [Key(0)] [CultName] public string Name = string.Empty; [Key(1)] public int Power; [Key(2)] [CultIndex("code")] public string Code = string.Empty; [Key(3)] public List Tags = new(); } [CultDocument("tests.variant_other", "tests.variant_other.v1")] [MessagePackObject(AllowPrivate = true)] internal sealed class VariantOther { [Key(0)] [CultName] public string Name = string.Empty; } [CultDocument("tests.variant_global", "tests.variant_global.v1")] [MessagePackObject(AllowPrivate = true)] [CultGlobal] internal sealed class VariantGlobal { [Key(0)] public string Value = string.Empty; } // The same schema at two versions of the type: Wide has a member Narrow later dropped. [CultDocument("tests.variant_drift", "tests.variant_drift.v1")] [MessagePackObject(AllowPrivate = true)] internal sealed class DriftWide { [Key(0)] [CultName] public string Name = string.Empty; [Key(1)] public int Power; [Key(2)] public string Wing = string.Empty; } // A later type that reuses the slot Narrow dropped, for another member. [CultDocument("tests.variant_drift", "tests.variant_drift.v3")] [MessagePackObject(AllowPrivate = true)] internal sealed class DriftReused { [Key(0)] [CultName] public string Name = string.Empty; [Key(1)] public int Power; [Key(2)] public string Colour = string.Empty; } [CultDocument("tests.variant_drift", "tests.variant_drift.v2")] [MessagePackObject(AllowPrivate = true)] internal sealed class DriftNarrow { [Key(0)] [CultName] public string Name = string.Empty; [Key(1)] public int Power; } } }