From 3e65477f22aeeddb880d9603f0c46cd17ee548a3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 22 Oct 2026 22:01:34 +0200 Subject: [PATCH] Caching tests: a 1.0.x store with a ref-list slot opens at 2.0 (red); reference gain is the only drift excused Co-Authored-By: Claude Sonnet 5.5 --- .../RefListDriftTests.cs | 124 ++++++++++++++++++ tests/vectors/ref-list-drift/README.md | 10 ++ tests/vectors/ref-list-drift/old-store.cc | Bin 0 -> 1684 bytes 3 files changed, 134 insertions(+) create mode 100644 tests/GameCult.Caching.Tests/RefListDriftTests.cs create mode 100644 tests/vectors/ref-list-drift/README.md create mode 100644 tests/vectors/ref-list-drift/old-store.cc diff --git a/tests/GameCult.Caching.Tests/RefListDriftTests.cs b/tests/GameCult.Caching.Tests/RefListDriftTests.cs new file mode 100644 index 00000000..798fee2d --- /dev/null +++ b/tests/GameCult.Caching.Tests/RefListDriftTests.cs @@ -0,0 +1,124 @@ +#nullable enable +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + // 1.0.x persisted a list of record references (CultRecordRef[]) as a plain value slot: isReference false, no target. + // 2.0 classifies the same slot as a reference to T. The payload is the same array of key strings, so a store written by + // 1.0.x opens: gaining reference classification on an otherwise identical slot is compatible drift. A reference that is + // lost, retargeted, or that arrives with a different type or cardinality stays a refusal. + public class RefListDriftTests + { + [CultDocument("tests.reflist_target", "tests.reflist_target.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class RefListTarget + { + [Key(0)] + [CultName] + public string Name = string.Empty; + } + + [CultDocument("tests.reflist_holder", "tests.reflist_holder.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class RefListHolder + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + public CultRecordRef[] Items = Array.Empty>(); + } + + private static string VectorPath(string name) + { + for (var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); dir != null; dir = dir.Parent) + { + var vectors = Path.Combine(dir.FullName, "tests", "vectors", "ref-list-drift"); + if (Directory.Exists(vectors)) return Path.Combine(vectors, name); + } + + throw new DirectoryNotFoundException($"tests/vectors/ref-list-drift not found above {TestContext.CurrentContext.TestDirectory}."); + } + + [Test] + public async Task A_1_0_Store_With_A_Ref_List_Slot_Opens_And_Rewrites_Under_The_Reference_Schema() + { + var copy = Path.Combine(Path.GetTempPath(), $"reflist-drift-{Guid.NewGuid():N}.cc"); + File.Copy(VectorPath("old-store.cc"), copy); + try + { + using var cache = new CultCache(); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(copy)); + await cache.PullAllBackingStoresAsync(); + + var holder = cache.GetAll().Single(); + Assert.That(holder.Items.Select(item => item.Key.Value), Is.EqualTo(new[] { "alpha", "beta" })); + Assert.That(cache.Get(holder.Items[0])!.Name, Is.EqualTo("alpha")); + + // The store is rewritten under the 2.0 schema, which now carries the reference. + holder.Items = holder.Items.Reverse().ToArray(); + await cache.AddAsync(holder); + using var reopened = new CultCache(); + reopened.AddBackingStore(new SingleFileMessagePackBackingStore(copy)); + await reopened.PullAllBackingStoresAsync(); + Assert.That(reopened.GetAll().Single().Items.Select(item => item.Key.Value), Is.EqualTo(new[] { "beta", "alpha" })); + } + finally + { + File.Delete(copy); + } + } + + private static CultSchemaMigrationReport Resolve(Action persistedItems) + { + var catalog = CultDocumentRegistry.Shared.GetRequired().ToCatalogEntry(); + catalog.SchemaId = "persisted.tests.reflist_holder.v1"; + catalog.CompatibleSchemaIds = new[] { CultDocumentRegistry.Shared.GetRequired().SchemaId }; + persistedItems(catalog.Members.Single(member => member.Slot == 1)); + return CultDocumentRegistry.Shared.ResolvePersistedSchemaReport(catalog.SchemaId, new[] { catalog }); + } + + private static void AsWrittenBy10(CultSchemaMemberCatalogEntry member) + { + member.IsReference = false; + member.TargetSchemaName = null; + } + + [Test] + public void A_Reference_Gained_On_An_Otherwise_Identical_Slot_Is_Compatible_Drift() + { + var report = Resolve(AsWrittenBy10); + Assert.That(report.Kind, Is.EqualTo(CultSchemaMigrationKind.CompatibleDrift)); + Assert.That(report.Warnings.Select(warning => warning.Code), Does.Contain("gained_reference_semantics")); + } + + [Test] + public void A_Persisted_Reference_Retargeted_Or_Gaining_Over_A_Foreign_Target_Still_Refuses() + { + Assert.That( + () => Resolve(member => member.TargetSchemaName = "tests.other_target"), + Throws.TypeOf().With.Message.Contains("changed target schema")); + Assert.That( + () => Resolve(member => { member.IsReference = false; member.TargetSchemaName = "tests.other_target"; }), + Throws.TypeOf()); + } + + [Test] + public void A_Gained_Reference_Does_Not_Excuse_A_Changed_Type_Or_Cardinality() + { + Assert.That( + () => Resolve(member => { AsWrittenBy10(member); member.TypeName = "System.String[]"; }), + Throws.TypeOf().With.Message.Contains("changed type")); + Assert.That( + () => Resolve(member => { AsWrittenBy10(member); member.IsMany = !member.IsMany; }), + Throws.TypeOf().With.Message.Contains("changed cardinality")); + } + } +} diff --git a/tests/vectors/ref-list-drift/README.md b/tests/vectors/ref-list-drift/README.md new file mode 100644 index 00000000..639bf6d5 --- /dev/null +++ b/tests/vectors/ref-list-drift/README.md @@ -0,0 +1,10 @@ +# ref-list-drift vector + +`old-store.cc` is a `cultcache.store.v1` file in the shape CultLib 1.0.x wrote for a list of record references: the +`Items` slot (`CultRecordRef[]`) is persisted with `isReference:false` and no target schema, and its +payload is an array of key strings. Its layout is copied from a real 1.0.x-written store (Aetheria `run.cc`, schema +`aetheria.savedgame`, slots `Zones` and `Factions`), pruned to two schemas and three records: `alpha` and `beta` +(`tests.reflist_target`) and `list` (`tests.reflist_holder`, `Items = [alpha, beta]`). + +Hand-written by a script outside the runtime under test; never regenerated by CultCache. A change to this file is a +reviewed commit. diff --git a/tests/vectors/ref-list-drift/old-store.cc b/tests/vectors/ref-list-drift/old-store.cc new file mode 100644 index 0000000000000000000000000000000000000000..dc7d199d9aa485754377e3fd28c4d845b5808511 GIT binary patch literal 1684 zcmd6nL2J}N6vypF?|uVwbsJ|UlbJ~nL3$8DJha72De`9KO?M%iElDaC>4(^D!G3`D z=tTstrTQh-vmeB_NsYC%qAZGs1m@+<{Qhs=|K;$#+bbFeZisQ+Oe&0D@!8w?lX?gu zRk=wLi@+e$0xUSDQhKorbef=ZLZDze2xCF|Oem!@E3{OJRZ9Bco&C^Y-PCb~p%~T8 z%i*N(Sbe-MNZg((Owddcga?L_nS=od~aUA&rDl<`~D8(xQBePA(=*l(Rupc89l&LDcN; zE>Ii$b%W!0qp3#a4(%M(>lm=Y($UudMU8YCl>7anfocbvjZ1RNy`rFvrN`HgixCj@ zB2stdG}Rs+489BDy^yB_t_gXe^2$DM^H714!nma&ktuh{&mJWi+kOmLZ`PW~%z5`O1yUDo8^}R02{Qq2k z*+|QFX?nAf4v$Wu*d0P|+GSbudx8nI%Gs(An>@=!nx`^WJTcPf`;6s`eZDj8phqVb gn}3VGJ#CpsCyU>7U-Yt*`a4D6owdwaS9+`B8yOoe%K!iX literal 0 HcmV?d00001