using Godosa.Core.Runs.Scripts; using Xunit; namespace Godosa.Core.Tests.Runs.Scripts; public sealed class CheckpointCacheTests : IDisposable { private readonly string _dir = Path.Combine(AppContext.BaseDirectory, "rs-tmp", Guid.NewGuid().ToString("N")); public void Dispose() { if (Directory.Exists(_dir)) Directory.Delete(_dir, recursive: true); } private (ScriptRunner Runner, RunResult Result, List Output) Run(string main, FakeGame g, ScriptRunOptions? o = null, Dictionary? files = null) { var all = new Dictionary(files ?? []) { ["main.steps"] = main }; var output = new List(); var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(all), new Dictionary()), g, o ?? new ScriptRunOptions { CacheDir = _dir }, output.Add); return (runner, runner.Run(10_000), output); } private const string Script = "format 1\nmode player\nkey A\ncheckpoint one\nkey B\ncheckpoint two\nkey C"; // --no-snapshots: checkpoints are markers only — no digest (a whole-world serialization: seconds late in a big game, // the window froze), nothing cached; a later run resumes from nothing. [Fact] public void NoSnapshots_CheckpointsNeitherDigestNorCache() { var g = new FakeGame(); var (r, res, output) = Run(Script, g, new ScriptRunOptions { CacheDir = _dir, Snapshots = false }); Assert.Equal(RunOutcome.Passed, res.Outcome); Assert.Equal(0, g.Digests); Assert.Equal(["marker", "marker"], r.Report!.Checkpoints.Select(c => c.Source)); Assert.Contains("checkpoint two f=4", output); Assert.False(Directory.Exists(_dir) && Directory.EnumerateFiles(_dir, "*", SearchOption.AllDirectories).Any()); var (_, stopped, _) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir, Snapshots = false, Until = "one" }); Assert.Equal((RunOutcome.Stopped, 2L), (stopped.Outcome, stopped.Frames)); } [Fact] public void SecondRun_ResumesAtTheLastCachedCheckpoint() { var first = new FakeGame(); first.OnAdvance = f => first.State += 1; var (r1, _, _) = Run(Script, first); Assert.Equal(6, r1.Report!.Frames); Assert.Equal(["replayed", "replayed"], r1.Report.Checkpoints.Select(c => c.Source)); var second = new FakeGame(); second.OnAdvance = f => second.State += 1; var (r2, res, output) = Run(Script, second); Assert.Equal(RunOutcome.Passed, res.Outcome); Assert.Contains("resumed at two (cache) f=4", output); Assert.Equal(["C"], second.Sent.Where(s => s.Event.Kind == InputKind.KeyDown).Select(s => s.Event.Key)); Assert.Equal((6L, 6), (res.Frames, second.State)); // restored frame 4 / state 4, key C = 2 frames → 6 / 6 Assert.Equal(1, r2.Report!.CacheHits); } // A window restore rebuilds the game view after the frame: the runner pauses (Restoring) for that frame, no game frame // passes, and the run ends as an immediate restore would. [Fact] public void DeferredRestore_PausesOneFrame_SameResult() { var first = new FakeGame(); first.OnAdvance = f => first.State += 1; Run(Script, first); var second = new FakeGame { RestoreDeferred = true }; second.OnAdvance = f => second.State += 1; var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary { ["main.steps"] = Script }), new Dictionary()), second, new ScriptRunOptions { CacheDir = _dir }, _ => { }); runner.BeforeFrame(); Assert.True(runner.Restoring); Assert.Empty(second.Sent); var third = new FakeGame { RestoreDeferred = true }; third.OnAdvance = f => third.State += 1; var (r3, res, _) = Run(Script, third); Assert.False(r3.Restoring); Assert.Equal((6L, 6), (res.Frames, third.State)); // the paused frame advanced nothing } [Fact] public void ChangedStepBeforeACheckpoint_InvalidatesIt_LaterStepsDoNot() { Run(Script, new FakeGame()); var (r, _, output) = Run(Script.Replace("key B", "key X"), new FakeGame()); Assert.Contains("resumed at one (cache) f=2", output); var (r3, _, output3) = Run(Script.Replace("key C", "key Y"), new FakeGame()); Assert.Contains("resumed at two (cache) f=4", output3); } [Fact] public void CommentEdits_KeepTheCache() { Run(Script, new FakeGame()); var (_, _, output) = Run("# new comment\n" + Script.Replace("key A", "key A # aim"), new FakeGame()); Assert.Contains("resumed at two (cache) f=4", output); } [Fact] public void SeedContentOrVars_InvalidateEverything() { Run(Script, new FakeGame()); var (_, _, output) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir, Seed = 5 }); Assert.DoesNotContain(output, l => l.StartsWith("resumed")); var (_, _, output2) = Run(Script, new FakeGame { ContentVersion = "c2" }); Assert.DoesNotContain(output2, l => l.StartsWith("resumed")); } [Fact] public void NoCache_ReplaysAndWarnsOnDigestMismatch() { Run(Script, new FakeGame()); var drift = new FakeGame { State = 100 }; var (r, _, output) = Run(Script, drift, new ScriptRunOptions { CacheDir = _dir, UseCache = false }); Assert.DoesNotContain(output, l => l.StartsWith("resumed")); Assert.Contains(r.Report!.Warnings, w => w.StartsWith("checkpoint one digest differs from cache")); // Cache replaced: a cached run now resumes from the new state. var after = new FakeGame(); Run(Script, after); Assert.Equal(100, after.State); } [Fact] public void From_RequiresACachedCheckpoint_Until_StopsEarly() { var (_, missing, out1) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir, From = "two" }); Assert.Equal((RunOutcome.Invalid, 2), (missing.Outcome, missing.ExitCode)); Assert.Contains("checkpoint two not cached", out1); var (_, unknown, out2) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir, From = "nope" }); Assert.Contains("unknown checkpoint nope", out2); var g = new FakeGame(); var (_, stopped, _) = Run(Script, g, new ScriptRunOptions { CacheDir = _dir, Until = "one" }); Assert.Equal((RunOutcome.Stopped, 0, 2L), (stopped.Outcome, stopped.ExitCode, stopped.Frames)); var (_, from, outFrom) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir, From = "one" }); Assert.Equal(RunOutcome.Passed, from.Outcome); Assert.Contains("resumed at one (cache) f=2", outFrom); } // Review focus 3: a checkpoint inside an include + def call restores vars, output level and labels. [Fact] public void Resume_InsideIncludeAndCall_RestoresScopes() { var files = new Dictionary { ["ch.steps"] = "format 1\noutput steps\ndef d(who):\n set inner = ${who}-x\n checkpoint deep\n log \"${who} ${inner} ${top}\"\nstep s:\n call d(gil)\n key Q", }; const string main = "format 1\nmode player\nset top = T\ninclude ch.steps\nkey Z"; var (_, _, replay) = Run(main, new FakeGame(), files: files); var (_, _, resumed) = Run(main, new FakeGame(), files: files); Assert.Contains("resumed at deep (cache) f=0", resumed); string[] Tail(List o) => [.. o.Where(l => !l.StartsWith("resumed") && !l.StartsWith("checkpoint") && !l.StartsWith("digest"))]; Assert.Equal(Tail(replay), Tail(resumed)); Assert.Contains("gil gil-x T", resumed); Assert.Contains(resumed, l => l.StartsWith("[ch.steps:9 s] key Q done")); // output level steps + label restored } [Fact] public void Eviction_KeepsTotalUnderTheCap() { var cache = new CheckpointCache(_dir, capBytes: 300); var state = new RunnerState(0, 0, [], []); for (var i = 0; i < 5; i++) { cache.Save($"k{i}", new CachedCheckpoint(state, new byte[100])); File.SetLastWriteTimeUtc(Path.Combine(_dir, $"k{i}.snap"), new DateTime(2026, 1, 1, 0, 0, i, DateTimeKind.Utc)); } var left = Directory.GetFiles(_dir, "*.snap").Select(Path.GetFileNameWithoutExtension).Order().ToList(); Assert.True(Directory.GetFiles(_dir, "*.snap").Sum(f => new FileInfo(f).Length) <= 300); Assert.Contains("k4", left); Assert.DoesNotContain("k0", left); } [Fact] public void NoSnapshotSupport_NoCache() { var (r, _, _) = Run(Script, new FakeGame { SupportsSnapshot = false }); Assert.False(Directory.Exists(_dir) && Directory.GetFiles(_dir).Length > 0); Assert.Equal(0, r.Report!.CacheMisses); } }