202 lines
10 KiB
C#
202 lines
10 KiB
C#
#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>>();
|
|
}
|
|
|
|
// The reverse of the 1.0.x drift: a slot persisted as a reference list, declared by a later version as a plain value.
|
|
[CultDocument("tests.reflist_loss", "tests.reflist_loss.v1")]
|
|
[MessagePackObject(AllowPrivate = true)]
|
|
internal sealed class RefListLossBefore
|
|
{
|
|
[Key(0)]
|
|
[CultName]
|
|
public string Name = string.Empty;
|
|
|
|
[Key(1)]
|
|
public CultRecordRef<RefListTarget>[] Items = Array.Empty<CultRecordRef<RefListTarget>>();
|
|
}
|
|
|
|
[CultDocument("tests.reflist_loss", "tests.reflist_loss.v2")]
|
|
[MessagePackObject(AllowPrivate = true)]
|
|
internal sealed class RefListLossAfter
|
|
{
|
|
[Key(0)]
|
|
[CultName]
|
|
public string Name = string.Empty;
|
|
|
|
[Key(1)]
|
|
public string[] Items = Array.Empty<string>();
|
|
}
|
|
|
|
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();
|
|
var store = new SingleFileMessagePackBackingStore(copy);
|
|
cache.AddBackingStore(store);
|
|
await cache.PullAllBackingStoresAsync();
|
|
Assert.That(
|
|
store.LastSchemaMigrationReports.Single(report => report.PersistedSchemaName == "tests.reflist_holder").Kind,
|
|
Is.EqualTo(CultSchemaMigrationKind.CompatibleDrift));
|
|
|
|
var holder = cache.GetAll<RefListHolder>().Single();
|
|
Assert.That(holder.Items.Select(item => item.Key.Value), Is.EqualTo(new[] { "alpha", "beta" }));
|
|
Assert.That(cache.Get<RefListTarget>(holder.Items[0].Key)!.Name, Is.EqualTo("alpha"));
|
|
|
|
// A write flushes the store under the 2.0 schema; the list reads back unchanged.
|
|
await cache.AddAsync(new RefListTarget { Name = "gamma" });
|
|
store.PushAll();
|
|
using var reopened = new CultCache();
|
|
reopened.AddBackingStore(new SingleFileMessagePackBackingStore(copy));
|
|
await reopened.PullAllBackingStoresAsync();
|
|
Assert.That(string.Join(",", reopened.GetAll<RefListTarget>().Select(target => target.Name).OrderBy(name => name)), Is.EqualTo("alpha,beta,gamma"));
|
|
Assert.That(reopened.GetAll<RefListHolder>().Single().Items.Select(item => item.Key.Value).ToArray(), Is.EqualTo(new[] { "alpha", "beta" }));
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(copy);
|
|
}
|
|
}
|
|
|
|
// The store the 1.0.60 writer wrote (tests/vectors/ref-list-drift/v1-0-60-written.cc, made by OldWriterRefListProbe), or the one
|
|
// old-reader-refusal.sh gate csharp names in CULTLIB_GATE_OLD_STORE. The line it prints is the one OldReaderRefListProbe prints
|
|
// at 2.0.1; the gate diffs them. A record is named by its schema name, not its id: a flush of an untouched record carries its
|
|
// persisted id forward at this tree and restamps it at 2.0.1, so the id after PushAll is not what "opens alike" is about.
|
|
[Test]
|
|
public async Task A_1_0_60_Written_Store_Opens_Like_2_0_1()
|
|
{
|
|
var source = Environment.GetEnvironmentVariable("CULTLIB_GATE_OLD_STORE");
|
|
var copy = Path.Combine(Path.GetTempPath(), $"reflist-1060-{Guid.NewGuid():N}.cc");
|
|
File.Copy(string.IsNullOrEmpty(source) ? VectorPath("v1-0-60-written.cc") : source, copy);
|
|
try
|
|
{
|
|
using var cache = new CultCache();
|
|
var store = new SingleFileMessagePackBackingStore(copy);
|
|
cache.AddBackingStore(store);
|
|
await cache.PullAllBackingStoresAsync();
|
|
var warnings = store.LastSchemaMigrationReports.SelectMany(report => report.Warnings).Select(warning => warning.Code).Distinct().OrderBy(code => code, StringComparer.Ordinal).ToArray();
|
|
Assert.That(warnings, Does.Contain("gained_reference_semantics"));
|
|
var items = cache.GetAll<RefListHolder>().Single().Items.Select(item => item.Key.Value).ToArray();
|
|
Assert.That(items, Is.EqualTo(new[] { "alpha", "beta" }));
|
|
Assert.That(cache.Get<RefListTarget>(cache.GetAll<RefListHolder>().Single().Items[0].Key)!.Name, Is.EqualTo("alpha"));
|
|
|
|
store.PushAll();
|
|
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(copy));
|
|
var records = snapshot.Records.OrderBy(record => record.Key, StringComparer.Ordinal).Select(record => record.Key + "=" + snapshot.SchemaCatalog.Where(entry => entry.SchemaId == record.SchemaId).Select(entry => entry.SchemaName).SingleOrDefault()).ToArray();
|
|
TestContext.Out.WriteLine("REFLIST-SUMMARY " + System.Text.Json.JsonSerializer.Serialize(new { records, items, warnings, header = snapshot.FormatVersion }));
|
|
}
|
|
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.ContentHash = "sha256:persisted-by-1.0.x";
|
|
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_Lost_Reference_Still_Refuses()
|
|
{
|
|
var registry = CultDocumentRegistry.Shared;
|
|
var catalog = registry.GetRequired<RefListLossBefore>().ToCatalogEntry();
|
|
catalog.SchemaId = "persisted.tests.reflist_loss.v1";
|
|
catalog.ContentHash = "sha256:persisted-by-2.0";
|
|
catalog.CompatibleSchemaIds = new[] { registry.GetRequired<RefListLossAfter>().SchemaId };
|
|
catalog.Members.Single(member => member.Slot == 1).TypeName = "System.String[]";
|
|
Assert.That(
|
|
() => registry.ResolvePersistedSchemaReport(catalog.SchemaId, new[] { catalog }),
|
|
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("changed reference semantics"));
|
|
}
|
|
|
|
[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"));
|
|
}
|
|
}
|
|
}
|