CultLib/tests/GameCult.Caching.Tests/VariantTests.cs
Meta Vens b93934f6b4 Pin the variant rules a mutant survived or that lost their only test
Reader refusals (v1 header over a variant, payload on a variant, unknown op), a
load that finds a variant flattened by another writer, a re-resolution reaching a
read-only cache, a base removed in the same batch as its new variant, and
plain-record-to-variant refused without the name rule masking it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 04:21:59 +02:00

795 lines
36 KiB
C#

#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 and power 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<VariantGear>(nameof(VariantGear.Name), name),
cache.Override<VariantGear>(nameof(VariantGear.Power), power)
}));
private static void EditBase(CultCache cache, Action<VariantGear> edit) =>
cache.Commit(batch =>
{
var edited = Laser();
edit(edited);
batch.Upsert(typeof(VariantGear), edited, BaseKey);
});
private static InvalidOperationException Refused(TestDelegate write) =>
Assert.Throws<InvalidOperationException>(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<VariantGear>(BigKey)!;
Assert.That(big.Name, Is.EqualTo("laser big"));
Assert.That(big.Power, Is.EqualTo(25));
Assert.That(big.Code, Is.EqualTo("l1"), "an inherited member");
Assert.That(big.Tags, Is.EqualTo(new[] { "beam" }), "an inherited list");
Assert.That(cache.Get<VariantGear>(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(2));
}
[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<VariantGear>(hugeKey)!.Tags, Is.EqualTo(new[] { "beam" }));
EditBase(cache, gear => gear.Tags.Add("pierce"));
Assert.That(cache.Get<VariantGear>(hugeKey)!.Tags, Is.EqualTo(new[] { "beam", "pierce" }));
Assert.That(cache.Get<VariantGear>(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<VariantGear>(nameof(VariantGear.Name), "laser coded"),
cache.Override<VariantGear>(nameof(VariantGear.Code), "l9")
}));
var seen = new List<(string Key, long Sequence, string BigTagsAtThatMoment, string BigNameLookupTags)>();
using var subscription = cache.Watch<VariantGear>().Subscribe(change => seen.Add((
change.Key.Value,
change.Sequence,
string.Join(",", cache.Get<VariantGear>(BigKey)!.Tags),
string.Join(",", cache.GetByName<VariantGear>("laser big")!.Tags))));
EditBase(cache, gear => { gear.Tags.Add("pierce"); gear.Code = "l2"; });
Assert.That(cache.Get<VariantGear>(BigKey)!.Tags, Is.EqualTo(new[] { "beam", "pierce" }));
Assert.That(cache.GetByName<VariantGear>("laser big")!.Tags, Is.EqualTo(new[] { "beam", "pierce" }));
Assert.That(cache.GetByName<VariantGear>("laser big")!.Power, Is.EqualTo(25), "the override survives the base edit");
Assert.That(cache.GetByIndex<VariantGear>("code", "l9")!.Tags, Is.EqualTo(new[] { "beam", "pierce" }));
// big inherited the old code; its stale index entry must be gone, not shadowing.
Assert.That(cache.GetByIndex<VariantGear>("code", "l1"), Is.Null);
Assert.That(cache.GetByIndex<VariantGear>("code", "l2")!.Name, Is.Not.Null);
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<CultCacheDocumentChange<VariantGear>>();
using var subscription = cache.WatchRecord<VariantGear>(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<VariantGear>(BigKey)!;
EditBase(b, gear => gear.Tags.Add("pierce"));
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<VariantGear>(BigKey)!;
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<VariantGear>();
var other = Registry.GetRequired<VariantOther>();
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<CultVariantOverride>())
}
}
}));
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<VariantGear>(BigKey)!.Code, Is.EqualTo(string.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<VariantGear>(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<VariantGear>(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<VariantGear>(BigKey)!.Power, Is.EqualTo(25), "flatten keeps the resolved values");
EditBase(cache, gear => gear.Tags.Add("pierce"));
Assert.That(cache.Get<VariantGear>(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<VariantGear>(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<VariantGear>(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<DriftWide>(nameof(DriftWide.Name), "drift variant"),
writer.Override<DriftWide>(nameof(DriftWide.Power), 7),
writer.Override<DriftWide>(nameof(DriftWide.Wing), "variant wing")
});
});
}
var narrow = CultDocumentRegistry.ForTypes(new[] { typeof(DriftNarrow) });
using var cache = Open(path, registry: narrow);
var variant = cache.Get<DriftNarrow>(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"));
var report = cache.VariantMigrationReports.Single();
Assert.That(report.Kind, Is.EqualTo(CultSchemaMigrationKind.CompatibleDrift));
Assert.That(report.IgnoredExtraSlots, Is.EqualTo(new[] { 2 }));
var warning = report.Warnings.Single();
Assert.That(warning.Code, Is.EqualTo("ignored_extra_slot"), "the code a dropped member gets on a plain record");
Assert.That(warning.Message, Does.Contain("drift-variant").And.Contain("Wing"));
}
[Test]
public void AnOverrideThatNoLongerDecodesIsIgnoredWithAWarningAndTheBaseValueApplies()
{
var path = PathOf("gear.cc");
const int notAnInt = 1; // VariantGear.Power, [Key(1)]
using (var cache = Open(path))
{
SeedBase(cache);
cache.Commit(batch => batch.UpsertVariant(BigKey, BaseKey, new[]
{
cache.Override<VariantGear>(nameof(VariantGear.Name), "laser big"),
CultVariantOverride.Set(notAnInt, new byte[] { 0xa3, (byte)'a', (byte)'b', (byte)'c' })
}));
var warning = cache.VariantMigrationReports.Single().Warnings.Single();
Assert.That(warning.Message, Does.Contain(BigKey.Value).And.Contain("Power"));
Assert.That(cache.Get<VariantGear>(BigKey)!.Power, Is.EqualTo(10), "inherits the base value");
Assert.That(cache.Get<VariantGear>(BigKey)!.Name, Is.EqualTo("laser big"));
}
using var reloaded = Open(path);
Assert.That(reloaded.Get<VariantGear>(BigKey)!.Power, Is.EqualTo(10));
Assert.That(reloaded.VariantMigrationReports.Single().Warnings.Single().Message, Does.Contain("Power"));
}
// ---- 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<VariantGear>(BigKey)!.Power, Is.EqualTo(25));
Assert.That(readOnly.Get<VariantGear>(BigKey)!.Code, Is.EqualTo("l1"));
// 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<VariantGear>(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<VariantGear>(BigKey)!.Tags, Is.EqualTo(new[] { "beam" }));
EditBase(a, gear => gear.Tags.Add("pierce"));
b.PullAllBackingStoresAsync();
Assert.That(b.Get<VariantGear>(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<VariantGear>(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<VariantGear>(BigKey)!.Tags, Is.EqualTo(new[] { "arc" }));
Assert.That(cache.Get<VariantGear>(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<VariantGear>();
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<VariantGear>().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<VariantGear>().SchemaId,
StoredAt = "2026-01-01T00:00:00.0000000Z",
Payload = payload ?? Array.Empty<byte>(),
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<NotSupportedException>(() => 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<NotSupportedException>(() => 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<NotSupportedException>(() => 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<NotSupportedException>(() => cache.UpsertVariantAsync(
BigKey, BaseKey, new[] { cache.Override<VariantGear>(nameof(VariantGear.Name), "laser big") }).GetAwaiter().GetResult())!;
Assert.That(push.Message, Does.Contain(BigKey.Value));
var commit = Assert.Throws<NotSupportedException>(() => 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<VariantGear>(BigKey)!.Power, Is.EqualTo(25));
Assert.That(cache.Get<VariantGear>(BigKey)!.Code, Is.EqualTo("l1"));
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<string> 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;
}
[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;
}
}
}