diff --git a/tests/GameCult.Caching.Tests/C2aGateWriter.cs b/tests/GameCult.Caching.Tests/C2aGateWriter.cs new file mode 100644 index 00000000..a6e28283 --- /dev/null +++ b/tests/GameCult.Caching.Tests/C2aGateWriter.cs @@ -0,0 +1,138 @@ +#nullable enable +using System; +using System.IO; +using System.Linq; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + // The writer half of the C2a format gate. It writes a record that holds element ids through CultCacheMessagePack.Create, once + // as a single-file store and once as a directory store, and asserts the headers C2a stamps. old-reader-refusal.sh gate runs it + // with CULTLIB_GATE_OUT set, so the 2.0.1 reader probes the very bytes this tree writes; the committed vectors are one such run. + public class C2aGateWriter + { + private const string SingleName = "c2a-written-ids.msgpack"; + private const string DirectoryName = "c2a-written-ids-directory"; + private const string ManifestName = "store.cc"; + private const string DirectoryHeader = "cultcache.store.v5.directory-content-addressed-pages"; + + private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(ElementIdTests.IdDeck) }); + + // Every id is the author's, so the written bytes and the decoded ids do not depend on minting. + private static readonly string[] Ids = { "00000000c2a1", "00000000c2a2", "00000000c2a3", "00000000c2a4" }; + + private static ElementIdTests.IdDeck Deck() => new() + { + Name = "gate", + Reels = + { + new ElementIdTests.IdReel + { + Label = "reel", + Id = Ids[0], + Marks = { new ElementIdTests.IdMark { Text = "m", Id = Ids[1] }, new ElementIdTests.IdMark { Text = "m", Id = Ids[2] } } + } + }, + Moves = { new ElementIdTests.IdSlash { Reach = 1, Id = Ids[3] } } + }; + + private static string[] IdsOf(ElementIdTests.IdDeck deck) => + deck.Reels.Select(reel => reel.Id) + .Concat(deck.Reels.SelectMany(reel => reel.Marks.Select(mark => mark.Id))) + .Concat(deck.Moves.Select(move => move.Id)) + .ToArray(); + + private static void Write(string path, bool directory) + { + using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry, UseDirectoryStore = directory }); + cache.Commit(batch => batch.Upsert(typeof(ElementIdTests.IdDeck), Deck(), new CultRecordKey("gate"))); + } + + private static string HeaderOf(string path) => + CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).FormatVersion; + + private static string[] ReadIds(string path) + { + using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }); + return IdsOf(cache.Get(new CultRecordKey("gate"))!); + } + + private static string Vectors() + { + for (var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); dir != null; dir = dir.Parent) + { + var vectors = Path.Combine(dir.FullName, "tests", "vectors", "document-variants-c2a"); + if (Directory.Exists(vectors)) return vectors; + } + + throw new DirectoryNotFoundException($"tests/vectors/document-variants-c2a not found above {TestContext.CurrentContext.TestDirectory}."); + } + + private static void CopyTree(string from, string to) + { + Directory.CreateDirectory(to); + foreach (var file in Directory.GetFiles(from)) + File.Copy(file, Path.Combine(to, Path.GetFileName(file))); + foreach (var directory in Directory.GetDirectories(from)) + CopyTree(directory, Path.Combine(to, Path.GetFileName(directory))); + } + + private string _temp = string.Empty; + + [SetUp] + public void CreateTemp() + { + _temp = Path.Combine(Path.GetTempPath(), $"cultlib-c2a-gate-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_temp); + } + + [TearDown] + public void DeleteTemp() + { + if (Directory.Exists(_temp)) + Directory.Delete(_temp, recursive: true); + } + + [Test] + public void C2aStampsV3OnASingleFileStoreAndV5OnADirectoryStoreThatHoldIds() + { + var output = Environment.GetEnvironmentVariable("CULTLIB_GATE_OUT"); + var root = string.IsNullOrEmpty(output) ? _temp : output; + Directory.CreateDirectory(root); + var single = Path.Combine(root, SingleName); + var directory = Path.Combine(root, DirectoryName); + File.Delete(single); + if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true); + Directory.CreateDirectory(directory); + + Write(single, directory: false); + Write(Path.Combine(directory, ManifestName), directory: true); + + Assert.That(HeaderOf(single), Is.EqualTo(CultPersistedStoreSnapshot.FormatV3)); + Assert.That(HeaderOf(Path.Combine(directory, ManifestName)), Is.EqualTo(DirectoryHeader)); + Assert.That(ReadIds(single), Is.EqualTo(Ids)); + } + + [Test] + public void TheCommittedVectorsAreWhatC2aWrites() + { + var live = Path.Combine(_temp, "live"); + Directory.CreateDirectory(live); + Write(Path.Combine(live, SingleName), directory: false); + Write(Path.Combine(live, ManifestName), directory: true); + + var committedSingle = Path.Combine(Vectors(), SingleName); + var committedManifest = Path.Combine(Vectors(), DirectoryName, ManifestName); + Assert.That(HeaderOf(committedSingle), Is.EqualTo(HeaderOf(Path.Combine(live, SingleName)))); + Assert.That(HeaderOf(committedManifest), Is.EqualTo(HeaderOf(Path.Combine(live, ManifestName)))); + + // The committed bytes are read through a copy: opening a store may take its lock beside it. + var copy = Path.Combine(_temp, "copy"); + CopyTree(Vectors(), copy); + Assert.That(ReadIds(Path.Combine(copy, SingleName)), Is.EqualTo(ReadIds(Path.Combine(live, SingleName)))); + Assert.That(ReadIds(Path.Combine(copy, DirectoryName, ManifestName)), Is.EqualTo(Ids)); + } + } +} diff --git a/tests/GameCult.Caching.Tests/RefListDriftTests.cs b/tests/GameCult.Caching.Tests/RefListDriftTests.cs index 2cb52167..73c98d08 100644 --- a/tests/GameCult.Caching.Tests/RefListDriftTests.cs +++ b/tests/GameCult.Caching.Tests/RefListDriftTests.cs @@ -106,6 +106,38 @@ namespace GameCult.Caching.Tests } } + // 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. + [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().Single().Items.Select(item => item.Key.Value).ToArray(); + Assert.That(items, Is.EqualTo(new[] { "alpha", "beta" })); + Assert.That(cache.Get(cache.GetAll().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 + "=" + record.SchemaId).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 persistedItems) { var catalog = CultDocumentRegistry.Shared.GetRequired().ToCatalogEntry(); diff --git a/tests/vectors/document-variants-c2a/old-reader-probes/OldReaderGateProbe.cs b/tests/vectors/document-variants-c2a/old-reader-probes/OldReaderGateProbe.cs new file mode 100644 index 00000000..a4a51fbe --- /dev/null +++ b/tests/vectors/document-variants-c2a/old-reader-probes/OldReaderGateProbe.cs @@ -0,0 +1,164 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Threading.Tasks; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + // Copied into a checkout of cultlib-unity-v2.0.1 (b8fc5eb0) by old-reader-refusal.sh. Not part of this tree's suite. + // The 2.0.1 reader meets the stores C2a writes. The source is "committed" (tests/vectors/document-variants-c2a) or, when + // CULTLIB_GATE_OUT is set, "live" (what C2aGateWriter just wrote at the tree under test). Every refusal names the format, and + // the store is byte-identical after it. + public class OldReaderGateProbe + { + private const string SingleName = "c2a-written-ids.msgpack"; + private const string DirectoryName = "c2a-written-ids-directory"; + private const string ManifestName = "store.cc"; + + [CultDocument("tests.gate_probe", "tests.gate_probe.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class GateProbeDocument + { + [Key(0)] + [CultName] + public string Name = string.Empty; + } + + public static IEnumerable Sources() + { + yield return "committed"; + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CULTLIB_GATE_OUT"))) + yield return "live"; + } + + private static string SourceRoot(string source) => source == "live" + ? Environment.GetEnvironmentVariable("CULTLIB_GATE_OUT")! + : Path.Combine(Environment.GetEnvironmentVariable("CULTLIB_ROOT")!, "tests", "vectors", "document-variants-c2a"); + + private string _temp = string.Empty; + + [SetUp] + public void CreateTemp() + { + _temp = Path.Combine(Path.GetTempPath(), $"cultlib-gate-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_temp); + } + + [TearDown] + public void DeleteTemp() + { + if (Directory.Exists(_temp)) + Directory.Delete(_temp, recursive: true); + } + + private string CopySingle(string source, string name) + { + var file = Path.Combine(_temp, name); + File.Copy(Path.Combine(SourceRoot(source), SingleName), file); + return file; + } + + private string CopyDirectory(string source, string name) + { + var root = Path.Combine(_temp, name); + CopyTree(Path.Combine(SourceRoot(source), DirectoryName), root); + return Path.Combine(root, ManifestName); + } + + private static void CopyTree(string from, string to) + { + Directory.CreateDirectory(to); + foreach (var file in Directory.GetFiles(from)) + File.Copy(file, Path.Combine(to, Path.GetFileName(file))); + foreach (var directory in Directory.GetDirectories(from)) + CopyTree(directory, Path.Combine(to, Path.GetFileName(directory))); + } + + // Every file under the root with its content hash, in a fixed order: a tree compares equal only if nothing in it moved. + private static string[] Tree(string root) => + Directory.GetFiles(root, "*", SearchOption.AllDirectories) + .OrderBy(file => file, StringComparer.Ordinal) + .Select(file => Path.GetRelativePath(root, file) + " " + Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(file)))) + .ToArray(); + + private static void Names(Exception? error, string format, string what) + { + Assert.That(error, Is.Not.Null, what); + TestContext.Out.WriteLine($"OLD-READER csharp refused {what}: " + error!.Message); + Assert.That(error.ToString(), Does.Contain(format), what); + } + + [TestCaseSource(nameof(Sources))] + public void V3IsRefusedAtAttach(string source) + { + var file = CopySingle(source, "attach.cc"); + var before = File.ReadAllBytes(file); + using var cache = new CultCache(); + Names(Assert.Catch(() => cache.AddBackingStore(new SingleFileMessagePackBackingStore(file))), "cultcache.store.v3", "attach"); + Assert.That(File.ReadAllBytes(file), Is.EqualTo(before)); + } + + [TestCaseSource(nameof(Sources))] + public void V3IsRefusedByPushAllOfAStoreThatWasNeverAttached(string source) + { + var file = CopySingle(source, "detached.cc"); + var before = File.ReadAllBytes(file); + var store = new SingleFileMessagePackBackingStore(file); + Names(Assert.Catch(() => store.PushAll()), "cultcache.store.v3", "PushAll of a detached store"); + Assert.That(File.ReadAllBytes(file), Is.EqualTo(before)); + } + + [TestCaseSource(nameof(Sources))] + public async Task V3SwappedInAfterAttachIsRefusedByFlushAndPushAll(string source) + { + var file = Path.Combine(_temp, "swap.cc"); + using var cache = new CultCache(); + var store = new SingleFileMessagePackBackingStore(file); + cache.AddBackingStore(store); + await cache.AddAsync(new GateProbeDocument { Name = "staged" }); + File.Copy(Path.Combine(SourceRoot(source), SingleName), file, overwrite: true); + var before = File.ReadAllBytes(file); + + Names(Assert.Catch(() => cache.FlushAllBackingStores()), "cultcache.store.v3", "flush after a swap"); + Assert.That(File.ReadAllBytes(file), Is.EqualTo(before)); + Names(Assert.Catch(() => store.PushAll()), "cultcache.store.v3", "PushAll after a swap"); + Assert.That(File.ReadAllBytes(file), Is.EqualTo(before)); + } + + [TestCaseSource(nameof(Sources))] + public void V5IsRefusedByTheDirectoryStoreAtAttach(string source) + { + var manifest = CopyDirectory(source, "attach-directory"); + var before = Tree(Path.GetDirectoryName(manifest)!); + using var cache = new CultCache(); + Names(Assert.Catch(() => cache.AddBackingStore(new DirectoryMessagePackBackingStore(manifest))), "cultcache.store.v5", "directory attach"); + Assert.That(Tree(Path.GetDirectoryName(manifest)!), Is.EqualTo(before)); + } + + [TestCaseSource(nameof(Sources))] + public async Task V5SwappedInAfterAttachIsRefusedByTheDirectoryStoreAtFlush(string source) + { + var root = Path.Combine(_temp, "swap-directory"); + Directory.CreateDirectory(root); + var manifest = Path.Combine(root, ManifestName); + using var cache = new CultCache(); + var store = new DirectoryMessagePackBackingStore(manifest); + cache.AddBackingStore(store); + await cache.AddAsync(new GateProbeDocument { Name = "staged" }); + CopyTree(Path.Combine(SourceRoot(source), DirectoryName), root); + var before = Tree(root); + + Names(Assert.Catch(() => cache.FlushAllBackingStores()), "cultcache.store.v5", "directory flush after a swap"); + Assert.That(Tree(root), Is.EqualTo(before)); + Names(Assert.Catch(() => store.PushAll()), "cultcache.store.v5", "directory PushAll after a swap"); + Assert.That(Tree(root), Is.EqualTo(before)); + } + } +} diff --git a/tests/vectors/document-variants-c2a/old-reader-probes/OldReaderRefListProbe.cs b/tests/vectors/document-variants-c2a/old-reader-probes/OldReaderRefListProbe.cs new file mode 100644 index 00000000..07dc55c9 --- /dev/null +++ b/tests/vectors/document-variants-c2a/old-reader-probes/OldReaderRefListProbe.cs @@ -0,0 +1,67 @@ +#nullable enable +using System; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + // Copied into a checkout of cultlib-unity-v2.0.1 (b8fc5eb0) by old-reader-refusal.sh. Not part of this tree's suite. + // It attaches the store the 1.0.60 writer produced and prints the REFLIST-SUMMARY line that + // RefListDriftTests.A_1_0_60_Written_Store_Opens_Like_2_0_1 prints at head; 'gate csharp' diffs the two lines. + public class OldReaderRefListProbe + { + [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>(); + } + + [Test] + public async Task A_1_0_60_Written_Store_Attaches_As_Compatible_Drift() + { + var copy = Path.Combine(Path.GetTempPath(), $"reflist-gate-{Guid.NewGuid():N}.cc"); + File.Copy(Path.Combine(Environment.GetEnvironmentVariable("CULTLIB_GATE_OUT")!, "v1-0-60-ref-list.cc"), 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().Single().Items.Select(item => item.Key.Value).ToArray(); + Assert.That(items, Is.EqualTo(new[] { "alpha", "beta" })); + + store.PushAll(); + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(copy)); + var records = snapshot.Records.OrderBy(record => record.Key, StringComparer.Ordinal).Select(record => record.Key + "=" + record.SchemaId).ToArray(); + TestContext.Out.WriteLine("REFLIST-SUMMARY " + JsonSerializer.Serialize(new { records, items, warnings, header = snapshot.FormatVersion })); + } + finally + { + File.Delete(copy); + } + } + } +} diff --git a/tests/vectors/document-variants-c2a/old-reader-probes/OldReaderV3Probe.cs b/tests/vectors/document-variants-c2a/old-reader-probes/OldReaderV3Probe.cs deleted file mode 100644 index 3f08aa8d..00000000 --- a/tests/vectors/document-variants-c2a/old-reader-probes/OldReaderV3Probe.cs +++ /dev/null @@ -1,34 +0,0 @@ -#nullable enable -using System; -using System.IO; -using GameCult.Caching; -using GameCult.Caching.MessagePack; -using NUnit.Framework; - -namespace GameCult.Caching.Tests -{ - // Copied into a checkout of the C0 merge (e382bb4) by old-reader-refusal.sh. Not part of this tree's suite. - public class OldReaderV3Probe - { - [Test] - public void ACopyOfV3BaseIsRefusedByHeader() - { - var root = Path.Combine(Path.GetTempPath(), $"cultlib-oldreader-{Guid.NewGuid():N}"); - Directory.CreateDirectory(root); - try - { - var file = Path.Combine(root, "store.msgpack"); - File.Copy(Path.Combine(Environment.GetEnvironmentVariable("CULTLIB_ROOT")!, "tests", "vectors", "document-variants-c2a", "v3-base.msgpack"), file); - using var cache = new CultCache(); - var error = Assert.Catch(() => cache.AddBackingStore(new SingleFileMessagePackBackingStore(file))); - Assert.That(error, Is.Not.Null); - TestContext.Out.WriteLine("OLD-READER csharp refused: " + error!.Message); - Assert.That(error.ToString(), Does.Contain("cultcache.store.v3")); - } - finally - { - Directory.Delete(root, recursive: true); - } - } - } -} diff --git a/tests/vectors/document-variants-c2a/old-reader-probes/old-reader-v3.test.ts b/tests/vectors/document-variants-c2a/old-reader-probes/old-reader-v3.test.ts index 82e92a93..083ad75a 100644 --- a/tests/vectors/document-variants-c2a/old-reader-probes/old-reader-v3.test.ts +++ b/tests/vectors/document-variants-c2a/old-reader-probes/old-reader-v3.test.ts @@ -4,15 +4,42 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { test } from "node:test"; +import { documentPayloadFromBytes } from "../src/document"; import { SingleFileMessagePackBackingStore } from "../src/single-file-messagepack-backing-store"; -// Copied into a checkout of the C0 merge (e382bb4) by old-reader-refusal.sh. Not part of this tree's suite. -test("old TypeScript reader refuses the v3 element-id store by header", async () => { - const dir = await mkdtemp(join(tmpdir(), "cultcache-oldreader-")); - const file = join(dir, "store.msgpack"); - await writeFile(file, await readFile(resolve(__dirname, "../../../../tests/vectors/document-variants-c2a/v3-base.msgpack"))); - await assert.rejects(() => new SingleFileMessagePackBackingStore(file).pullAll(), (error: Error) => { - console.log("OLD-READER typescript refused: " + error.message); +// Copied into a checkout of cultcache-rs-v0.3.1 (02cf04b5) by old-reader-refusal.sh. Not part of this tree's suite. +// The vector is the store C2aGateWriter wrote through CultCacheMessagePack.Create. +const vector = resolve(__dirname, "../../../../tests/vectors/document-variants-c2a/c2a-written-ids.msgpack"); +const envelope = { key: "probe", type: "probe", payload: documentPayloadFromBytes(new Uint8Array([0x80])), storedAt: "2026-10-22T00:00:00Z" }; + +async function copyOfTheVector(): Promise<{ file: string; bytes: Buffer }> { + const bytes = await readFile(vector); + const file = join(await mkdtemp(join(tmpdir(), "cultcache-oldreader-")), "store.msgpack"); + await writeFile(file, bytes); + return { file, bytes }; +} + +function refusesV3(label: string) { + return (error: Error) => { + console.log(`OLD-READER typescript refused ${label}: ` + error.message); return /cultcache\.store\.v3/u.test(error.message); - }); + }; +} + +test("old TypeScript reader refuses the C2a element-id store at read", async () => { + const { file, bytes } = await copyOfTheVector(); + await assert.rejects(() => new SingleFileMessagePackBackingStore(file).pullAll(), refusesV3("pullAll")); + assert.equal(Buffer.compare(await readFile(file), bytes), 0); +}); + +test("old TypeScript reader refuses a per-record push to the C2a element-id store", async () => { + const { file, bytes } = await copyOfTheVector(); + await assert.rejects(() => new SingleFileMessagePackBackingStore(file).push(envelope), refusesV3("push")); + assert.equal(Buffer.compare(await readFile(file), bytes), 0); +}); + +test("old TypeScript reader refuses a whole-store pushAll over the C2a element-id store", async () => { + const { file, bytes } = await copyOfTheVector(); + await assert.rejects(() => new SingleFileMessagePackBackingStore(file).pushAll([envelope]), refusesV3("pushAll")); + assert.equal(Buffer.compare(await readFile(file), bytes), 0); }); diff --git a/tests/vectors/document-variants-c2a/old-reader-probes/old_reader_v3.rs b/tests/vectors/document-variants-c2a/old-reader-probes/old_reader_v3.rs index 809f13b6..0965eff2 100644 --- a/tests/vectors/document-variants-c2a/old-reader-probes/old_reader_v3.rs +++ b/tests/vectors/document-variants-c2a/old-reader-probes/old_reader_v3.rs @@ -1,15 +1,53 @@ -// Copied into a checkout of the C0 merge (e382bb4) by old-reader-refusal.sh. Not part of this tree's suite. -use cultcache_rs::{CacheBackingStore, SingleFileMessagePackBackingStore}; +// Copied into a checkout of cultcache-rs-v0.3.1 (02cf04b5) by old-reader-refusal.sh. Not part of this tree's suite. +// The vector is the store C2aGateWriter wrote through CultCacheMessagePack.Create. +use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, PushAllOptions, SingleFileMessagePackBackingStore}; -#[test] -fn old_rust_reader_refuses_the_v3_element_id_store_by_header() { +fn copy_of_the_vector() -> (tempfile::TempDir, std::path::PathBuf, Vec) { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("store.msgpack"); let vector = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../tests/vectors/document-variants-c2a/v3-base.msgpack"); + .join("../../tests/vectors/document-variants-c2a/c2a-written-ids.msgpack"); std::fs::copy(vector, &path).unwrap(); - let error = SingleFileMessagePackBackingStore::new(&path).pull_all().unwrap_err(); + let bytes = std::fs::read(&path).unwrap(); + (temp, path, bytes) +} + +fn envelope() -> CultCacheEnvelope { + CultCacheEnvelope { + key: "probe".into(), + r#type: "probe".into(), + payload: vec![0x80], + stored_at: "2026-10-22T00:00:00Z".into(), + schema_id: None, + } +} + +fn refused_v3(label: &str, error: anyhow::Error) { let message = format!("{error:#}"); - println!("OLD-READER rust refused: {message}"); + println!("OLD-READER rust refused {label}: {message}"); assert!(message.contains("cultcache.store.v3"), "{message}"); } + +#[test] +fn old_rust_reader_refuses_the_c2a_element_id_store_at_read() { + let (_temp, path, bytes) = copy_of_the_vector(); + refused_v3("pull_all", SingleFileMessagePackBackingStore::new(&path).pull_all().unwrap_err()); + assert_eq!(std::fs::read(&path).unwrap(), bytes); +} + +#[test] +fn old_rust_reader_refuses_a_per_record_push_to_the_c2a_element_id_store() { + let (_temp, path, bytes) = copy_of_the_vector(); + refused_v3("push", SingleFileMessagePackBackingStore::new(&path).push(&envelope()).unwrap_err()); + assert_eq!(std::fs::read(&path).unwrap(), bytes); +} + +#[test] +fn old_rust_reader_refuses_a_whole_store_push_all_over_the_c2a_element_id_store() { + let (_temp, path, bytes) = copy_of_the_vector(); + let error = SingleFileMessagePackBackingStore::new(&path) + .push_all(std::slice::from_ref(&envelope()), PushAllOptions::default()) + .unwrap_err(); + refused_v3("push_all", error); + assert_eq!(std::fs::read(&path).unwrap(), bytes); +} diff --git a/tests/vectors/document-variants-c2a/old-reader-probes/test_old_reader_v3.py b/tests/vectors/document-variants-c2a/old-reader-probes/test_old_reader_v3.py index d899f12b..83a7e2ea 100644 --- a/tests/vectors/document-variants-c2a/old-reader-probes/test_old_reader_v3.py +++ b/tests/vectors/document-variants-c2a/old-reader-probes/test_old_reader_v3.py @@ -2,20 +2,39 @@ import tempfile import unittest from pathlib import Path -from cultcache_py import SingleFileMessagePackBackingStore +from cultcache_py import CultCacheEnvelope, SingleFileMessagePackBackingStore -# Copied into a checkout of the C0 merge (e382bb4) by old-reader-refusal.sh. Not part of this tree's suite. +# Copied into a checkout of cultcache-rs-v0.3.1 (02cf04b5) by old-reader-refusal.sh. Not part of this tree's suite. +# The vector is the store C2aGateWriter wrote through CultCacheMessagePack.Create. class OldReaderV3(unittest.TestCase): - def test_old_python_reader_refuses_the_v3_element_id_store_by_header(self) -> None: - vector = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "document-variants-c2a" / "v3-base.msgpack" - with tempfile.TemporaryDirectory() as tmp: - store_path = Path(tmp) / "store.msgpack" - store_path.write_bytes(vector.read_bytes()) - with self.assertRaises(ValueError) as caught: - SingleFileMessagePackBackingStore(store_path).pull_all() - print("OLD-READER python refused:", caught.exception) + def setUp(self) -> None: + vector = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "document-variants-c2a" / "c2a-written-ids.msgpack" + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.path = Path(self._tmp.name) / "store.msgpack" + self.bytes = vector.read_bytes() + self.path.write_bytes(self.bytes) + self.envelope = CultCacheEnvelope(key="probe", type="probe", payload=b"\x80", stored_at="2026-10-22T00:00:00Z") + + def refuses_v3(self, label: str, action) -> None: + with self.assertRaises(ValueError) as caught: + action(SingleFileMessagePackBackingStore(self.path)) + print(f"OLD-READER python refused {label}:", caught.exception) self.assertIn("cultcache.store.v3", str(caught.exception)) + self.assertEqual(self.path.read_bytes(), self.bytes) + + def test_old_python_reader_refuses_the_c2a_element_id_store_at_read(self) -> None: + self.refuses_v3("pull_all", lambda store: store.pull_all()) + + def test_old_python_reader_refuses_a_per_record_push(self) -> None: + self.refuses_v3("push", lambda store: store.push(self.envelope)) + + def test_old_python_reader_refuses_a_whole_store_push_all(self) -> None: + self.refuses_v3("push_all", lambda store: store.push_all([self.envelope])) + + def test_old_python_reader_refuses_a_delete(self) -> None: + self.refuses_v3("delete", lambda store: store.delete("probe", "probe")) if __name__ == "__main__": diff --git a/tests/vectors/document-variants-c2a/old-reader-refusal.sh b/tests/vectors/document-variants-c2a/old-reader-refusal.sh index 5172c78c..40efb7db 100755 --- a/tests/vectors/document-variants-c2a/old-reader-refusal.sh +++ b/tests/vectors/document-variants-c2a/old-reader-refusal.sh @@ -1,46 +1,128 @@ #!/usr/bin/env bash -# Proves that a reader from before element ids refuses the v3 header, per runtime. The old reader is the code at the C0 -# merge (e382bb4), which accepts only cultcache.store.v1; OLD_READER_REV overrides it (the C1 merge, for the C# reader that -# reads v2). +# The C2a format gate: a reader from before element ids must refuse what C2a writes, and a store the 1.0.60 writer made must +# open alike at 2.0.1 and at the tree under test. Each old revision is a resolved SHA with its tag beside it, because the +# verification clone holds ancestors, not tags; an environment variable overrides each. # -# old-reader-refusal.sh stage -# Run in a CultLib clone. Commits the old revision plus the v3 vector and the probes in old-reader-probes/ onto a -# scratch ref and prints its SHA. The tree at that SHA is the old reader with one extra test. -# old-reader-refusal.sh probe csharp|typescript|python|rust -# Run at the root of the staged tree (a verification job given that SHA), with the runtime's toolchain on PATH. -# Exit status zero means the old reader refused the header by name; the refusal message is printed. +# C# OLD_READER_REV_CSHARP b8fc5eb0 cultlib-unity-v2.0.1 +# TypeScript, OLD_READER_REV_LEAF 02cf04b5 cultcache-rs-v0.3.1 (the whole-store writer fix; no cultcache-ts tag carries it) +# Python, Rust +# 1.0.60 writer OLD_WRITER_REV 45c2f400 cultlib-unity-v1.0.60 +# +# old-reader-refusal.sh stage csharp|typescript|python|rust|writer +# Run in a CultLib clone. Commits the old revision plus the C2a-written vectors and that runtime's probes onto a scratch +# ref and prints its SHA. The tree at that SHA is the old code with extra tests ("writer" is the 1.0.60 writer). +# old-reader-refusal.sh probe csharp|typescript|python|rust|writer +# Run at the root of a staged tree, with the runtime's toolchain on PATH. Exit status zero means every probe passed; the +# refusal messages are printed. +# old-reader-refusal.sh gate csharp|typescript|python|rust +# Run in a CultLib clone at the revision under test: stage, then probe. For csharp, C2aGateWriter first writes the live +# v3 and v5 stores into $GATE_OUT (default /tmp/gate) and the 2.0.1 probes read them beside the committed vectors; the +# 1.0.60 writer then writes a ref-list store there, 2.0.1 and the tree under test both open it, and their REFLIST-SUMMARY +# lines must be identical. set -euo pipefail + +here="tests/vectors/document-variants-c2a" +self="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")" +OLD_READER_REV_CSHARP="${OLD_READER_REV_CSHARP:-b8fc5eb0}" # cultlib-unity-v2.0.1 +OLD_READER_REV_LEAF="${OLD_READER_REV_LEAF:-02cf04b5}" # cultcache-rs-v0.3.1 +OLD_WRITER_REV="${OLD_WRITER_REV:-45c2f400}" # cultlib-unity-v1.0.60 + +usage() { echo "usage: $0 stage|probe|gate csharp|typescript|python|rust (writer for stage and probe)" >&2; exit 2; } + +revision_for() { + case "$1" in + csharp) echo "$OLD_READER_REV_CSHARP" ;; + typescript|python|rust) echo "$OLD_READER_REV_LEAF" ;; + writer) echo "$OLD_WRITER_REV" ;; + *) usage ;; + esac +} + +stage() { + local runtime="$1" root rev old sha + root="$(git rev-parse --show-toplevel)" + rev="$(revision_for "$runtime")" + old="$(mktemp -d)/old" + git -C "$root" worktree add --detach "$old" "$rev" >/dev/null + mkdir -p "$old/$here" + case "$runtime" in + csharp) + cp "$root/$here/c2a-written-ids.msgpack" "$old/$here/" + cp -r "$root/$here/c2a-written-ids-directory" "$old/$here/" + cp "$root/$here/old-reader-probes/OldReaderGateProbe.cs" "$root/$here/old-reader-probes/OldReaderRefListProbe.cs" "$old/tests/GameCult.Caching.Tests/" ;; + typescript) + cp "$root/$here/c2a-written-ids.msgpack" "$old/$here/" + cp "$root/$here/old-reader-probes/old-reader-v3.test.ts" "$old/packages/cultcache-ts/test/" ;; + python) + cp "$root/$here/c2a-written-ids.msgpack" "$old/$here/" + cp "$root/$here/old-reader-probes/test_old_reader_v3.py" "$old/packages/cultcache-py/tests/" ;; + rust) + cp "$root/$here/c2a-written-ids.msgpack" "$old/$here/" + mkdir -p "$old/packages/cultcache-rs/tests" + cp "$root/$here/old-reader-probes/old_reader_v3.rs" "$old/packages/cultcache-rs/tests/" ;; + writer) + cp "$root/$here/old-writer-probes/OldWriterRefListProbe.cs" "$old/tests/GameCult.Caching.Tests/" ;; + esac + git -C "$old" add -A + git -C "$old" -c user.name=old-reader-refusal -c user.email=old-reader-refusal@localhost commit -q -m "$runtime probe over $rev" + sha="$(git -C "$old" rev-parse HEAD)" + git -C "$root" update-ref "refs/old-reader-probe/$sha" "$sha" + git -C "$root" worktree remove --force "$old" + echo "$sha" +} + +probe() { + case "$1" in + csharp) CULTLIB_ROOT="$PWD" dotnet test tests/GameCult.Caching.Tests --filter "FullyQualifiedName~OldReaderGateProbe|FullyQualifiedName~OldReaderRefListProbe" --logger "console;verbosity=detailed" ;; + writer) dotnet test tests/GameCult.Caching.Tests --filter FullyQualifiedName~OldWriterRefListProbe --logger "console;verbosity=detailed" ;; + typescript) + npm install --no-audit --no-fund >/dev/null 2>&1 + cd packages/cultcache-ts + node ../../node_modules/typescript/bin/tsc -p tsconfig.json + node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json + node --test dist-test/test/old-reader-v3.test.js ;; + python) PYTHONPATH=packages/cultcache-py/src python3 packages/cultcache-py/tests/test_old_reader_v3.py -v ;; + rust) cd packages/cultcache-rs && cargo test --test old_reader_v3 -- --nocapture ;; + *) usage ;; + esac +} + +# Stage a runtime's probes, then run them in a worktree of the staged commit. +probe_staged() { + local sha tree + sha="$(stage "$1")" + tree="$(mktemp -d)/staged" + git worktree add --detach "$tree" "$sha" >/dev/null + (cd "$tree" && "$self" probe "$1") + git worktree remove --force "$tree" +} + +summary_of() { grep -o 'REFLIST-SUMMARY .*' "$1" | sort -u; } + +gate() { + case "$1" in + csharp) + local out="${GATE_OUT:-/tmp/gate}" + rm -rf "$out"; mkdir -p "$out" + export CULTLIB_GATE_OUT="$out" + dotnet test tests/GameCult.Caching.Tests --filter FullyQualifiedName~C2aGateWriter --logger "console;verbosity=detailed" + probe_staged writer + probe_staged csharp 2>&1 | tee "$out/reader.log" + CULTLIB_GATE_OLD_STORE="$out/v1-0-60-ref-list.cc" dotnet test tests/GameCult.Caching.Tests \ + --filter "FullyQualifiedName~RefListDriftTests.A_1_0_60_Written_Store_Opens_Like_2_0_1" --logger "console;verbosity=detailed" 2>&1 | tee "$out/head.log" + summary_of "$out/reader.log" > "$out/reader.summary" + summary_of "$out/head.log" > "$out/head.summary" + test "$(wc -l < "$out/reader.summary")" -eq 1 && test "$(wc -l < "$out/head.summary")" -eq 1 + cat "$out/reader.summary" "$out/head.summary" + diff "$out/reader.summary" "$out/head.summary" && echo "GATE csharp: the 2.0.1 and head REFLIST-SUMMARY lines are identical" ;; + typescript|python|rust) probe_staged "$1" ;; + *) usage ;; + esac +} + case "${1:-}" in - stage) - root="$(git rev-parse --show-toplevel)" - here="tests/vectors/document-variants-c2a" - old="$(mktemp -d)/old" - git -C "$root" worktree add --detach "$old" "${OLD_READER_REV:-e382bb4}" >/dev/null - mkdir -p "$old/$here" - cp "$root/$here/v3-base.msgpack" "$old/$here/" - cp "$root/$here/old-reader-probes/OldReaderV3Probe.cs" "$old/tests/GameCult.Caching.Tests/" - cp "$root/$here/old-reader-probes/old-reader-v3.test.ts" "$old/packages/cultcache-ts/test/" - cp "$root/$here/old-reader-probes/test_old_reader_v3.py" "$old/packages/cultcache-py/tests/" - mkdir -p "$old/packages/cultcache-rs/tests" - cp "$root/$here/old-reader-probes/old_reader_v3.rs" "$old/packages/cultcache-rs/tests/" - git -C "$old" add -A - git -C "$old" commit -q -m "old-reader probe over ${OLD_READER_REV:-e382bb4}" - sha="$(git -C "$old" rev-parse HEAD)" - git -C "$root" update-ref "refs/old-reader-probe/$sha" "$sha" - git -C "$root" worktree remove --force "$old" - echo "$sha" ;; - probe) - case "${2:-}" in - csharp) CULTLIB_ROOT="$PWD" dotnet test tests/GameCult.Caching.Tests --filter FullyQualifiedName~OldReaderV3Probe --logger "console;verbosity=detailed" ;; - typescript) - npm install --no-audit --no-fund >/dev/null 2>&1 - cd packages/cultcache-ts - node ../../node_modules/typescript/bin/tsc -p tsconfig.json - node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json - node --test dist-test/test/old-reader-v3.test.js ;; - python) PYTHONPATH=packages/cultcache-py/src python3 packages/cultcache-py/tests/test_old_reader_v3.py -v ;; - rust) cd packages/cultcache-rs && cargo test --test old_reader_v3 -- --nocapture ;; - *) echo "usage: $0 probe csharp|typescript|python|rust" >&2; exit 2 ;; - esac ;; - *) echo "usage: $0 stage | probe " >&2; exit 2 ;; + stage) stage "${2:-}" ;; + probe) probe "${2:-}" ;; + gate) gate "${2:-}" ;; + *) usage ;; esac diff --git a/tests/vectors/document-variants-c2a/old-writer-probes/OldWriterRefListProbe.cs b/tests/vectors/document-variants-c2a/old-writer-probes/OldWriterRefListProbe.cs new file mode 100644 index 00000000..a5124c4c --- /dev/null +++ b/tests/vectors/document-variants-c2a/old-writer-probes/OldWriterRefListProbe.cs @@ -0,0 +1,63 @@ +#nullable enable +using System; +using System.IO; +using System.Threading.Tasks; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + // Copied into a checkout of cultlib-unity-v1.0.60 (45c2f400) by old-reader-refusal.sh. Not part of this tree's suite. + // It writes the store the 1.0.x writer produces for a list of record references (the shapes are RefListDriftTests'), and + // is the only author of tests/vectors/ref-list-drift/v1-0-60-written.cc. + public class OldWriterRefListProbe + { + [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>(); + } + + [Test] + public async Task WritesAStoreWhoseRefListSlotIsAPlainValue() + { + var output = Environment.GetEnvironmentVariable("CULTLIB_GATE_OUT")!; + Directory.CreateDirectory(output); + var file = Path.Combine(output, "v1-0-60-ref-list.cc"); + File.Delete(file); + + using var cache = new CultCache(); + var store = new SingleFileMessagePackBackingStore(file); + cache.AddBackingStore(store); + await cache.AddAsync(new RefListTarget { Name = "alpha" }); + await cache.AddAsync(new RefListTarget { Name = "beta" }); + await cache.AddAsync(new RefListHolder + { + Name = "list", + Items = new[] { new CultRecordRef(new CultRecordKey("alpha")), new CultRecordRef(new CultRecordKey("beta")) } + }); + store.PushAll(); + + var header = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(file)).FormatVersion; + Assert.That(header, Is.EqualTo("cultcache.store.v1")); + TestContext.Out.WriteLine("OLD-WRITER 1.0.60 wrote " + file + " as " + header); + } + } +}