Union golden: the store's clock is an internal seam, the writer pins it, the gate test runs under any switch
CultCache mints storedAt from an internal Clock (default the wall clock); the vector writer pins it, so union-surface.cc is regenerated in process and byte-compared on every run. The gate test sets the switch itself and writes only a probe, so it neither skips nor touches a committed vector. This commit does not pass: union-surface.cc still holds the old wall-clock stored-at; the next commit regenerates it. Builds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e5cfb10674
commit
b4edbb1eeb
5 changed files with 116 additions and 18 deletions
|
|
@ -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<DateTimeOffset> 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)
|
||||
|
|
|
|||
|
|
@ -8,5 +8,6 @@
|
|||
<PackageReference Include="MessagePack.Annotations" Version="3.1.7" />
|
||||
<PackageReference Include="R3" Version="1.3.0" />
|
||||
<PackageReference Include="ConcurrentHashSet " Version="1.3.0" />
|
||||
<InternalsVisibleTo Include="GameCult.Caching.Tests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
69
tests/GameCult.Caching.Tests/CultCacheClockTests.cs
Normal file
69
tests/GameCult.Caching.Tests/CultCacheClockTests.cs
Normal file
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AssertionException>(() => Check("union-surface.msgpack", new byte[] { 0x90 }), "bytes that differ from the vector fail the run");
|
||||
Assert.Throws<AssertionException>(() => 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<AssertionException>(() => 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<AssertionException>(() => 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue