From 5ef63d35ee2ff64a6f135fc7e8bc2a339eeb948d Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 04:22:51 +0000 Subject: [PATCH] Unique-index vectors: canonical index text and load verdicts, written by the C# reference Co-Authored-By: Claude Sonnet 5.5 --- .../UniqueIndexVectorTests.cs | 294 ++++++++++++++++++ tests/vectors/unique-index/README.md | 24 ++ tests/vectors/unique-index/blank-store.cc | Bin 0 -> 964 bytes tests/vectors/unique-index/duplicate-store.cc | Bin 0 -> 1222 bytes tests/vectors/unique-index/outcomes.msgpack | 1 + tests/vectors/unique-index/values.msgpack | Bin 0 -> 788 bytes 6 files changed, 319 insertions(+) create mode 100644 tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs create mode 100644 tests/vectors/unique-index/README.md create mode 100644 tests/vectors/unique-index/blank-store.cc create mode 100644 tests/vectors/unique-index/duplicate-store.cc create mode 100644 tests/vectors/unique-index/outcomes.msgpack create mode 100644 tests/vectors/unique-index/values.msgpack diff --git a/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs b/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs new file mode 100644 index 00000000..d7c68459 --- /dev/null +++ b/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs @@ -0,0 +1,294 @@ +#nullable enable +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reflection; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + // variants:ruling:unique-indexes. The canonical index text and the unique-index load verdicts in tests/vectors/unique-index. + // The C# reference is the only writer (CULTCACHE_WRITE_VECTORS=1); every other run reads the committed bytes and + // 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 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 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; + } + + 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); + } + + // ---- values.msgpack: [label, kind, value, text-or-nil] ---- + + private sealed record Row(string Label, string Kind, object Value, string? Text); + + private static readonly MethodInfo RenderCanonicalNumber = + typeof(CultDocumentRegistry).GetMethod("RenderCanonicalNumber", BindingFlags.NonPublic | BindingFlags.Static)!; + + private static object? ByIndex(CultCache cache, Type type, string alias, string value) => + typeof(CultCache).GetMethod(nameof(CultCache.GetByIndex))!.MakeGenericMethod(type).Invoke(cache, new object[] { alias, value }); + + private static string? Number(object raw) => (string?)RenderCanonicalNumber.Invoke(null, new[] { raw }); + + // Strings and booleans carry the text the rule states; numbers carry whatever the reference renders, never hand-typed. + private static List Rows() => new() + { + new("plain", "string", "S-1", "S-1"), + new("empty", "string", "", null), + new("space", "string", " ", null), + new("nel", "string", "\u0085", null), + new("nbsp", "string", "\u00A0", null), + new("ideographic-space", "string", "\u3000", null), + new("vertical-tab", "string", "\u000B", null), + new("bom", "string", "\uFEFF", "\uFEFF"), + new("unit-separator", "string", "\u001F", "\u001F"), + new("padded", "string", " a ", " a "), + new("int64-max", "int64", long.MaxValue, Number(long.MaxValue)), + new("int64-min", "int64", long.MinValue, Number(long.MinValue)), + new("uint64-max", "uint64", ulong.MaxValue, Number(ulong.MaxValue)), + new("double-zero", "double", 0.0, Number(0.0)), + new("double-negative-zero", "double", -0.0, Number(-0.0)), + new("double-tenth", "double", 0.1, Number(0.1)), + new("double-1e21", "double", 1e21, Number(1e21)), + new("double-2.5e-7", "double", 2.5e-7, Number(2.5e-7)), + new("double-nan", "double", double.NaN, Number(double.NaN)), + new("double-infinity", "double", double.PositiveInfinity, Number(double.PositiveInfinity)), + new("bool-true", "bool", true, "True"), + new("bool-false", "bool", false, "False"), + }; + + private static byte[] ValuesFile() + { + var buffer = new ArrayBufferWriter(); + var writer = new MessagePackWriter(buffer); + var rows = Rows(); + writer.WriteArrayHeader(rows.Count); + foreach (var row in rows) + { + writer.WriteArrayHeader(4); + writer.Write(row.Label); + writer.Write(row.Kind); + switch (row.Value) + { + case string s: writer.Write(s); break; + case long l: writer.Write(l); break; + case ulong u: writer.Write(u); break; + case double d: writer.Write(d); break; + case bool b: writer.Write(b); break; + default: throw new InvalidOperationException(row.Kind); + } + + if (row.Text == null) writer.WriteNil(); else writer.Write(row.Text); + } + + writer.Flush(); + return buffer.WrittenSpan.ToArray(); + } + + private static readonly Dictionary KindTypes = new[] + { + ("string", typeof(string)), ("int64", typeof(long)), ("uint64", typeof(ulong)), ("double", typeof(double)), ("bool", typeof(bool)) + }.ToDictionary(pair => pair.Item1, pair => EmittedDocumentTypes.Emit( + "UqKind" + pair.Item1, "tests.uq_kind_" + pair.Item1, "tests.uq_kind_" + pair.Item1 + ".v1", + new[] { new EmittedDocumentTypes.Field("Value", pair.Item2, 0, Index: "v") })); + + [Test] + public void ValuesFileIsTheReferencesCanonicalText() + { + Check("values.msgpack", ValuesFile()); + + var reader = new MessagePackReader(Committed("values.msgpack")); + var count = reader.ReadArrayHeader(); + var rows = Rows(); + Assert.That(count, Is.EqualTo(rows.Count)); + foreach (var row in rows) + { + Assert.That(reader.ReadArrayHeader(), Is.EqualTo(4)); + Assert.That(reader.ReadString(), Is.EqualTo(row.Label)); + Assert.That(reader.ReadString(), Is.EqualTo(row.Kind)); + reader.Skip(); + var text = reader.TryReadNil() ? null : reader.ReadString(); + Assert.That(text, Is.EqualTo(row.Text), row.Label); + + var type = KindTypes[row.Kind]; + var registry = CultDocumentRegistry.ForTypes(new[] { type }); + using var cache = CultCacheMessagePack.Create(Path.Combine(Path.GetTempPath(), $"cultlib-uidxv-{Guid.NewGuid():N}.cc"), new CultCacheOpenOptions { Registry = registry }); + cache.Commit(batch => + { + batch.Upsert(type, EmittedDocumentTypes.New(type, ("Value", row.Value)), new CultRecordKey("one")); + batch.Upsert(type, EmittedDocumentTypes.New(type, ("Value", row.Value)), new CultRecordKey(text == null ? "two" : "one")); + }); + if (text == null) + { + Assert.That(cache.Get(new CultRecordKey("two")), Is.Not.Null, $"{row.Label}: a blank value commits twice"); + foreach (var probe in new[] { "", " ", "NaN", "0", "Infinity", "∞" }) + Assert.That(ByIndex(cache, type, "v", probe), Is.Null, $"{row.Label}: found by '{probe}'"); + } + else + { + Assert.That(ByIndex(cache, type, "v", text), Is.Not.Null, $"{row.Label}: found by its text"); + } + } + } + + // ---- the stores: written through a role, judged by an index ---- + + private static readonly EmittedDocumentTypes.Field[] RoleFields = + { + new("Sku", typeof(string), 0, Role: "Sku"), new("Category", typeof(string), 1) + }; + + private static readonly EmittedDocumentTypes.Field[] IndexFields = + { + new("Sku", typeof(string), 0, Index: "Sku"), new("Category", typeof(string), 1) + }; + + private static readonly Type ItemRole = EmittedDocumentTypes.Emit("UqVecItemRole", "tests.unique_item", "tests.unique_item.v1", RoleFields); + private static readonly Type ItemIndex = EmittedDocumentTypes.Emit("UqVecItemIndex", "tests.unique_item", "tests.unique_item.v1", IndexFields); + + private static readonly (string Key, string Sku, string Category)[] Items = + { + ("item:a", "S-1", "tools"), ("item:b", "S-1", "tools"), ("item:c", " ", "parts"), ("item:d", "\u0085", "parts") + }; + + private static byte[] StoreBytes(string[] keys) + { + var directory = Path.Combine(Path.GetTempPath(), $"cultlib-uidxs-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + try + { + var path = Path.Combine(directory, "store.cc"); + using (var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions + { + Registry = CultDocumentRegistry.ForTypes(new[] { ItemRole }), StoreFlushOnDispose = true + })) + { + cache.Clock = () => StoredAt; + foreach (var item in Items.Where(item => keys.Contains(item.Key))) + cache.UpsertAsync(ItemRole, EmittedDocumentTypes.New(ItemRole, ("Sku", item.Sku), ("Category", item.Category)), new CultRecordKey(item.Key)).GetAwaiter().GetResult(); + cache.FlushAsync().GetAwaiter().GetResult(); + } + + return File.ReadAllBytes(path); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + private static readonly (string File, string? Alias, string Outcome, string? K1, string? K2)[] Outcomes = + { + ("duplicate-store.cc", "Sku", "refused", "item:a", "item:b"), + ("duplicate-store.cc", null, "open", null, null), + ("blank-store.cc", "Sku", "open", null, null), + }; + + private static byte[] OutcomesFile() + { + var buffer = new ArrayBufferWriter(); + var writer = new MessagePackWriter(buffer); + writer.WriteArrayHeader(Outcomes.Length); + foreach (var row in Outcomes) + { + writer.WriteArrayHeader(5); + writer.Write(row.File); + foreach (var text in new[] { row.Alias, row.Outcome, row.K1, row.K2 }) + if (text == null) writer.WriteNil(); else writer.Write(text); + } + + writer.Flush(); + return buffer.WrittenSpan.ToArray(); + } + + private static string? NextText(ref MessagePackReader reader) => reader.TryReadNil() ? null : reader.ReadString(); + + [Test] + public void StoreVectorsHoldTheirVerdicts() + { + Check("duplicate-store.cc", StoreBytes(new[] { "item:a", "item:b", "item:c", "item:d" })); + Check("blank-store.cc", StoreBytes(new[] { "item:c", "item:d" })); + Check("outcomes.msgpack", OutcomesFile()); + + var reader = new MessagePackReader(Committed("outcomes.msgpack")); + var count = reader.ReadArrayHeader(); + Assert.That(count, Is.EqualTo(Outcomes.Length)); + for (var i = 0; i < count; i++) + { + Assert.That(reader.ReadArrayHeader(), Is.EqualTo(5)); + var file = reader.ReadString()!; + var alias = NextText(ref reader); + var outcome = NextText(ref reader)!; + var k1 = NextText(ref reader); + var k2 = NextText(ref reader); + + var directory = Path.Combine(Path.GetTempPath(), $"cultlib-uidxo-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + try + { + var path = Path.Combine(directory, "store.cc"); + File.WriteAllBytes(path, Committed(file)); + var before = File.ReadAllBytes(path); + var type = alias == null ? ItemRole : ItemIndex; + var registry = CultDocumentRegistry.ForTypes(new[] { type }); + if (outcome == "open") + { + using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = registry }); + Assert.That(cache.Get(new CultRecordKey("item:c")), Is.Not.Null, file); + } + else + { + var refused = Assert.Throws(() => CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = registry }).Dispose())!; + Assert.That(refused.Message, Is.EqualTo( + $"Records {k1} and {k2} (tests.unique_item) both hold unique index '{alias}'; an index value names one record.")); + Assert.That(refused.Message, Does.Not.Contain("S-1")); + } + + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), $"{file} is untouched"); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + } + } +} diff --git a/tests/vectors/unique-index/README.md b/tests/vectors/unique-index/README.md new file mode 100644 index 00000000..548c8845 --- /dev/null +++ b/tests/vectors/unique-index/README.md @@ -0,0 +1,24 @@ +# unique-index vectors + +Written only by the C# reference, `tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs`, with +`CULTCACHE_WRITE_VECTORS=1`. Every other run reads these files and compares them byte for byte; a changed vector is a +reviewed commit. Other runtimes are held to the same canonical text and the same verdicts (`variants:ruling:unique-indexes`). + +## Files + +- `values.msgpack`: an array of `[label, kind, value, text-or-nil]`, kind one of `string`, `int64`, `uint64`, `double`, + `bool` (decode integers 64-bit wide). `text` is the canonical index text of a member holding `value`: a record holding + it is found by `GetByIndex(alias, text)`. A nil text is a blank value: no value, never indexed, never judged, found by + no text, and two records holding it commit. Strings are as is (never trimmed), blank being empty or only C# + `White_Space` characters (U+0009-000D, U+0020, U+0085, U+00A0, U+1680, U+2000-200A, U+2028, U+2029, U+202F, U+205F, + U+3000); U+FEFF and U+001F are values. Booleans are `True` and `False`. Integers are their decimal digits. A + non-integral finite double is its exact decimal expansion (written by C# `RenderExactDouble`, never hand-typed), + `-0.0` is `0`, NaN and infinities are nil. +- `duplicate-store.cc`: a single-file store, schema `tests.unique_item` v1, slots `[Sku: string, Category: string]`, + stored-at pinned to `2026-01-01T00:00:00Z`. Records `item:a` and `item:b` both hold Sku `S-1`; `item:c` holds Sku + `" "` and `item:d` holds Sku `U+0085`. Written through a role Sku. +- `blank-store.cc`: the same, only `item:c` and `item:d`. +- `outcomes.msgpack`: rows `[file, unique alias or nil, open|refused, k1 or nil, k2 or nil]`. Opening `file` with that + alias a unique index (nil: a role, no unique alias) yields the verdict. A refusal reads, byte for byte, + `Records and (tests.unique_item) both hold unique index ''; an index value names one record.` + with `k1 < k2` ordinal, never contains the value, and leaves the file byte-identical. diff --git a/tests/vectors/unique-index/blank-store.cc b/tests/vectors/unique-index/blank-store.cc new file mode 100644 index 0000000000000000000000000000000000000000..2b69faccd2c2237747bde2673f25dbcdcf6947bb GIT binary patch literal 964 zcmchV!A{#i5QfXKJO%Z&!CJ5Fjn}uThu(T4T{S{E@&HLI!L=X| zmmXU204U-m;Axn|Ap(UP6l*U#`|tn#v!0*-R8u$wY6pGm1~vNMgYn_@2fG727O4!g zERjrO5;7r~2*3y_G9DTk869$yg^Fv?p^^cjh+<4Y>sYWPHbIi`zYeXlepBV&8r;qu zmKQ4(A})DRq77;Y1&@G5EXriexJaam5Da<8qAQGmE(Rhdtjdh&a>FB)1tHy#H*4j- zXQ`PIK0=9J>aF0s&O-1RYnuEof@PBT;IKcahyOXwBH)8GUq|ERpL^=0OpaciP{)5ByybLyIh|FH*3R$RToqhS zex1!GD^<@H=K2k%vD;(;tu4mV`SMx&y`Ar_UQd2+obEyG>;kV}6u&+>o*uOPTV?V; Ar~m)} literal 0 HcmV?d00001 diff --git a/tests/vectors/unique-index/duplicate-store.cc b/tests/vectors/unique-index/duplicate-store.cc new file mode 100644 index 0000000000000000000000000000000000000000..4caec45135e6bf1ccb182d64fb3ab055e8b40708 GIT binary patch literal 1222 zcmc(d&u-K(5XR{dcnQdrZR9wKH_i=l;KqREv-Pl+AG3iZeXbg4wIP_^oX z1BeHxR=h-c8fG`UE2`xNl*kv4KhO93oO%CM_m$V6_b_z6?J#^D9e%ic==Ol8BA2mJ znPdjDm51Xf6)cx+{2P0X!|H8)^lEh9vcq?my*sbE=Zqb%cJJUZ{fzA0bz_@eY! zf7_~%@OtCbaFZh4|r6w?j)W2b%_ zI0{q4Bd9URgDtyYv=ThU&XsNRI}IU`tj1cACdi*r&#Lu7&PG8qv@Krtr0~=Ig-0=U zlq@WKS2hzWm+moI?6A>vYN2vS&7hfXHXgbO`r;SPrmrfx*cg0szbb)DgTbP7Ly%>g zgVCBK+K@Z|kCV2W{$rdK;1!zZ2Zx(K_otIGJHBy3i9cU?%lZ6bzNjAWoUdbU3oee1 z&K8rE!(W5Byvu3qc3DL031j(kg>3llEqC8<&rXWn=+w7ull`6uzCEfu;8ca&F-d!L^CrE^)ANyMd^WqB&qpt$3} zI&~?ANxsu|_bOdx3Ga)xo^&f+C=s>g)8hw~o~j^A)(tapC_@nsQl4zdzVq*N@ohY= z^ue}BbJ^#Slk>ppUNX8tJZ#J2?CrZc^^1{`xs!>6r#z2S%ifr5=vl(UP=t1(%e#e1 zy)RYnVQ0wil`C|zyKLgfN62+40%?Q+NrCLKK$By_2c2`EzoPcAnczD#yn@i@j zO#$?Ysm$SA>iML0uIKhbT-i;_-^~Rp{jz7Dr SfqK5`Zt^H=+Q;RM8vg+*qAr*K literal 0 HcmV?d00001