diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 3de040b9..a10685a2 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -2337,9 +2337,12 @@ namespace GameCult.Caching throw new ArgumentException($"Expect({key.Value}) was given an instance this cache does not hold at that key.", nameof(current)); } - private static string MintStoredAt(string? previous) + // The wall clock behind every storedAt. Internal: a test that writes a golden store pins it, so the bytes reproduce. + internal Func Clock { get; set; } = () => DateTimeOffset.UtcNow; + + private string MintStoredAt(string? previous) { - var now = DateTimeOffset.UtcNow; + var now = Clock(); if (previous != null && DateTimeOffset.TryParse(previous, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var last) && now <= last) diff --git a/src/GameCult.Caching/GameCult.Caching.csproj b/src/GameCult.Caching/GameCult.Caching.csproj index a34cdfd3..7bc41ccc 100644 --- a/src/GameCult.Caching/GameCult.Caching.csproj +++ b/src/GameCult.Caching/GameCult.Caching.csproj @@ -8,5 +8,6 @@ + \ No newline at end of file diff --git a/tests/GameCult.Caching.Tests/CultCacheClockTests.cs b/tests/GameCult.Caching.Tests/CultCacheClockTests.cs new file mode 100644 index 00000000..c93b91fd --- /dev/null +++ b/tests/GameCult.Caching.Tests/CultCacheClockTests.cs @@ -0,0 +1,69 @@ +#nullable enable +using System; +using System.Globalization; +using System.IO; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + // The clock behind every storedAt: the wall clock by default, and a pinned one when a golden store has to reproduce. + public class CultCacheClockTests + { + private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(UnionParityVectorTests.UnionSurface) }); + private static readonly CultRecordKey Key = new("surface"); + + private string _directory = string.Empty; + + [SetUp] + public void CreateDirectory() + { + _directory = Path.Combine(Path.GetTempPath(), $"cultlib-clock-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_directory); + } + + [TearDown] + public void DeleteDirectory() + { + if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true); + } + + private CultCache Open() => CultCacheMessagePack.Create(Path.Combine(_directory, "clock.cc"), new CultCacheOpenOptions { Registry = Registry }); + + private static string Upsert(CultCache cache) + { + cache.UpsertAsync(typeof(UnionParityVectorTests.UnionSurface), new UnionParityVectorTests.UnionSurface(), Key).GetAwaiter().GetResult(); + return cache.GetStored(Key)!.StoredAt; + } + + [Test] + public void ADefaultCacheStampsTheWallClock() + { + using var cache = Open(); + var before = DateTimeOffset.UtcNow; + var stamp = DateTimeOffset.Parse(Upsert(cache), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); + var after = DateTimeOffset.UtcNow; + Assert.That(stamp, Is.InRange(before, after)); + } + + [Test] + public void APinnedClockIsTheStoredAt() + { + var pinned = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + using var cache = Open(); + cache.Clock = () => pinned; + Assert.That(Upsert(cache), Is.EqualTo("2026-01-01T00:00:00.0000000+00:00")); + } + + [Test] + public void APinnedClockStillIncreasesStoredAtPerKey() + { + var pinned = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + using var cache = Open(); + cache.Clock = () => pinned; + Upsert(cache); + Assert.That(Upsert(cache), Is.EqualTo("2026-01-01T00:00:00.0000001+00:00"), "the same instant again is one tick later"); + } + } +} diff --git a/tests/GameCult.Caching.Tests/UnionParityVectorTests.cs b/tests/GameCult.Caching.Tests/UnionParityVectorTests.cs index 381024c7..a710a96b 100644 --- a/tests/GameCult.Caching.Tests/UnionParityVectorTests.cs +++ b/tests/GameCult.Caching.Tests/UnionParityVectorTests.cs @@ -20,6 +20,9 @@ namespace GameCult.Caching.Tests private const string WriteVariable = "CULTCACHE_WRITE_VECTORS"; private const string Canary = "CANARY-union-golden-4c2a"; + // The stored-at of the one record in union-surface.cc: the store's clock is pinned to it, so the store reproduces. + private static readonly DateTimeOffset StoredAt = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(UnionSurface) }); private static bool Writing => Environment.GetEnvironmentVariable(WriteVariable) == "1"; @@ -329,17 +332,15 @@ namespace GameCult.Caching.Tests try { var scratch = Path.Combine(work, "union-surface.cc"); - if (Writing) + using (var cache = CultCacheMessagePack.Create(scratch, new CultCacheOpenOptions { Registry = Registry, StoreFlushOnDispose = true })) { - using (var cache = CultCacheMessagePack.Create(scratch, new CultCacheOpenOptions { Registry = Registry, StoreFlushOnDispose = true })) - { - cache.UpsertAsync(typeof(UnionSurface), value, new CultRecordKey("surface")).GetAwaiter().GetResult(); - cache.FlushAsync().GetAwaiter().GetResult(); - } - - Check("union-surface.cc", File.ReadAllBytes(scratch)); + cache.Clock = () => StoredAt; + cache.UpsertAsync(typeof(UnionSurface), value, new CultRecordKey("surface")).GetAwaiter().GetResult(); + cache.FlushAsync().GetAwaiter().GetResult(); } + Check("union-surface.cc", File.ReadAllBytes(scratch)); + // The committed store: one record whose payload is the committed payload, holding the constructed value, and rewritten unchanged. var store = Committed("union-surface.cc"); var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(store); @@ -428,19 +429,42 @@ namespace GameCult.Caching.Tests } } - [Test] + // Runs whatever the switch reads when the test starts: it sets the switch itself, and writes only a probe file. + [Test, NonParallelizable] public void VectorsNotRewrittenByDefault() { - if (Writing) Assert.Ignore($"{WriteVariable}=1 is the one way to write the vectors."); - string Fingerprint() => string.Join("|", Directory.EnumerateFiles(VectorDirectory()).OrderBy(f => f, StringComparer.Ordinal) .Select(f => Path.GetFileName(f) + "=" + Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(f))))); + const string probe = "gate-probe.msgpack"; + var path = VectorPath(probe); var before = Fingerprint(); - Assert.Throws(() => Check("union-surface.msgpack", new byte[] { 0x90 }), "bytes that differ from the vector fail the run"); - Assert.Throws(() => Check("not-a-vector.msgpack", new byte[] { 0x90 }), "a missing vector fails the run"); - Assert.That(Fingerprint(), Is.EqualTo(before), "an ordinary run writes nothing"); - Assert.That(File.Exists(VectorPath("not-a-vector.msgpack")), Is.False); + var inherited = Environment.GetEnvironmentVariable(WriteVariable); + try + { + foreach (var off in new string?[] { null, string.Empty, "0", "true", "11" }) + { + Environment.SetEnvironmentVariable(WriteVariable, off); + Assert.Throws(() => Check(probe, new byte[] { 0x90 }), $"a missing vector fails the run, switch {off ?? "unset"}"); + Assert.That(File.Exists(path), Is.False, $"switch {off ?? "unset"} writes nothing"); + + File.WriteAllBytes(path, new byte[] { 0x91 }); + Assert.Throws(() => Check(probe, new byte[] { 0x90 }), $"bytes that differ from the vector fail the run, switch {off ?? "unset"}"); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(new byte[] { 0x91 }), $"switch {off ?? "unset"} rewrites nothing"); + File.Delete(path); + } + + Environment.SetEnvironmentVariable(WriteVariable, "1"); + Check(probe, new byte[] { 0x90 }); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(new byte[] { 0x90 }), "1 is the one way to write a vector"); + } + finally + { + Environment.SetEnvironmentVariable(WriteVariable, inherited); + if (File.Exists(path)) File.Delete(path); + } + + Assert.That(Fingerprint(), Is.EqualTo(before), "no committed vector changed"); } } } diff --git a/tests/vectors/union-parity/README.md b/tests/vectors/union-parity/README.md index 04499e04..277c15ad 100644 --- a/tests/vectors/union-parity/README.md +++ b/tests/vectors/union-parity/README.md @@ -24,7 +24,8 @@ Composition is a record, not an arm: `[Name, Body: Node]`. CraftedItem field) is Part `gear`; Missing is nil. A union inside a record inside a union, a unit arm, a list of unions with a nil element, an inner union keyed apart from its outer. Written by C#. - `union-surface.cc`: a single-file store written by the C# store holding that one record under key `surface`. Its - payload equals `union-surface.msgpack`; rewriting the loaded store is byte-identical. + payload equals `union-surface.msgpack`; rewriting the loaded store is byte-identical. The writer pins the store's + clock, so the record's stored-at is `2026-01-01T00:00:00.0000000+00:00` and the whole file reproduces byte for byte. - `outcomes.msgpack`: the expected outcome of every file below, as data. An array of entries `[file, outcome, union, key, detail]`: `outcome` is `refused` or `read-only`; `union` is the union (or record) named; `key` is the arm key or nil; `detail` is as in the tables below.