A staged write mints a later storedAt, so the 'rewrite kept storedAt' premise is gone; the pin is that a record rewritten under another schema fails Expect. The variant refusal names the renamed base through ChangedKeys, as C2a's typed exception carries it. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1839 lines
88 KiB
C#
1839 lines
88 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, 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<VariantGear>(nameof(VariantGear.Name), name),
|
|
cache.Override<VariantGear>(nameof(VariantGear.Power), power),
|
|
cache.Override<VariantGear>(nameof(VariantGear.Code), name)
|
|
}));
|
|
|
|
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("laser big"), "the override the unique index requires");
|
|
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(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<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" }));
|
|
// the base's old code entry must be gone, not shadowing, and its new one must find the base itself.
|
|
Assert.That(cache.GetByIndex<VariantGear>("code", "l1"), Is.Null);
|
|
Assert.That(cache.GetByIndex<VariantGear>("code", "l2")!.Name, Is.EqualTo("laser"));
|
|
Assert.That(cache.GetByIndex<VariantGear>("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<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"));
|
|
// 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<CultWriteConflictException>(() => a.Commit(batch =>
|
|
{
|
|
batch.Expect(BigKey, seen);
|
|
batch.Upsert(typeof(VariantOther), new VariantOther { Name = "marker" }, new CultRecordKey("marker"));
|
|
}));
|
|
seen = a.Get<VariantGear>(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<VariantGear>(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<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)!.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<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"));
|
|
|
|
// 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<VariantGear>().SchemaId,
|
|
StoredAt = "2026-01-01T00:00:00.0000000Z",
|
|
Payload = Array.Empty<byte>(),
|
|
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<VariantGear>().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<VariantGear>(new CultRecordKey("hand-variant"))!.Name, Is.EqualTo("hand variant"));
|
|
Assert.That(cache.Get<VariantGear>(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<VariantGear>(nameof(VariantGear.Power), 1),
|
|
cache.Override<VariantGear>(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<ArgumentException>(() => cache.Override<VariantGear>("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<VariantGear>(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<VariantGear>(nameof(VariantGear.Name), "laser big"),
|
|
cache.Override<VariantGear>(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<VariantGear>("code", "l1")!.Name, Is.EqualTo("laser"));
|
|
|
|
SeedBig(cache);
|
|
Assert.That(cache.GetByIndex<VariantGear>("code", "l1")!.Name, Is.EqualTo("laser"));
|
|
Assert.That(cache.GetByIndex<VariantGear>("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<VariantGear>(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<InvalidOperationException>(() => 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<InvalidOperationException>(
|
|
() => cache.UpsertAsync(Other("laser big"), new CultRecordHandle<VariantGear>(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<VariantGear>("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<VariantGear>(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<VariantGear>("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<VariantGear>(nameof(VariantGear.Name), "laser big"),
|
|
cache.Override<VariantGear>(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<VariantGear>(nameof(VariantGear.Name), "laser big"),
|
|
cache.Override<VariantGear>(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<CultWriteConflictException>(() =>
|
|
{
|
|
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<VariantGear>(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<CultWriteConflictException>(() => 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<VariantGear>(nameof(VariantGear.Name), "laser big"),
|
|
b.Override<VariantGear>(nameof(VariantGear.Power), 99),
|
|
b.Override<VariantGear>(nameof(VariantGear.Code), "laser big")
|
|
}));
|
|
Assert.That(a.Get<VariantGear>(BigKey)!.Power, Is.EqualTo(25));
|
|
|
|
var conflict = Assert.Throws<CultWriteConflictException>(() =>
|
|
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<VariantGear>(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<CultWriteConflictException>(() => 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<CultWriteConflictException>(() => 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<DriftWide>(nameof(DriftWide.Name), "drift variant"),
|
|
writer.Override<DriftWide>(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<DriftNarrow>(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<DriftReused>(DriftBase)!.Colour, Is.Empty);
|
|
Assert.That(reused.Get<DriftReused>(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<CultSchemaConflictException>(() =>
|
|
{
|
|
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<DriftNarrow>(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<DriftNarrow>(nameof(DriftNarrow.Name), "fresh") }));
|
|
|
|
using var reopened = Open(path, registry: narrowRegistry);
|
|
Assert.That(reopened.Get<DriftNarrow>(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<DriftWide>(nameof(DriftWide.Name), "drift variant"),
|
|
writer.Override<DriftWide>(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<VariantGear>(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<VariantGear>(nameof(VariantGear.Name), "hand variant"),
|
|
reopened.Override<VariantGear>(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<VariantGear>(BigKey)!.Tags, Is.EqualTo(new[] { "beam", "pierce" }), "the edit made in the same batch");
|
|
Assert.That(cache.Get<VariantGear>(BigKey)!.Power, Is.EqualTo(25));
|
|
}
|
|
|
|
using var reloaded = Open(path);
|
|
Assert.That(reloaded.GetStored(BigKey)!.Variant, Is.Null);
|
|
Assert.That(reloaded.Get<VariantGear>(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<VariantGear>(nameof(VariantGear.Name), "laser big"),
|
|
cache.Override<VariantGear>(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<VariantGear>("code", "x")!.Name, Is.EqualTo("p1"), "the smallest key");
|
|
|
|
cache.Remove(pair[0]);
|
|
Assert.That(cache.GetByIndex<VariantGear>("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<VariantGear>("code", "x")!.Name, Is.EqualTo("p1"));
|
|
cache.Remove(pair[1]);
|
|
Assert.That(cache.GetByIndex<VariantGear>("code", "x")!.Name, Is.EqualTo("p1"), "removing the other changes nothing");
|
|
cache.Remove(pair[0]);
|
|
Assert.That(cache.GetByIndex<VariantGear>("code", "x"), Is.Null);
|
|
cache.BackingStores[0].PushAll();
|
|
}
|
|
|
|
using var reopened = Open(path);
|
|
Assert.That(reopened.GetByIndex<VariantGear>("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<VariantGear>("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<VariantGear>("code", "z")!.Name, Is.EqualTo("laser big"));
|
|
Assert.That(reopened.GetByIndex<VariantGear>("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<VariantGear>(nameof(VariantGear.Name), "laser big"),
|
|
a.Override<VariantGear>(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<CultWriteConflictException>(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<VariantGear>("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<VariantGear>(BaseKey)!;
|
|
SeedBig(a);
|
|
|
|
void Write() => b.Commit(batch =>
|
|
{
|
|
batch.Expect(BaseKey, seen);
|
|
batch.Remove(BaseKey);
|
|
});
|
|
|
|
var conflict = Assert.Throws<CultWriteConflictException>(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<VariantGear>(BaseKey), Is.Not.Null);
|
|
Assert.That(reopened.Get<VariantGear>(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<CultWriteConflictException>(() => a.Commit(batch =>
|
|
{
|
|
batch.Expect(other, a.Get(other));
|
|
batch.UpsertVariant(BigKey, BaseKey, new[]
|
|
{
|
|
a.Override<VariantGear>(nameof(VariantGear.Name), "x"),
|
|
a.Override<VariantGear>(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<CultWriteConflictException>(() => 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<VariantGear>(other), Is.Not.Null);
|
|
Assert.That(reopened.Get<VariantGear>(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<DriftWide>(nameof(DriftWide.Name), "drift variant"),
|
|
writer.Override<DriftWide>(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<CountedGear>(nameof(CountedGear.Name), "variant"),
|
|
writer.Override<CountedGear>(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<VariantGear>(other), Is.Not.Null);
|
|
Assert.That(reopened.Get<VariantOther>(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<CultWriteConflictException>(() => 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<VariantGear>("code", "x")!.Name, Is.EqualTo("na"), "live");
|
|
cache.BackingStores[0].PushAll();
|
|
Assert.That(cache.GetByIndex<VariantGear>("code", "x")!.Name, Is.EqualTo("na"), "after a flush");
|
|
}
|
|
|
|
using (var reopened = Open(path))
|
|
{
|
|
Assert.That(reopened.GetByIndex<VariantGear>("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<VariantGear>("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<DriftWide>(nameof(DriftWide.Name), "drift variant"),
|
|
writer.Override<DriftWide>(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<System.Reflection.TargetInvocationException>(
|
|
() => read.Invoke(store, new object?[] { new CultPersistedRecord { Key = "hand-variant", Payload = hash }, null }))!;
|
|
|
|
Assert.That(refused.InnerException, Is.TypeOf<NotSupportedException>());
|
|
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<VariantGear>(BigKey)!.Power, Is.EqualTo(25));
|
|
Assert.That(readOnly.Get<VariantGear>(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<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<CultStoreUnreadableException>(() => 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<CultStoreUnreadableException>(() => 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<CultStoreUnreadableException>(() => 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"), cache.Override<VariantGear>(nameof(VariantGear.Code), "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("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<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;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
}
|