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>
795 lines
36 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|