diff options
| author | godosa <godosa@godosa.eu> | 2026-10-06 23:39:36 +0200 |
|---|---|---|
| committer | godosa <godosa@godosa.eu> | 2026-10-06 23:39:36 +0200 |
| commit | 39066900773e7857faf2d02e7ed51b71d219d97d (patch) | |
| tree | ffab8e4ddd971626776c3b1f4ce1da2c186c2187 /src/Godosa.Core.Tests/Runs/Scripts/CheckpointCacheTests.cs | |
| download | godosa-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.cs | 185 |
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); + } +} |
