1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
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);
}
}
|