Key-identity vector: retype refusal, foreign carry, key in two stores (C# rows, 12) (#120)
This commit is contained in:
parent
871d81807b
commit
6d1684e1c2
9 changed files with 477 additions and 32 deletions
|
|
@ -40,6 +40,9 @@ Probes: scratch tests in this worktree, removed. C# filtered runs on Starfire (o
|
|||
| P5 cache {A,B}: remove, then add in a second commit | C | n/a | n/a | n/a |
|
||||
| P6 N2 over an N1 record (undeclared name drift), cache {N2} / cacheless reg {N2} | C restamped / C restamped | not probed | not probed | C (cache) |
|
||||
|
||||
The C#-written probe the leaves are held to is `tests/vectors/key-identity` (12 rows: retype, foreign carry, key in two stores), walked by
|
||||
`KeyIdentityVectorTests`; each leaf cut corrects its own column above.
|
||||
|
||||
What this establishes:
|
||||
|
||||
- C#: one record per key. The only path that retypes is the cache arm (P1): `CacheBackingStore.RefuseUndeclared`
|
||||
|
|
|
|||
371
tests/GameCult.Caching.Tests/KeyIdentityVectorTests.cs
Normal file
371
tests/GameCult.Caching.Tests/KeyIdentityVectorTests.cs
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
#nullable enable
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using GameCult.Caching;
|
||||
using GameCult.Caching.MessagePack;
|
||||
using MessagePack;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace GameCult.Caching.Tests
|
||||
{
|
||||
// tests/vectors/key-identity: a cache holds one record per key whatever its type, and a store holding a record no registered
|
||||
// type claims keeps it (variants:ruling:cache-refuses-retype, write-ownership-by-written-type, unheld-removal-refused-at-commit).
|
||||
// The C# reference is the only writer: under CULTCACHE_WRITE_VECTORS=1 the rows are run and what the reference answers is
|
||||
// written; every other run walks the committed rows against the reference and compares the files byte for byte.
|
||||
public class KeyIdentityVectorTests
|
||||
{
|
||||
private const string Vector = "key-identity";
|
||||
|
||||
private static readonly Type Alpha = Kind("KiAlpha", "tests.ki_alpha");
|
||||
private static readonly Type Beta = Kind("KiBeta", "tests.ki_beta");
|
||||
private static readonly Type Gamma = Kind("KiGamma", "tests.ki_gamma");
|
||||
|
||||
private static Type Kind(string type, string schema) =>
|
||||
EmittedDocumentTypes.Emit(type, schema, schema + ".v1", new[] { new EmittedDocumentTypes.Field("Text", typeof(string), 0) });
|
||||
|
||||
private static readonly Dictionary<string, Type> Types = new() { ["alpha"] = Alpha, ["beta"] = Beta, ["gamma"] = Gamma };
|
||||
|
||||
private sealed record Op(string Kind, string? File = null, string[]? Types = null, string? Type = null, string? Key = null, string? Text = null);
|
||||
|
||||
private sealed record Row(string Label, string[] Registered, Op[] Ops);
|
||||
|
||||
// [refusal key or nil, held [type, key, text], file [key, schema name] or nil, foreign keys, kept foreign keys], all sorted.
|
||||
private sealed record Expect(string? Refusal, string[][] Held, string[][]? File, string[] Foreign, string[] Kept);
|
||||
|
||||
private static Op Open(string? file) => new("open", file);
|
||||
private static Op Attach(string file, params string[] home) => new("attach", file, home);
|
||||
private static Op Reopen() => new("reopen");
|
||||
private static Op Put(string type, string key, string text) => new("put", Type: type, Key: key, Text: text);
|
||||
private static Op Delete(string type, string key) => new("delete", Type: type, Key: key);
|
||||
|
||||
private static readonly string[] AB = { "alpha", "beta" };
|
||||
private static readonly string[] A = { "alpha" };
|
||||
|
||||
// Only the ops are typed here; every verdict is what the reference answers.
|
||||
private static readonly Row[] Table =
|
||||
{
|
||||
new("retype-refused-held-in-file", AB, new[] { Open(null), Put("alpha", "k", "a"), Put("beta", "k", "b") }),
|
||||
new("retype-by-remove-then-add", AB, new[] { Open(null), Put("alpha", "k", "a"), Delete("alpha", "k"), Put("beta", "k", "b") }),
|
||||
new("retype-refused-memory-cache", AB, new[] { Open("memory"), Put("alpha", "k", "a"), Put("beta", "k", "b") }),
|
||||
new("same-type-replaces", A, new[] { Open(null), Put("alpha", "k", "a"), Put("alpha", "k", "b") }),
|
||||
new("retype-refused-after-reload", AB, new[] { Open(null), Put("alpha", "k", "a"), Reopen(), Put("beta", "k", "b") }),
|
||||
new("foreign-loads", A, new[] { Open("foreign-store.cc") }),
|
||||
new("foreign-kept-by-a-write", A, new[] { Open("foreign-store.cc"), Put("alpha", "m", "m") }),
|
||||
new("foreign-key-write-refused", A, new[] { Open("foreign-store.cc"), Put("alpha", "k", "x") }),
|
||||
new("foreign-key-delete-refused", A, new[] { Open("foreign-store.cc"), Delete("alpha", "k") }),
|
||||
new("foreign-claimed-by-a-later-registration", new[] { "alpha", "gamma" }, new[] { Open("foreign-store.cc") }),
|
||||
new("key-in-two-stores-refused", AB, new[] { Open("memory"), Attach("catalog-store.cc", "alpha"), Attach("run-store.cc", "beta") }),
|
||||
new("same-type-replaces-memory-cache", A, new[] { Open("memory"), Put("alpha", "k", "a"), Put("alpha", "k", "b") }),
|
||||
};
|
||||
|
||||
// The stores C# writes: [file name, registered types, (type, key, text) records]. Never typed by a runtime.
|
||||
private static readonly (string File, string[] Registered, (string Type, string Key, string Text)[] Records)[] Stores =
|
||||
{
|
||||
("foreign-store.cc", new[] { "alpha", "gamma" }, new[] { ("alpha", "j", "a"), ("gamma", "k", "g") }),
|
||||
("catalog-store.cc", new[] { "alpha" }, new[] { ("alpha", "qx", "a") }),
|
||||
("run-store.cc", new[] { "beta" }, new[] { ("beta", "qx", "b") }),
|
||||
};
|
||||
|
||||
private static CultDocumentRegistry RegistryOf(IEnumerable<string> names) => CultDocumentRegistry.ForTypes(names.Select(name => Types[name]).ToArray());
|
||||
|
||||
private static object Doc(string type, string text) => EmittedDocumentTypes.New(Types[type], ("Text", text));
|
||||
|
||||
private static byte[] StoreBytes(string[] registered, (string Type, string Key, string Text)[] records)
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), $"cultlib-kiv-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(directory);
|
||||
try
|
||||
{
|
||||
var path = Path.Combine(directory, "store.cc");
|
||||
using (var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = RegistryOf(registered), StoreFlushOnDispose = true }))
|
||||
{
|
||||
cache.Clock = () => VectorFiles.StoredAt;
|
||||
foreach (var record in records)
|
||||
cache.UpsertAsync(Types[record.Type], Doc(record.Type, record.Text), new CultRecordKey(record.Key)).GetAwaiter().GetResult();
|
||||
cache.FlushAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
return File.ReadAllBytes(path);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static CultPersistedStoreSnapshot Snapshot(byte[] bytes) => CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes);
|
||||
|
||||
private static string Name(CultPersistedStoreSnapshot snapshot, CultPersistedRecord record) =>
|
||||
snapshot.SchemaCatalog.Single(entry => entry.SchemaId == record.SchemaId).SchemaName;
|
||||
|
||||
private static string Short(string schemaName) => Types.Single(pair => pair.Key == schemaName.Replace("tests.ki_", "")).Key;
|
||||
|
||||
// The key as a delimited token: not a piece of a longer word, schema name or path.
|
||||
private static bool Delimited(string message, string key) => Regex.IsMatch(message, $@"(?<![A-Za-z0-9_]){Regex.Escape(key)}(?![A-Za-z0-9_])");
|
||||
|
||||
private static string RecordBytes(CultPersistedRecord record) => $"{record.SchemaId}|{record.StoredAt}|{Convert.ToBase64String(record.Payload)}";
|
||||
|
||||
// Runs the ops and returns what the reference answers. Every op but the last must succeed; the last may refuse.
|
||||
private static Expect Observe(Row row)
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), $"cultlib-kiw-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(directory);
|
||||
var registry = RegistryOf(row.Registered);
|
||||
string? path = null;
|
||||
byte[]? before = null;
|
||||
CultCache? cache = null;
|
||||
try
|
||||
{
|
||||
Exception? refusal = null;
|
||||
string? refusedKey = null;
|
||||
for (var i = 0; i < row.Ops.Length; i++)
|
||||
{
|
||||
var op = row.Ops[i];
|
||||
try
|
||||
{
|
||||
switch (op.Kind)
|
||||
{
|
||||
case "open" or "reopen":
|
||||
cache?.Dispose();
|
||||
if (op.Kind == "open" && op.File == "memory")
|
||||
{
|
||||
cache = new CultCache(registry, CultCacheMessagePack.CreateCodec(registry));
|
||||
break;
|
||||
}
|
||||
|
||||
if (op.Kind == "open")
|
||||
{
|
||||
path = Path.Combine(directory, "store.cc");
|
||||
if (op.File != null) File.WriteAllBytes(path, VectorFiles.Committed(Vector, op.File));
|
||||
before = File.Exists(path) ? File.ReadAllBytes(path) : null;
|
||||
}
|
||||
|
||||
cache = CultCacheMessagePack.Create(path!, new CultCacheOpenOptions { Registry = registry, StoreFlushOnDispose = true });
|
||||
break;
|
||||
case "attach":
|
||||
{
|
||||
var attached = Path.Combine(directory, op.File!);
|
||||
File.WriteAllBytes(attached, VectorFiles.Committed(Vector, op.File!));
|
||||
var held = cache!.AllStoredDocuments.Select(stored => stored.Key.Value).ToHashSet(StringComparer.Ordinal);
|
||||
var shared = Snapshot(File.ReadAllBytes(attached)).Records.Select(record => record.Key).Where(held.Contains).ToArray();
|
||||
try
|
||||
{
|
||||
cache.AddBackingStore(new SingleFileMessagePackBackingStore(attached), op.Types!.Select(name => Types[name]).ToArray());
|
||||
cache.PullAllBackingStoresAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
catch (InvalidOperationException) when (shared.Length == 1)
|
||||
{
|
||||
Assert.That(File.ReadAllBytes(attached), Is.EqualTo(VectorFiles.Committed(Vector, op.File!)), "a refused attach leaves the store as it was");
|
||||
throw;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "put":
|
||||
cache!.Commit(batch => batch.Upsert(Types[op.Type!], Doc(op.Type!, op.Text!), new CultRecordKey(op.Key!)));
|
||||
break;
|
||||
case "delete":
|
||||
cache!.Commit(batch => batch.Remove(new CultRecordKey(op.Key!)));
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException(op.Kind);
|
||||
}
|
||||
}
|
||||
catch (Exception failure) when (i == row.Ops.Length - 1 &&
|
||||
(failure is CultSchemaConflictException || (op.Kind == "attach" && failure is InvalidOperationException)))
|
||||
{
|
||||
refusal = failure;
|
||||
}
|
||||
}
|
||||
|
||||
if (refusal is CultSchemaConflictException conflict)
|
||||
{
|
||||
refusedKey = conflict.RecordKey;
|
||||
Assert.That(row.Ops[^1].Key, Is.EqualTo(conflict.RecordKey), $"{row.Label}: the refusal names the key written or removed");
|
||||
}
|
||||
|
||||
if (refusal is InvalidOperationException && refusal is not CultSchemaConflictException)
|
||||
{
|
||||
// The key is read out of the refusal's own text, not taken from the row: the second-store refusal opens "Record <key> is held by".
|
||||
var named = Regex.Match(refusal.Message, @"^Record (\S+) is held by");
|
||||
Assert.That(named.Success, Is.True, $"{row.Label}: the refusal opens by naming a key: {refusal.Message}");
|
||||
refusedKey = named.Groups[1].Value;
|
||||
var shared = Snapshot(VectorFiles.Committed(Vector, "run-store.cc")).Records.Select(record => record.Key).ToArray();
|
||||
Assert.That(shared, Does.Contain(refusedKey), $"{row.Label}: the key the refusal names is the shared one");
|
||||
Assert.That(Regex.Matches(refusal.Message, Regex.Escape(refusedKey)).Count, Is.EqualTo(Regex.Matches(refusal.Message, $@"(?<![A-Za-z0-9_]){Regex.Escape(refusedKey)}(?![A-Za-z0-9_])").Count),
|
||||
$"{row.Label}: the key occurs in the refusal only as a delimited token, never inside a schema name or other text");
|
||||
}
|
||||
|
||||
if (refusal != null)
|
||||
Assert.That(Delimited(refusal.Message, refusedKey ?? "\0"), Is.True, $"{row.Label}: the refusal names the key as a token");
|
||||
|
||||
var heldNow = cache!.AllStoredDocuments
|
||||
.Select(stored => new[] { Short(stored.Descriptor.SchemaName), stored.Key.Value, (string)EmittedDocumentTypes.Read(stored.Document, "Text")! })
|
||||
.OrderBy(entry => entry[0], StringComparer.Ordinal).ThenBy(entry => entry[1], StringComparer.Ordinal).ToArray();
|
||||
var foreign = cache.BackingStores.SelectMany(store => store.ForeignRecords).Select(record => record.Key).OrderBy(key => key, StringComparer.Ordinal).ToArray();
|
||||
|
||||
string[][]? file = null;
|
||||
var kept = Array.Empty<string>();
|
||||
if (path != null)
|
||||
{
|
||||
cache.FlushAsync().GetAwaiter().GetResult();
|
||||
var after = Snapshot(File.ReadAllBytes(path));
|
||||
file = after.Records.OrderBy(record => record.Key, StringComparer.Ordinal).Select(record => new[] { record.Key, Name(after, record) }).ToArray();
|
||||
var start = before == null ? null : Snapshot(before);
|
||||
kept = foreign.Where(key =>
|
||||
{
|
||||
var original = start?.Records.SingleOrDefault(record => record.Key == key);
|
||||
return original != null && RecordBytes(original) == RecordBytes(after.Records.Single(record => record.Key == key));
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
return new Expect(refusedKey, heldNow, file, foreign, kept);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache?.Dispose();
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- rows.msgpack: [label, registered, ops, expect] ----
|
||||
|
||||
private static void WriteStrings(ref MessagePackWriter writer, IEnumerable<string?> values)
|
||||
{
|
||||
var list = values.ToArray();
|
||||
writer.WriteArrayHeader(list.Length);
|
||||
foreach (var value in list)
|
||||
if (value == null) writer.WriteNil(); else writer.Write(value);
|
||||
}
|
||||
|
||||
private static void WriteRows(ref MessagePackWriter writer, Row row, Expect expect)
|
||||
{
|
||||
writer.WriteArrayHeader(4);
|
||||
writer.Write(row.Label);
|
||||
WriteStrings(ref writer, row.Registered);
|
||||
writer.WriteArrayHeader(row.Ops.Length);
|
||||
foreach (var op in row.Ops)
|
||||
{
|
||||
var fields = op.Kind switch
|
||||
{
|
||||
"open" => new object?[] { op.Kind, op.File },
|
||||
"attach" => new object?[] { op.Kind, op.File, op.Types },
|
||||
"reopen" => new object?[] { op.Kind },
|
||||
"put" => new object?[] { op.Kind, op.Type, op.Key, op.Text },
|
||||
_ => new object?[] { op.Kind, op.Type, op.Key },
|
||||
};
|
||||
writer.WriteArrayHeader(fields.Length);
|
||||
foreach (var field in fields)
|
||||
switch (field)
|
||||
{
|
||||
case null: writer.WriteNil(); break;
|
||||
case string[] list: WriteStrings(ref writer, list); break;
|
||||
default: writer.Write((string)field); break;
|
||||
}
|
||||
}
|
||||
|
||||
writer.WriteArrayHeader(5);
|
||||
if (expect.Refusal == null) writer.WriteNil(); else WriteStrings(ref writer, new[] { expect.Refusal });
|
||||
writer.WriteArrayHeader(expect.Held.Length);
|
||||
foreach (var held in expect.Held) WriteStrings(ref writer, held);
|
||||
if (expect.File == null) writer.WriteNil();
|
||||
else
|
||||
{
|
||||
writer.WriteArrayHeader(expect.File.Length);
|
||||
foreach (var record in expect.File) WriteStrings(ref writer, record);
|
||||
}
|
||||
|
||||
WriteStrings(ref writer, expect.Foreign);
|
||||
WriteStrings(ref writer, expect.Kept);
|
||||
}
|
||||
|
||||
private static byte[] RowsFile()
|
||||
{
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
var writer = new MessagePackWriter(buffer);
|
||||
writer.WriteArrayHeader(Table.Length);
|
||||
foreach (var row in Table)
|
||||
WriteRows(ref writer, row, Observe(row));
|
||||
writer.Flush();
|
||||
return buffer.WrittenSpan.ToArray();
|
||||
}
|
||||
|
||||
private static string[] ReadStrings(ref MessagePackReader reader)
|
||||
{
|
||||
var values = new string[reader.ReadArrayHeader()];
|
||||
for (var i = 0; i < values.Length; i++) values[i] = reader.ReadString()!;
|
||||
return values;
|
||||
}
|
||||
|
||||
private static (Row Row, Expect Expect) ReadRow(ref MessagePackReader reader)
|
||||
{
|
||||
Assert.That(reader.ReadArrayHeader(), Is.EqualTo(4));
|
||||
var label = reader.ReadString()!;
|
||||
var registered = ReadStrings(ref reader);
|
||||
var ops = new Op[reader.ReadArrayHeader()];
|
||||
for (var i = 0; i < ops.Length; i++)
|
||||
{
|
||||
var length = reader.ReadArrayHeader();
|
||||
var kind = reader.ReadString()!;
|
||||
ops[i] = kind switch
|
||||
{
|
||||
"open" => new Op(kind, reader.TryReadNil() ? null : reader.ReadString()),
|
||||
"attach" => new Op(kind, reader.ReadString(), ReadStrings(ref reader)),
|
||||
"reopen" => new Op(kind),
|
||||
"put" => new Op(kind, Type: reader.ReadString(), Key: reader.ReadString(), Text: reader.ReadString()),
|
||||
"delete" => new Op(kind, Type: reader.ReadString(), Key: reader.ReadString()),
|
||||
_ => throw new InvalidOperationException(kind),
|
||||
};
|
||||
Assert.That(length, Is.EqualTo(kind switch { "reopen" => 1, "open" => 2, "delete" => 3, "attach" => 3, _ => 4 }), $"{label}: op {kind}");
|
||||
}
|
||||
|
||||
Assert.That(reader.ReadArrayHeader(), Is.EqualTo(5));
|
||||
var refusal = reader.TryReadNil() ? null : ReadStrings(ref reader).Single();
|
||||
var held = new string[reader.ReadArrayHeader()][];
|
||||
for (var i = 0; i < held.Length; i++) held[i] = ReadStrings(ref reader);
|
||||
string[][]? file = null;
|
||||
if (!reader.TryReadNil())
|
||||
{
|
||||
file = new string[reader.ReadArrayHeader()][];
|
||||
for (var i = 0; i < file.Length; i++) file[i] = ReadStrings(ref reader);
|
||||
}
|
||||
|
||||
var foreign = ReadStrings(ref reader);
|
||||
var kept = ReadStrings(ref reader);
|
||||
return (new Row(label, registered, ops), new Expect(refusal, held, file, foreign, kept));
|
||||
}
|
||||
|
||||
private static string Render(Expect expect) =>
|
||||
$"refusal={expect.Refusal ?? "-"} held={string.Join(";", expect.Held.Select(h => string.Join(",", h)))} " +
|
||||
$"file={(expect.File == null ? "-" : string.Join(";", expect.File.Select(f => string.Join(",", f))))} " +
|
||||
$"foreign={string.Join(",", expect.Foreign)} kept={string.Join(",", expect.Kept)}";
|
||||
|
||||
[Test]
|
||||
public void KeyIdentityRowsHoldTheirVerdicts()
|
||||
{
|
||||
foreach (var store in Stores)
|
||||
VectorFiles.Check(Vector, store.File, StoreBytes(store.Registered, store.Records));
|
||||
VectorFiles.Check(Vector, "rows.msgpack", RowsFile());
|
||||
|
||||
var reader = new MessagePackReader(VectorFiles.Committed(Vector, "rows.msgpack"));
|
||||
var count = reader.ReadArrayHeader();
|
||||
Assert.That(count, Is.EqualTo(Table.Length));
|
||||
var walked = 0;
|
||||
var failed = new List<string>();
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var (row, expect) = ReadRow(ref reader);
|
||||
var observed = Observe(row);
|
||||
if (Render(observed) == Render(expect)) walked++;
|
||||
else failed.Add($"{row.Label}: committed {Render(expect)} but the reference answers {Render(observed)}");
|
||||
}
|
||||
|
||||
Assert.That(failed, Is.Empty);
|
||||
Assert.That(walked, Is.EqualTo(12), "rows walked");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -18,43 +18,16 @@ namespace GameCult.Caching.Tests
|
|||
// compares them byte for byte. Other runtimes are held to the same text and the same verdicts.
|
||||
public class UniqueIndexVectorTests
|
||||
{
|
||||
private const string WriteVariable = "CULTCACHE_WRITE_VECTORS";
|
||||
private static readonly DateTimeOffset StoredAt = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
private static bool Writing => Environment.GetEnvironmentVariable(WriteVariable) == "1";
|
||||
private static readonly DateTimeOffset StoredAt = VectorFiles.StoredAt;
|
||||
private static bool Writing => VectorFiles.Writing;
|
||||
|
||||
private static string VectorDirectory()
|
||||
{
|
||||
for (var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); dir != null; dir = dir.Parent)
|
||||
{
|
||||
var vectors = Path.Combine(dir.FullName, "tests", "vectors");
|
||||
if (Directory.Exists(vectors)) return Path.Combine(vectors, "unique-index");
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException($"tests/vectors not found above {TestContext.CurrentContext.TestDirectory}.");
|
||||
}
|
||||
private static string VectorDirectory() => VectorFiles.Directory("unique-index");
|
||||
|
||||
private static string VectorPath(string name) => Path.Combine(VectorDirectory(), name);
|
||||
|
||||
private static void Check(string name, byte[] reference)
|
||||
{
|
||||
var path = VectorPath(name);
|
||||
if (Writing)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
File.WriteAllBytes(path, reference);
|
||||
return;
|
||||
}
|
||||
private static void Check(string name, byte[] reference) => VectorFiles.Check("unique-index", name, reference);
|
||||
|
||||
Assert.That(File.Exists(path), Is.True, $"vector {name} is committed");
|
||||
Assert.That(Convert.ToHexString(reference), Is.EqualTo(Convert.ToHexString(File.ReadAllBytes(path))), $"vector {name}");
|
||||
}
|
||||
|
||||
private static byte[] Committed(string name)
|
||||
{
|
||||
var path = VectorPath(name);
|
||||
Assert.That(File.Exists(path), Is.True, $"vector {name} is committed");
|
||||
return File.ReadAllBytes(path);
|
||||
}
|
||||
private static byte[] Committed(string name) => VectorFiles.Committed("unique-index", name);
|
||||
|
||||
// ---- values.msgpack: [label, kind, value, text-or-nil] ----
|
||||
|
||||
|
|
|
|||
50
tests/GameCult.Caching.Tests/VectorFiles.cs
Normal file
50
tests/GameCult.Caching.Tests/VectorFiles.cs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
#nullable enable
|
||||
using System;
|
||||
using System.IO;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace GameCult.Caching.Tests
|
||||
{
|
||||
// The one place a C#-written vector directory is found, written and compared. The C# reference is the only writer
|
||||
// (CULTCACHE_WRITE_VECTORS=1, never set in CI); every other run reads the committed bytes and compares them byte for byte.
|
||||
internal static class VectorFiles
|
||||
{
|
||||
private const string WriteVariable = "CULTCACHE_WRITE_VECTORS";
|
||||
|
||||
internal static readonly DateTimeOffset StoredAt = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
internal static bool Writing => Environment.GetEnvironmentVariable(WriteVariable) == "1";
|
||||
|
||||
internal static string Directory(string vector)
|
||||
{
|
||||
for (var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); dir != null; dir = dir.Parent)
|
||||
{
|
||||
var vectors = System.IO.Path.Combine(dir.FullName, "tests", "vectors");
|
||||
if (System.IO.Directory.Exists(vectors)) return System.IO.Path.Combine(vectors, vector);
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException($"tests/vectors not found above {TestContext.CurrentContext.TestDirectory}.");
|
||||
}
|
||||
|
||||
internal static void Check(string vector, string name, byte[] reference)
|
||||
{
|
||||
var path = System.IO.Path.Combine(Directory(vector), name);
|
||||
if (Writing)
|
||||
{
|
||||
System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)!);
|
||||
File.WriteAllBytes(path, reference);
|
||||
return;
|
||||
}
|
||||
|
||||
Assert.That(File.Exists(path), Is.True, $"vector {name} is committed");
|
||||
Assert.That(Convert.ToHexString(reference), Is.EqualTo(Convert.ToHexString(File.ReadAllBytes(path))), $"vector {name}");
|
||||
}
|
||||
|
||||
internal static byte[] Committed(string vector, string name)
|
||||
{
|
||||
var path = System.IO.Path.Combine(Directory(vector), name);
|
||||
Assert.That(File.Exists(path), Is.True, $"vector {name} is committed");
|
||||
return File.ReadAllBytes(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
47
tests/vectors/key-identity/README.md
Normal file
47
tests/vectors/key-identity/README.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# key-identity vectors
|
||||
|
||||
Written only by the C# reference, `tests/GameCult.Caching.Tests/KeyIdentityVectorTests.cs`, with
|
||||
`CULTCACHE_WRITE_VECTORS=1` (never set in CI). Every other run reads these files, runs every row against the reference and
|
||||
compares the files byte for byte; a changed vector is a reviewed commit. Other runtimes are held to the same verdicts
|
||||
(`variants:ruling:cache-refuses-retype`, `variants:ruling:write-ownership-by-written-type`,
|
||||
`variants:ruling:unheld-removal-refused-at-commit`).
|
||||
|
||||
RULE: a cache holds one record per key whatever its type; a write of a type that does not own the held record's schema id at
|
||||
that key is refused as a schema conflict naming the key, with the cache, its store file and every foreign record
|
||||
byte-identical; retyping is a delete then a put. A store that holds a record no registered type claims loads: the cache does
|
||||
not serve it, lists its key as foreign, keeps its bytes through every write, and refuses a write or a removal at its key.
|
||||
Registering a type that declares its schema id claims it at the next load.
|
||||
|
||||
## Files
|
||||
|
||||
- Schemas: `tests.ki_alpha`, `tests.ki_beta`, `tests.ki_gamma` (`alpha`, `beta`, `gamma` below), each one string slot
|
||||
`[0: Text]`, version the name plus `.v1`. The schema ids C# writes in the store catalogs and on every record:
|
||||
- `tests.ki_alpha`: `sha256:b20703a98f49c253898404ab50ba2c630720f0b8a5e1a41614d5650be22799fe`
|
||||
- `tests.ki_beta`: `sha256:dee4b727cf2ee04e61ab635ff0c5f3dca7345a411dc5f1018e583ab16009cf94`
|
||||
- `tests.ki_gamma`: `sha256:fbc26e351e00c9e7185e8de171c7486aba707c229c949f0aae6b889288af5f6d`
|
||||
- `foreign-store.cc`: a single-file store, alpha `a` at key `j`, gamma `g` at key `k`, stored-at pinned to
|
||||
`2026-01-01T00:00:00Z`. `catalog-store.cc`: alpha `a` at `qx`. `run-store.cc`: beta `b` at `qx` (row 11). The row-11 key is `qx` because it must
|
||||
occur in the refusal's message only as the key, never inside a schema name (`k` is inside `ki_alpha`).
|
||||
- `rows.msgpack`: an array of `[label, registered, ops, expect]`.
|
||||
- `registered`: the types the cache registers, a subset of `[alpha, beta, gamma]`.
|
||||
- `ops`, in order: `[open, file-or-nil-or-memory]` (a cache on a fresh copy of the vector file; nil is a fresh path with no
|
||||
file; the string `memory` is a cache with no store), `[attach, file, [home types]]` (route one more store, a fresh copy
|
||||
of the vector file, to those home types, and pull every store), `[reopen]` (dispose, then a new cache on the same path),
|
||||
`[put, type, key, text]`, `[delete, type, key]` (each one commit).
|
||||
- `expect` is `[refusal, held, file, foreign, kept]`. Every op but the last succeeds; the last succeeds or refuses, and in the generator only a schema conflict (or, for an attach, an invalid-operation refusal) counts as a refusal: any other exception fails the run.
|
||||
`refusal` is nil or `[key]`: a typed schema-conflict refusal naming the key for a put or a delete (rows 1, 3, 5, 8, 9),
|
||||
and for row 11 any refusal (an invalid-operation error, not a schema conflict) whose message contains the key as a delimited token (not inside a schema name or other word; the C# walker also pins its own `Record <key> is held by` opening, which a leaf need not copy); the key in `expect` is the one the stores share, the store file it was refused from left byte-identical.
|
||||
`held` is the cache's records, sorted by type then key, as `[type, key, text]`. `file` is the store file's records after
|
||||
the ops, sorted by key, as `[key, schema name]`, nil for a cache with no store. `foreign` is the keys the cache lists as
|
||||
foreign, sorted. `kept` is the foreign keys whose stored record (schema id, stored-at, payload) after the ops equals the
|
||||
file's before the first op.
|
||||
|
||||
## Rows
|
||||
|
||||
1 retype-refused-held-in-file, 2 retype-by-remove-then-add, 3 retype-refused-memory-cache, 4 same-type-replaces, 5
|
||||
retype-refused-after-reload, 6 foreign-loads, 7 foreign-kept-by-a-write, 8 foreign-key-write-refused, 9
|
||||
foreign-key-delete-refused, 10 foreign-claimed-by-a-later-registration (registered `[alpha, gamma]`: row 6's store loads
|
||||
with nothing foreign), 11 key-in-two-stores-refused, 12 same-type-replaces-memory-cache (a cache with no store replaces a same-type record: admitted).
|
||||
|
||||
Every runtime walks 12 rows and asserts the count; none is exempt (every row is single-record ops). A leaf never registers
|
||||
gamma in rows 6 and 8; only row 10 does.
|
||||
BIN
tests/vectors/key-identity/catalog-store.cc
Normal file
BIN
tests/vectors/key-identity/catalog-store.cc
Normal file
Binary file not shown.
BIN
tests/vectors/key-identity/foreign-store.cc
Normal file
BIN
tests/vectors/key-identity/foreign-store.cc
Normal file
Binary file not shown.
1
tests/vectors/key-identity/rows.msgpack
Normal file
1
tests/vectors/key-identity/rows.msgpack
Normal file
|
|
@ -0,0 +1 @@
|
|||
œ”»retype-refused-held-in-file’¥alpha¤beta“’¤openÀ”£put¥alpha¡k¡a”£put¤beta¡k¡b•‘¡k‘“¥alpha¡k¡a‘’¡k®tests.ki_alpha<68><61>”¹retype-by-remove-then-add’¥alpha¤beta”’¤openÀ”£put¥alpha¡k¡a“¦delete¥alpha¡k”£put¤beta¡k¡b•À‘“¤beta¡k¡b‘’¡ktests.ki_beta<74><61>”»retype-refused-memory-cache’¥alpha¤beta“’¤open¦memory”£put¥alpha¡k¡a”£put¤beta¡k¡b•‘¡k‘“¥alpha¡k¡aÀ<61><C380>”²same-type-replaces‘¥alpha“’¤openÀ”£put¥alpha¡k¡a”£put¥alpha¡k¡b•À‘“¥alpha¡k¡b‘’¡k®tests.ki_alpha<68><61>”»retype-refused-after-reload’¥alpha¤beta”’¤openÀ”£put¥alpha¡k¡a‘¦reopen”£put¤beta¡k¡b•‘¡k‘“¥alpha¡k¡a‘’¡k®tests.ki_alpha<68><61>”foreign-loads‘¥alpha‘’¤open°foreign-store.cc•À‘“¥alpha¡j¡a’’¡j®tests.ki_alpha’¡k®tests.ki_gamma‘¡k‘¡k”·foreign-kept-by-a-write‘¥alpha’’¤open°foreign-store.cc”£put¥alpha¡m¡m•À’“¥alpha¡j¡a“¥alpha¡m¡m“’¡j®tests.ki_alpha’¡k®tests.ki_gamma’¡m®tests.ki_alpha‘¡k‘¡k”¹foreign-key-write-refused‘¥alpha’’¤open°foreign-store.cc”£put¥alpha¡k¡x•‘¡k‘“¥alpha¡j¡a’’¡j®tests.ki_alpha’¡k®tests.ki_gamma‘¡k‘¡k”ºforeign-key-delete-refused‘¥alpha’’¤open°foreign-store.cc“¦delete¥alpha¡k•‘¡k‘“¥alpha¡j¡a’’¡j®tests.ki_alpha’¡k®tests.ki_gamma‘¡k‘¡k”Ù'foreign-claimed-by-a-later-registration’¥alpha¥gamma‘’¤open°foreign-store.cc•À’“¥alpha¡j¡a“¥gamma¡k¡g’’¡j®tests.ki_alpha’¡k®tests.ki_gamma<6D><61>”¹key-in-two-stores-refused’¥alpha¤beta“’¤open¦memory“¦attach°catalog-store.cc‘¥alpha“¦attach¬run-store.cc‘¤beta•‘¢qx‘“¥alpha¢qx¡aÀ<61><C380>”¿same-type-replaces-memory-cache‘¥alpha“’¤open¦memory”£put¥alpha¡k¡a”£put¥alpha¡k¡b•À‘“¥alpha¡k¡bÀ<62><C380>
|
||||
BIN
tests/vectors/key-identity/run-store.cc
Normal file
BIN
tests/vectors/key-identity/run-store.cc
Normal file
Binary file not shown.
Loading…
Reference in a new issue