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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-22 22:01:34 +02:00
commit 3e65477f22
3 changed files with 134 additions and 0 deletions

View file

@ -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<T>[]) 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<RefListTarget>[] Items = Array.Empty<CultRecordRef<RefListTarget>>();
}
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<RefListHolder>().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<RefListHolder>().Single().Items.Select(item => item.Key.Value), Is.EqualTo(new[] { "beta", "alpha" }));
}
finally
{
File.Delete(copy);
}
}
private static CultSchemaMigrationReport Resolve(Action<CultSchemaMemberCatalogEntry> persistedItems)
{
var catalog = CultDocumentRegistry.Shared.GetRequired<RefListHolder>().ToCatalogEntry();
catalog.SchemaId = "persisted.tests.reflist_holder.v1";
catalog.CompatibleSchemaIds = new[] { CultDocumentRegistry.Shared.GetRequired<RefListHolder>().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<InvalidOperationException>().With.Message.Contains("changed target schema"));
Assert.That(
() => Resolve(member => { member.IsReference = false; member.TargetSchemaName = "tests.other_target"; }),
Throws.TypeOf<InvalidOperationException>());
}
[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<InvalidOperationException>().With.Message.Contains("changed type"));
Assert.That(
() => Resolve(member => { AsWrittenBy10(member); member.IsMany = !member.IsMany; }),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("changed cardinality"));
}
}
}

View file

@ -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<RefListTarget>[]`) 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.

Binary file not shown.