aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/src/Godosa.Core.Tests/Runs/Scripts/CheckpointCacheTests.cs
diff options
context:
space:
mode:
authorgodosa <godosa@godosa.eu>2026-10-06 23:39:36 +0200
committergodosa <godosa@godosa.eu>2026-10-06 23:39:36 +0200
commit39066900773e7857faf2d02e7ed51b71d219d97d (patch)
treeffab8e4ddd971626776c3b1f4ce1da2c186c2187 /src/Godosa.Core.Tests/Runs/Scripts/CheckpointCacheTests.cs
downloadgodosa-engine-39066900773e7857faf2d02e7ed51b71d219d97d.tar.gz
godosa-engine-39066900773e7857faf2d02e7ed51b71d219d97d.zip
godosa-engine: initial public history
Diffstat (limited to 'src/Godosa.Core.Tests/Runs/Scripts/CheckpointCacheTests.cs')
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/CheckpointCacheTests.cs185
1 files changed, 185 insertions, 0 deletions
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/CheckpointCacheTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/CheckpointCacheTests.cs
new file mode 100644
index 0000000..a84cf94
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/CheckpointCacheTests.cs
@@ -0,0 +1,185 @@
+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<string> Output) Run(string main, FakeGame g, ScriptRunOptions? o = null, Dictionary<string, string>? files = null)
+ {
+ var all = new Dictionary<string, string>(files ?? []) { ["main.steps"] = main };
+ var output = new List<string>();
+ var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(all), new Dictionary<string, string>()), 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<string, string> { ["main.steps"] = Script }),
+ new Dictionary<string, string>()), 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<string, string>
+ {
+ ["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<string> 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);
+ }
+}