Brokkr/brokkr-contracts.Tests/BrokkrCommandLedgerTests.cs
Meta Vens 121fa329e2 Soul pass 3: conditional enable in Runtime, receipt floor, one write path
- The enable commit is conditional (ExpectUnchanged) and lives in the new
  Runtime BrokkrCommandStore, with the pull-recompute-retry loop in
  BrokkrCommandDrain.EnableAsync, so the tests exercise the shipped code. An
  intent landing between the pull and the commit is expired, not run; eight
  refusals in a row fail the enable loudly with the sink off. The probe is now
  a thin fault-injecting wrapper over the real store, not a copy of it.
- Every editor write to the command lane is one cache commit; durable shard
  logs are off (no server, no reader).
- No receipt is pruned before ten minutes, whatever the count, and the CLI
  refuses a wait over five minutes. Fixed the comments that claimed otherwise.
- Pending and Prunable count a document only under the key its own id names.
- README and snapshot comment: the token stops stale EditorPrefs, it does not
  authenticate writers; a restored or sync-conflict copy of the editor's own
  store can rerun answered intents.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 17:52:34 +02:00

185 lines
7.3 KiB
C#

using GameCult.Brokkr;
using GameCult.Caching;
namespace Brokkr.Contracts.Tests;
public sealed class BrokkrCommandLedgerTests
{
internal static async Task Answer(CultCache cache, string commandId)
{
await cache.AddAsync(
new BrokkrUnityCommandReceipt { commandId = commandId, status = "accepted" },
new CultRecordHandle<BrokkrUnityCommandReceipt>(BrokkrCommandLedger.ReceiptKey(commandId)));
await cache.FlushAsync();
}
[Fact]
public async Task ExternallyWrittenCommandIsPendingAfterPull()
{
using var project = new ScratchProject();
using var editor = project.OpenCache();
var ledger = new BrokkrCommandLedger(editor);
Assert.False(ledger.TryNextPending(out _));
await project.WriteCommandAsync("written-elsewhere");
Assert.False(ledger.TryNextPending(out _), "an intent is invisible until the editor pulls the store");
await ledger.PullAsync();
Assert.True(ledger.TryNextPending(out var pending));
Assert.Equal("written-elsewhere", pending.commandId);
}
[Fact]
public async Task CommandWithReceiptIsNeverPendingAgain()
{
using var project = new ScratchProject();
await project.WriteCommandAsync("once");
using var editor = project.OpenCache();
var ledger = new BrokkrCommandLedger(editor);
Assert.True(ledger.TryNextPending(out _));
await Answer(editor, "once");
Assert.False(ledger.TryNextPending(out _));
await ledger.PullAsync();
Assert.False(ledger.TryNextPending(out _));
}
[Fact]
public async Task RewrittenCommandIdIsNotReExecuted()
{
// Command ids are single-use: a receipt retires the id, and a later intent written under it is a caller
// defect that the editor ignores. Callers that mean a new act mint a new id.
using var project = new ScratchProject();
await project.WriteCommandAsync("reused", "refreshAssets");
using var editor = project.OpenCache();
var ledger = new BrokkrCommandLedger(editor);
Assert.True(ledger.TryNextPending(out _));
await Answer(editor, "reused");
await project.WriteCommandAsync("reused", "setEditorPaused");
await ledger.PullAsync();
Assert.False(ledger.TryNextPending(out _));
}
[Fact]
public async Task FreshIdAfterAnAnsweredOneIsPending()
{
using var project = new ScratchProject();
await project.WriteCommandAsync("first");
using var editor = project.OpenCache();
var ledger = new BrokkrCommandLedger(editor);
await Answer(editor, "first");
await project.WriteCommandAsync("second", "setEditorPaused");
await ledger.PullAsync();
Assert.True(ledger.TryNextPending(out var pending));
Assert.Equal("second", pending.commandId);
}
[Fact]
public async Task ReceiptWrittenByAnotherProcessRetiresTheCommand()
{
using var project = new ScratchProject();
await project.WriteCommandAsync("answered-elsewhere");
using var editor = project.OpenCache();
var ledger = new BrokkrCommandLedger(editor);
Assert.True(ledger.TryNextPending(out _));
using (var other = project.OpenCache())
await Answer(other, "answered-elsewhere");
await ledger.PullAsync();
Assert.False(ledger.TryNextPending(out _));
}
[Fact]
public async Task PendingOrderIsArrivalOrder()
{
using var project = new ScratchProject();
// Written b then a: stored-at order, not key order, decides.
await project.WriteCommandAsync("b-first");
await Task.Delay(20);
await project.WriteCommandAsync("a-second");
using var editor = project.OpenCache();
var ledger = new BrokkrCommandLedger(editor);
Assert.True(ledger.TryNextPending(out var first));
Assert.Equal("b-first", first.commandId);
await Answer(editor, "b-first");
Assert.True(ledger.TryNextPending(out var second));
Assert.Equal("a-second", second.commandId);
}
[Fact]
public async Task CommandWithoutAnIdIsNeverPending()
{
using var project = new ScratchProject();
await project.WriteCommandAsync(" ");
using var editor = project.OpenCache();
Assert.False(new BrokkrCommandLedger(editor).TryNextPending(out _));
}
[Fact]
public async Task TwoLedgersOverOneStoreExecuteEachIdOnce()
{
// The Unity double drain, at the layer where the rule lives. Two editors take turns over one store; each
// pulls before it looks and answers before it yields, so together they must execute each id exactly once.
// The ledger cannot serialise simultaneous drains; the service being the only owner does that.
using var project = new ScratchProject();
var ids = Enumerable.Range(0, 6).Select(index => $"cmd-{index}").ToArray();
foreach (var id in ids)
{
await project.WriteCommandAsync(id);
await Task.Delay(5);
}
using var first = project.OpenCache();
using var second = project.OpenCache();
var turns = new[]
{
(Cache: first, Ledger: new BrokkrCommandLedger(first)),
(Cache: second, Ledger: new BrokkrCommandLedger(second))
};
var executed = new List<string>();
for (var turn = 0; turn < ids.Length * 2; turn++)
{
var (cache, ledger) = turns[turn % 2];
await ledger.PullAsync();
if (!ledger.TryNextPending(out var command)) continue;
executed.Add(command.commandId);
await Answer(cache, command.commandId);
}
Assert.Equal(ids.OrderBy(id => id), executed.OrderBy(id => id));
using var audit = project.OpenCache();
Assert.Equal(ids.Length, audit.AllStoredDocuments.Count(entry => entry.Document is BrokkrUnityCommandReceipt));
}
[Fact]
public async Task DocumentUnderAKeyItsIdDoesNotNameIsNeverPending()
{
// One body id under two keys must not run twice, and a receipt under the wrong key retires nothing.
using var project = new ScratchProject();
using (var writer = project.OpenCache())
{
var command = new BrokkrUnityCommand { commandId = "dup", action = "refreshAssets" };
await writer.AddAsync(command, new CultRecordHandle<BrokkrUnityCommand>(BrokkrCommandLedger.CommandKey("dup")));
await writer.AddAsync(new BrokkrUnityCommand { commandId = "dup", action = "refreshAssets" },
new CultRecordHandle<BrokkrUnityCommand>(BrokkrCommandLedger.CommandKey("other")));
await writer.AddAsync(new BrokkrUnityCommand { commandId = "victim", action = "refreshAssets" },
new CultRecordHandle<BrokkrUnityCommand>(BrokkrCommandLedger.CommandKey("victim")));
await writer.AddAsync(new BrokkrUnityCommandReceipt { commandId = "victim", status = "accepted" },
new CultRecordHandle<BrokkrUnityCommandReceipt>(new CultRecordKey("unity/receipts/elsewhere")));
await writer.FlushAsync();
}
using var editor = project.OpenCache();
var pending = new BrokkrCommandLedger(editor).Pending(int.MaxValue);
Assert.Equal(new[] { "dup", "victim" }, pending.Select(command => command.commandId).OrderBy(id => id));
}
}