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 | |
| download | godosa-engine-39066900773e7857faf2d02e7ed51b71d219d97d.tar.gz godosa-engine-39066900773e7857faf2d02e7ed51b71d219d97d.zip | |
godosa-engine: initial public history
Diffstat (limited to 'src/Godosa.Core.Tests/Runs/Scripts')
14 files changed, 1417 insertions, 0 deletions
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ActionInputTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ActionInputTests.cs new file mode 100644 index 0000000..1b53910 --- /dev/null +++ b/src/Godosa.Core.Tests/Runs/Scripts/ActionInputTests.cs @@ -0,0 +1,47 @@ +using Godosa.Core.Runs; +using Godosa.Core.Runs.Scripts; +using Xunit; +using static Godosa.Core.Tests.Runs.Scripts.ScriptRunnerTests; + +namespace Godosa.Core.Tests.Runs.Scripts; + +/// <summary>Multi-click verbs (walk-path, fight, travel-to): a poll may hand the runner more input; the plan plays from +/// the next frame (its frame 0), the action is not polled until it is sent.</summary> +public sealed class ActionInputTests +{ + [Fact] + public void PollInput_PlaysFromTheNextFrame_ThenPollsAgain() + { + var g = new FakeGame(); + var polls = new List<int>(); + var follow = new InputPlan([new(0, new InputEvent(InputKind.KeyDown, Key: "B")), new(2, new InputEvent(InputKind.KeyUp, Key: "B"))]); + g.ActionTable["walk"] = new FakeAction(VerbKind.Player, + _ => new ActionStart(new InputPlan([new(0, new InputEvent(InputKind.KeyDown, Key: "A"))])), + n => + { + polls.Add(n); + return n == 1 ? StepStatus.Input(follow) : StepStatus.Done; + }); + var (r, output) = Run("format 1\nmode player\nwalk\nlog done", g); + + Assert.True(r.Outcome == RunOutcome.Passed, string.Join("\n", output)); + Assert.Equal(["A", "B", "B"], g.Sent.Select(s => s.Event.Key)); + var a = g.Sent[0].Frame; + Assert.Equal(a + 1, g.Sent[1].Frame); // polled after frame a → B on the next frame + Assert.Equal(a + 3, g.Sent[2].Frame); // its frame 2 + Assert.Equal([1, 2], polls); // no poll while the follow-up plan is unsent + Assert.Contains("done", output); + } + + [Fact] + public void PollInput_HalfSent_ReleasedOnFailure() + { + var g = new FakeGame(); + var follow = new InputPlan([new(0, new InputEvent(InputKind.KeyDown, Key: "B")), new(50, new InputEvent(InputKind.KeyUp, Key: "B"))]); + g.ActionTable["walk"] = new FakeAction(VerbKind.Player, _ => new ActionStart(), n => StepStatus.Input(follow)); + var (r, _) = Run("format 1\nmode player\nwalk", g, new ScriptRunOptions { StepCap = 10 }); + + Assert.Equal(RunOutcome.Failed, r.Outcome); + Assert.Equal(InputKind.KeyUp, g.Sent[^1].Event.Kind); // the held key is let go + } +} 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); + } +} diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ConditionTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ConditionTests.cs new file mode 100644 index 0000000..d14ea6a --- /dev/null +++ b/src/Godosa.Core.Tests/Runs/Scripts/ConditionTests.cs @@ -0,0 +1,63 @@ +using Godosa.Core.Runs.Scripts; +using Xunit; + +namespace Godosa.Core.Tests.Runs.Scripts; + +public class ConditionTests +{ + private sealed class Ctx : IConditionContext + { + public Dictionary<string, RunValue> Values { get; } = new() + { + ["quest(1)"] = RunValue.Of(2), ["map"] = RunValue.Of("tarant"), ["dialog-open"] = RunValue.Of(true), + ["global(gold)"] = RunValue.Of(500), ["alive"] = RunValue.Of(false), + }; + + public bool HasProbe(string name) => name is "quest" or "map" or "dialog-open" or "global" or "alive"; + + public RunValue Probe(string name, IReadOnlyList<string> args) => + Values[args.Count == 0 ? name : $"{name}({string.Join(",", args)})"]; + + public long? Constant(string name) => name switch { "accepted" => 2, "completed" => 4, _ => null }; + } + + private static bool Test(string text) => ConditionParser.Parse(text).Test(new Ctx()); + + [Theory] + [InlineData("quest(1) >= accepted", true)] + [InlineData("quest(1) = completed", false)] + [InlineData("map = \"tarant\"", true)] + [InlineData("map != \"ashbury\"", true)] + [InlineData("dialog-open", true)] + [InlineData("alive", false)] + [InlineData("not alive", true)] + [InlineData("dialog-open and global(gold) > 499", true)] + [InlineData("alive or global(gold) < 100", false)] + [InlineData("not (alive or dialog-open)", false)] + [InlineData("alive or dialog-open and quest(1) = 2", true)] // and binds tighter than or + [InlineData("global(gold) <= 500", true)] + public void Evaluates(string text, bool expected) => Assert.Equal(expected, Test(text)); + + [Fact] + public void MismatchedTypes_Fault() => + Assert.Equal("cannot compare tarant with 3", Assert.Throws<ScriptFault>(() => Test("map > 3")).Message); + + [Theory] + [InlineData("quest(1) >=")] + [InlineData("(alive")] + [InlineData("alive alive")] + [InlineData("\"open")] + public void BadSyntax_Throws(string text) => Assert.Throws<FormatException>(() => ConditionParser.Parse(text)); + + [Fact] + public void ListsProbesAndBareNames() + { + var c = ConditionParser.Parse("quest(1) >= accepted and not dialog-open"); + Assert.Equal(["quest(1)", "accepted", "dialog-open"], c.Probes().Select(p => p.Args.Count == 0 ? p.Name : $"{p.Name}({string.Join(",", p.Args)})")); + Assert.Equal(["accepted", "dialog-open"], c.Bare()); + } + + [Fact] + public void Describe_ListsProbeValues() => + Assert.Equal("quest(1)=2 map=tarant", Cond.Describe(ConditionParser.Parse("quest(1) > 3 and map = \"x\""), new Ctx())); +} diff --git a/src/Godosa.Core.Tests/Runs/Scripts/FakeGame.cs b/src/Godosa.Core.Tests/Runs/Scripts/FakeGame.cs new file mode 100644 index 0000000..8318148 --- /dev/null +++ b/src/Godosa.Core.Tests/Runs/Scripts/FakeGame.cs @@ -0,0 +1,100 @@ +using Godosa.Core.Runs.Scripts; + +namespace Godosa.Core.Tests.Runs.Scripts; + +/// <summary>Scriptable game for runner tests: probes are a dictionary, actions are lambdas, input is recorded with the +/// frame it arrived on.</summary> +public sealed class FakeGame : IRunGame, IInputRouter +{ + public long Frame { get; private set; } + public List<(long Frame, InputEvent Event)> Sent { get; } = []; + public Dictionary<string, Func<IReadOnlyList<string>, RunValue>> ProbeFns { get; } = new(); + public Dictionary<string, IRunAction> ActionTable { get; } = new(); + public Dictionary<string, (int X, int Y)> Objects { get; } = new(); + public Dictionary<string, long> ConstantTable { get; } = new() { ["accepted"] = 2 }; + public Action<long>? OnAdvance { get; set; } + public int State { get; set; } + public FakeRng? RngImpl { get; set; } = new(); + + public string Name => "fake"; + public string ContentVersion { get; set; } = "c1"; + public IInputRouter Input => this; + public IReadOnlyDictionary<string, IProbe> Probes => ProbeFns.ToDictionary(p => p.Key, p => (IProbe)new FnProbe(p.Value)); + public IReadOnlyDictionary<string, long> Constants => ConstantTable; + public IReadOnlyDictionary<string, ILocator> Locators => new Dictionary<string, ILocator> { ["obj"] = new ObjLocator(this) }; + public IReadOnlyDictionary<string, IRunAction> Actions => ActionTable; + public IRunRng? Rng => RngImpl; + public bool SupportsSnapshot { get; set; } = true; + public bool RestoreDeferred { get; set; } + + public Action<InputEvent>? OnSend { get; set; } + public bool RestoreThrows { get; set; } + public string? Refusal { get; set; } + public string? SnapshotRefusal() => Refusal; + + public void Send(InputEvent e) + { + OnSend?.Invoke(e); + Sent.Add((Frame, e)); + } + + public byte[] SnapshotSave() => BitConverter.GetBytes(State).Concat(BitConverter.GetBytes(Frame)).ToArray(); + + public void SnapshotRestore(byte[] data) + { + if (RestoreThrows) + throw new IOException("restore broke"); + State = BitConverter.ToInt32(data, 0); + Frame = BitConverter.ToInt64(data, 4); + } + + public int Digests { get; private set; } + public ulong Digest() { Digests++; return (ulong)State * 1_000_003UL + (ulong)Frame; } + public string Dump() => $"state={State} frame={Frame}"; + + public void Advance() + { + Frame++; + OnAdvance?.Invoke(Frame); + } + + private sealed class FnProbe(Func<IReadOnlyList<string>, RunValue> fn) : IProbe + { + public int MinArgs => 0; + public int MaxArgs => 4; + public RunValue Eval(IReadOnlyList<string> args) => fn(args); + } + + private sealed class ObjLocator(FakeGame g) : ILocator + { + public LocatorResult Locate(IReadOnlyList<string> args) => + g.Objects.TryGetValue(args[0], out var p) ? LocatorResult.Ok(p.X, p.Y) : LocatorResult.Fail($"no object {args[0]} on screen"); + } +} + +public sealed class FakeRng : IRunRng +{ + public List<string> Calls { get; } = []; + public void Seed(long seed) => Calls.Add($"seed {seed}"); + public void ForceNext(string kind, long value) => Calls.Add($"next {kind} {value}"); +} + +/// <summary>An action: optional input plan, then Poll() answers from a function of the frames polled so far.</summary> +public sealed class FakeAction(VerbKind kind, Func<IReadOnlyList<string>, ActionStart> begin, Func<int, StepStatus>? poll = null, + int min = 0, int max = 4) : IRunAction +{ + private int _polls; + public VerbKind Kind => kind; + public int MinArgs => min; + public int MaxArgs => max; + public List<IReadOnlyList<string>> Begun { get; } = []; + + public ActionStart Begin(IReadOnlyList<string> args) + { + Begun.Add(args); + _polls = 0; + return begin(args); + } + + public StepStatus Poll() => (poll ?? (_ => StepStatus.Done))(++_polls); +} diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ReviewFixTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ReviewFixTests.cs new file mode 100644 index 0000000..1b7257b --- /dev/null +++ b/src/Godosa.Core.Tests/Runs/Scripts/ReviewFixTests.cs @@ -0,0 +1,157 @@ +using Godosa.Core.Runs; +using Godosa.Core.Runs.Scripts; +using Xunit; +using static Godosa.Core.Tests.Runs.Scripts.ScriptRunnerTests; + +namespace Godosa.Core.Tests.Runs.Scripts; + +/// <summary>Final-review findings (lib core): each test failed before its fix.</summary> +public sealed class ReviewFixTests : 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 const string Script = "format 1\nmode player\nkey A\ncheckpoint one\nkey B\ncheckpoint two\nkey C"; + + [Fact] + public void NoArgVerb_AndEmptySet_Run() + { + var g = new FakeGame(); + g.ActionTable["close-panel"] = new FakeAction(VerbKind.Player, _ => new ActionStart(Status: StepStatus.Done), min: 0, max: 0); + var (r, output) = Run("format 1\nmode player\nset x =\nclose-panel\nlog \"[${x}]\"", g); + Assert.Equal(RunOutcome.Passed, r.Outcome); + Assert.Contains("[]", output); + } + + // A probe value (dialog text) with quotes inside a quoted argument stays text: the line is split into tokens first. + [Fact] + public void QuotedArgument_ValueWithQuotes_StaysOneToken() + { + var (r, output) = Run("format 1\nmode player\nset x = \"\\\"Hi,\\\" he said\"\nlog \"R: ${x}\"", new FakeGame()); + Assert.Equal(RunOutcome.Passed, r.Outcome); + Assert.Contains("R: \"Hi,\" he said", output); + } + + [Fact] + public void SubstitutedValues_AreNotRescanned() + { + var values = new Dictionary<string, string> { ["d"] = "$", ["e"] = "{x}", ["raw"] = "${y}" }; + Assert.Equal("${x}", VarExpander.Expand("${d}${e}", n => values.GetValueOrDefault(n), null)); + Assert.Equal("say ${y}!", VarExpander.Expand("say ${raw}!", n => values.GetValueOrDefault(n), null)); + } + + [Theory] + [InlineData("log Done:", "log", "Done:")] + [InlineData("type name:", "type", "name:")] + public void LineEndingInColon_IsAVerbUnlessABlockKeyword(string line, string verb, string args) + { + var f = ScriptParser.Parse("main.steps", $"format 1\nmode debug\n{line}"); + Assert.Empty(f.Errors); + var v = Assert.IsType<VerbNode>(f.Body.Single()); + Assert.Equal((verb, args), (v.Verb, v.Args)); + } + + [Fact] + public void ThrowingInputRouter_FailsTheRun() + { + var g = new FakeGame { OnSend = _ => throw new InvalidOperationException("router down") }; + var (r, output) = Run("format 1\nmode player\nkey R", g); + Assert.Equal(RunOutcome.Failed, r.Outcome); + Assert.Contains(output, l => l == "FAIL main.steps:3 key R: InvalidOperationException: router down"); + } + + [Fact] + public void CorruptCacheEntry_IsAMiss() + { + Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir }); + foreach (var f in Directory.GetFiles(_dir, "*.snap")) + File.WriteAllBytes(f, [.. "GRS1"u8, 3, 0, 0, 0, (byte)'{', (byte)'x', (byte)'!']); + var (r, output) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir }); + Assert.Equal(RunOutcome.Passed, r.Outcome); + Assert.DoesNotContain(output, l => l.StartsWith("resumed")); + } + + // A game state the snapshot can't hold (an open dialog) → checkpoint not cached, run unharmed. + [Fact] + public void RefusedSnapshot_CheckpointNotCached() + { + var g = new FakeGame { Refusal = "dialog open" }; + var output = new List<string>(); + var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> + { + ["main.steps"] = Script, + }), new Dictionary<string, string>()), g, new ScriptRunOptions { CacheDir = _dir }, output.Add); + Assert.Equal(RunOutcome.Passed, runner.Run(1000).Outcome); + Assert.Equal(["checkpoint one not cached: dialog open", "checkpoint two not cached: dialog open"], runner.Report!.Warnings); + Assert.False(Directory.Exists(_dir) && Directory.GetFiles(_dir, "*.snap", SearchOption.AllDirectories).Length > 0); + } + + [Fact] + public void FailingRestore_FailsTheRun() + { + Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir }); + var (r, output) = Run(Script, new FakeGame { RestoreThrows = true }, new ScriptRunOptions { CacheDir = _dir }); + Assert.Equal((RunOutcome.Failed, 1), (r.Outcome, r.ExitCode)); + Assert.Contains(output, l => l.Contains("IOException: restore broke")); + } + + [Fact] + public void UnwritableCacheAndReportDirs_WarnInsteadOfThrowing() + { + Directory.CreateDirectory(_dir); + var file = Path.Combine(_dir, "file"); + File.WriteAllText(file, "x"); + var g = new FakeGame(); + g.ProbeFns["no"] = _ => RunValue.Of(false); + var output = new List<string>(); + var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> + { + ["main.steps"] = "format 1\nmode player\ncheckpoint one\nexpect no", + }), new Dictionary<string, string>()), g, new ScriptRunOptions { CacheDir = Path.Combine(file, "cache"), ReportDir = Path.Combine(file, "report") }, output.Add); + var r = runner.Run(1000); + Assert.Equal(RunOutcome.Failed, r.Outcome); + Assert.Contains(runner.Report!.Warnings, w => w.StartsWith("checkpoint cache not written:")); + Assert.Contains(output, l => l.StartsWith("warning: report not written:")); + } + + [Fact] + public void Until_UnknownName_Invalid() + { + var (r, output) = Run(Script, new FakeGame(), new ScriptRunOptions { Until = "nowhere" }); + Assert.Equal((RunOutcome.Invalid, 2), (r.Outcome, r.ExitCode)); + Assert.Contains("unknown label or checkpoint nowhere", output); + } + + [Fact] + public void Until_ResumesOnlyBeforeIt() + { + Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir }); // caches one and two + var output = new List<string>(); + var r = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> { ["main.steps"] = Script }), + new Dictionary<string, string>()), new FakeGame(), new ScriptRunOptions { CacheDir = _dir, Until = "two" }, output.Add).Run(1000); + Assert.Contains("resumed at one (cache) f=2", output); + Assert.Equal((RunOutcome.Stopped, 4L), (r.Outcome, r.Frames)); + } + + [Fact] + public void ScriptSeedHeader_SeedsTheGame_CommandLineWins() + { + var g = new FakeGame(); + var output = new List<string>(); + var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> { ["main.steps"] = "format 1\nmode player\nseed 7\nkey A" }), + new Dictionary<string, string>()), g, new ScriptRunOptions(), output.Add); + runner.Run(1000); + Assert.Equal(["seed 7"], g.RngImpl!.Calls); + Assert.Equal(7UL, runner.Report!.Seed); + var g2 = new FakeGame(); + var runner2 = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> { ["main.steps"] = "format 1\nmode player\nseed 7\nkey A" }), + new Dictionary<string, string>()), g2, new ScriptRunOptions { Seed = 9 }, output.Add); + runner2.Run(1000); + Assert.Equal(["seed 9"], g2.RngImpl!.Calls); + } +} diff --git a/src/Godosa.Core.Tests/Runs/Scripts/RunReportTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/RunReportTests.cs new file mode 100644 index 0000000..00ee4b0 --- /dev/null +++ b/src/Godosa.Core.Tests/Runs/Scripts/RunReportTests.cs @@ -0,0 +1,83 @@ +using System.Text.Json; +using Godosa.Core.Runs; +using Godosa.Core.Runs.Scripts; +using Xunit; + +namespace Godosa.Core.Tests.Runs.Scripts; + +public sealed class RunReportTests : 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 static (ScriptRunner Runner, RunResult Result, List<string> Output) Run(string main, FakeGame g, ScriptRunOptions o, 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, output.Add); + return (runner, runner.Run(10_000), output); + } + + [Fact] + public void Failure_ReportHoldsChainStepReasonRecentDumpVars() + { + var g = new FakeGame { State = 7 }; + g.ProbeFns["quest"] = _ => RunValue.Of(1); + var (runner, _, _) = Run("format 1\nmode player\nset who = Gilbert\nkey A\ninclude act2.steps", g, new ScriptRunOptions { ReportDir = _dir }, + new() { ["act2.steps"] = "format 1\nstep talk-gilbert:\n key B\n expect quest(5) >= accepted" }); + var f = runner.Report!.Failure!; + Assert.Equal("act2.steps:4", f.At); + Assert.Equal(["main.steps:5"], f.Chain); + Assert.Equal(("expect quest(5) >= accepted", "talk-gilbert"), (f.Step, f.Label)); + Assert.Equal("expect failed: quest(5) >= accepted (quest(5)=1)", f.Reason); + Assert.Equal(["main.steps:4 key A f=2", "act2.steps:3 key B f=4"], f.Recent); + Assert.Equal("state=7 frame=4", f.Dump); + Assert.Equal("Gilbert", f.Vars["who"]); + Assert.True(File.Exists(Path.Combine(_dir, "report.txt"))); + Assert.True(File.Exists(Path.Combine(_dir, "fail.snap"))); + var json = JsonDocument.Parse(File.ReadAllText(Path.Combine(_dir, "report.json"))); + Assert.Equal("Failed", json.RootElement.GetProperty("Outcome").GetString()); + var text = File.ReadAllText(Path.Combine(_dir, "report.txt")); + Assert.Contains("main.steps:5 → act2.steps:4", text); + Assert.Contains("state=7 frame=4", text); + } + + [Fact] + public void Recent_KeepsLast20() + { + var g = new FakeGame(); + g.ProbeFns["no"] = _ => RunValue.Of(false); + var steps = string.Join("\n", Enumerable.Range(1, 25).Select(n => $"key K{n}")); + var (runner, _, _) = Run($"format 1\nmode player\n{steps}\nexpect no", g, new ScriptRunOptions()); + var recent = runner.Report!.Failure!.Recent; + Assert.Equal(20, recent.Count); + Assert.StartsWith("main.steps:8 key K6", recent[0]); + } + + [Fact] + public void Summary_StepsFramesCheckpointsDigests() + { + var g = new FakeGame { State = 3 }; + var (runner, r, output) = Run("format 1\nmode player\nkey A\ncheckpoint one\nstep s:\n key B", g, new ScriptRunOptions { Seed = 9 }); + var rep = runner.Report!; + Assert.Equal((RunOutcome.Passed, 4L, 2, 9UL), (rep.Outcome, rep.Frames, rep.Steps, rep.Seed)); + // Digest = State * 1_000_003 + Frame (FakeGame), at frame 2: 3 * 1000003 + 2 = 3000011. + Assert.Equal([new CheckpointRecord("one", 2, 3000011UL.ToString("x16"), "replayed")], rep.Checkpoints); + Assert.Contains(DigestLine.Format(2, 2 / 60.0, 3000011UL), output); + Assert.Contains(DigestLine.Format(4, 4 / 60.0, 3000013UL), output); // after the labelled step + } + + [Fact] + public void NonDeterministic_NoDigestLines_FlagInReport() + { + var (runner, _, output) = Run("format 1\nmode player\ncheckpoint one", new FakeGame(), new ScriptRunOptions { Deterministic = false }); + Assert.DoesNotContain(output, l => l.StartsWith("digest ")); + Assert.False(runner.Report!.Deterministic); + Assert.Contains("deterministic: no", ReportWriter.Text(runner.Report)); + } +} diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptLoaderTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptLoaderTests.cs new file mode 100644 index 0000000..f28f330 --- /dev/null +++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptLoaderTests.cs @@ -0,0 +1,103 @@ +using Godosa.Core.Runs.Scripts; +using Xunit; + +namespace Godosa.Core.Tests.Runs.Scripts; + +public class ScriptLoaderTests +{ + private static ScriptProgram Load(Dictionary<string, string> files, Dictionary<string, string>? vars = null) => + ScriptLoader.Load("main.steps", new MemoryScriptSource(files), vars ?? []); + + private static string Errors(ScriptProgram p) => string.Join(" | ", p.Errors.Select(e => e.ToString())); + + private static string Ops(ScriptProgram p) => string.Join(" ", p.Code.Select(i => i.Op switch + { + Op.Verb => $"{i.A}", Op.ScopeBegin => i.LocalVars ? "{L" : "{", Op.ScopeEnd => "}", Op.LabelBegin => $"[{i.A}", + Op.LabelEnd => "]", Op.TryBegin => "try(", Op.TryEnd => ")", Op.SetupBegin => "setup(", Op.SetupEnd => ")", + _ => i.Op.ToString().ToLowerInvariant(), + })); + + [Fact] + public void Flattens_IncludesDefsBlocks() + { + var p = Load(new() + { + ["main.steps"] = "format 1\nmode player\nsetup:\n cheat gold\ndef rest(h):\n key R\nstep a:\n include ch/one.steps\n call rest(8)\ncheckpoint done", + ["ch/one.steps"] = "format 1\nclick 1,1\ninclude ../common.steps", + ["common.steps"] = "format 1\ntry:\n key Q", + }); + Assert.Equal("", Errors(p)); + Assert.Equal("setup( cheat ) [a { click { try( key ) } } {L key } ] checkpoint", Ops(p)); + var tryBegin = p.Code.Single(i => i.Op == Op.TryBegin); + Assert.Equal(Op.TryEnd, p.Code[tryBegin.Jump].Op); + var q = p.Code.Single(i => i.Op == Op.Verb && i.A == "key" && i.B == "Q"); + Assert.Equal("common.steps:3", q.At.ToString()); + Assert.Equal(["main.steps:8", "ch/one.steps:3"], q.Chain.Select(c => c.ToString())); + var bind = p.Code.Single(i => i.Op == Op.ScopeBegin && i.LocalVars); + Assert.Equal([new KeyValuePair<string, string>("h", "8")], bind.Bindings); + } + + [Fact] + public void VarsFiles_PathExpandedWithCommandLine_FileDoesNotOverrideIt() + { + var p = Load(new() + { + ["main.steps"] = "format 1\nmode player\nvars ui/${res}.vars\nclick ${ui.ok}", + ["ui/hd.vars"] = "ui.ok = 10,20\nres = sd", + }, new() { ["res"] = "hd" }); + Assert.Equal("", Errors(p)); + Assert.Equal(("10,20", "hd"), (p.Vars["ui.ok"], p.Vars["res"])); + Assert.Contains("res", p.Locked); + } + + [Theory] + [InlineData("format 1\nclick 1,1", "main.steps:1: top-level file needs 'mode'")] + [InlineData("format 1\nmode player\ninclude x.steps", "main.steps:3: include not found: x.steps")] + [InlineData("format 1\nmode player\ncall nope()", "main.steps:3: unknown def nope")] + [InlineData("format 1\nmode player\ndef d(a):\n key R\ncall d()", "main.steps:5: def d takes 1 arguments, got 0")] + [InlineData("format 1\nmode player\ndef d():\n call d()\ncall d()", "main.steps:4: recursive call d")] + [InlineData("format 1\nmode player\ndef d():\n key R\ndef d():\n key Q", "main.steps:5: def d already defined")] + [InlineData("format 1\nmode player\ncheckpoint a\ncheckpoint a", "main.steps:4: duplicate checkpoint a")] + [InlineData("format 1\nmode player\nclick ${nowhere}", "main.steps:3: unresolved variable ${nowhere}")] + [InlineData("format 1\nmode player\nkey R\nsetup:\n cheat x", "main.steps:4: setup must be the first block")] + [InlineData("format 1\nmode player\ntry:\n checkpoint c", "main.steps:4: checkpoint inside try")] + public void Reports(string main, string error) => Assert.Contains(error, Errors(Load(new() { ["main.steps"] = main }))); + + [Fact] + public void IncludeCycle_NamesThePath() => + Assert.Contains("b.steps:2: include cycle: main.steps → a.steps → b.steps → a.steps", Errors(Load(new() + { + ["main.steps"] = "format 1\nmode debug\ninclude a.steps", + ["a.steps"] = "format 1\ninclude b.steps", + ["b.steps"] = "format 1\ninclude a.steps", + }))); + + [Fact] + public void IncludedFile_ModeOrSetup_Rejected() + { + var p = Load(new() + { + ["main.steps"] = "format 1\nmode debug\ninclude a.steps", + ["a.steps"] = "format 1\nmode player\nsetup:\n cheat x", + }); + Assert.Contains("a.steps:2: mode only in the top-level file", Errors(p)); + Assert.Contains("a.steps:3: setup only in the top-level file", Errors(p)); + } + + [Fact] + public void LockedVariable_SetIsAnError() => + Assert.Contains("main.steps:3: variable res is set on the command line", + Errors(Load(new() { ["main.steps"] = "format 1\nmode debug\nset res = x" }, new() { ["res"] = "hd" }))); + + [Fact] + public void DefParamsAndEarlierSets_AreKnownVariables() => + Assert.Equal("", Errors(Load(new() + { + ["main.steps"] = "format 1\nmode debug\nset n = 3\ndef d(a):\n key ${a}\n wait-frames ${n}\ncall d(R)", + }))); + + [Fact] + public void IncludedFileKeepsItsOwnFormat() => + Assert.Equal(1, Load(new() { ["main.steps"] = "format 1\nmode debug\ninclude a.steps", ["a.steps"] = "format 1\nkey R" }) + .Code.Single(i => i.Op == Op.Verb).Format); +} diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptParserTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptParserTests.cs new file mode 100644 index 0000000..352ff03 --- /dev/null +++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptParserTests.cs @@ -0,0 +1,111 @@ +using Godosa.Core.Runs.Scripts; +using Xunit; + +namespace Godosa.Core.Tests.Runs.Scripts; + +public class ScriptParserTests +{ + private static ScriptFile Parse(string text) => ScriptParser.Parse("main.steps", text); + + private static string Errors(ScriptFile f) => string.Join(" | ", f.Errors.Select(e => e.ToString())); + + [Fact] + public void Header_AndStatements() + { + var f = Parse(""" + # comment + format 1 + mode player + seed 7 + vars ui/${res}.vars + click 10,20 + wait dialog-open 2s + expect quest(1) >= accepted + set x = "a b" + include ch/one.steps + call rest(8, inn) + checkpoint done + """); + Assert.Equal("", Errors(f)); + Assert.Equal((1, RunMode.Player, 7UL), (f.Format, f.Mode, f.Seed!.Value)); + Assert.Equal(["ui/${res}.vars"], f.VarFiles); + var v = Assert.IsType<VerbNode>(f.Body[0]); + Assert.Equal(("click", "10,20", 6), (v.Verb, v.Args, v.At.Line)); + var w = Assert.IsType<WaitNode>(f.Body[1]); + Assert.Equal(("dialog-open", 120), (w.Condition, w.Timeout)); + Assert.Equal("quest(1) >= accepted", Assert.IsType<ExpectNode>(f.Body[2]).Condition); + var s = Assert.IsType<SetNode>(f.Body[3]); + Assert.Equal(("x", "a b"), (s.Name, s.Value)); + Assert.Equal("ch/one.steps", Assert.IsType<IncludeNode>(f.Body[4]).Path); + var c = Assert.IsType<CallNode>(f.Body[5]); + Assert.Equal("rest", c.Name); + Assert.Equal(["8", "inn"], c.Args); + Assert.Equal("done", Assert.IsType<CheckpointNode>(f.Body[6]).Name); + } + + [Fact] + public void Blocks_NestByIndent() + { + var f = Parse(""" + format 1 + mode debug + setup: + cheat gold 5 + step talk-virgil: + click 1,2 + try: + key R + def rest(hours): + key R + wait-frames ${hours} + key Q + """); + Assert.Equal("", Errors(f)); + var setup = Assert.IsType<BlockNode>(f.Body[0]); + Assert.Equal((BlockKind.Setup, 1), (setup.Kind, setup.Body.Count)); + var step = Assert.IsType<BlockNode>(f.Body[1]); + Assert.Equal((BlockKind.Step, "talk-virgil", 2), (step.Kind, step.Label, step.Body.Count)); + Assert.Equal(BlockKind.Try, Assert.IsType<BlockNode>(step.Body[1]).Kind); + var def = Assert.IsType<DefNode>(f.Body[2]); + Assert.Equal(("rest", 2), (def.Name, def.Body.Count)); + Assert.Equal(["hours"], def.Params); + Assert.Equal("Q", Assert.IsType<VerbNode>(f.Body[3]).Args); + } + + [Theory] + [InlineData("mode player\nclick 1,1", "main.steps:1: first line must be 'format N'")] + [InlineData("format 9\nmode player", "main.steps:1: unknown format 9 (latest 1)")] + [InlineData("format 1\nmode sneaky", "main.steps:2: mode must be player or debug")] + [InlineData("format 1\nmode debug\nclick 1,1\nseed 3", "main.steps:4: header line after steps")] + [InlineData("format 1\nmode debug\nwait dialog-open 0.01s", "main.steps:3: bad timeout '0.01s'")] + [InlineData("format 1\nmode debug\nwait 2s", "main.steps:3: wait needs a condition and a timeout")] + [InlineData("format 1\nmode debug\nstep a:\n click 1,1", "main.steps:4: indent not a multiple of 2")] + [InlineData("format 1\nmode debug\nstep a:\n\tclick 1,1", "main.steps:4: tab in indentation")] + [InlineData("format 1\nmode debug\nstep a:\nclick 1,1", "main.steps:3: empty block")] + [InlineData("format 1\nmode debug\nloop:\n click 1,1", "main.steps:3: unknown block 'loop:'")] + [InlineData("format 1\nmode debug\nclick 1,1\n key R", "main.steps:4: unexpected indent")] + [InlineData("format 1\nmode debug\nlog \"abc", "main.steps:3: unterminated quote")] + [InlineData("format 1\nmode debug\nset 9x = 1", "main.steps:3: bad set (set name = value)")] + public void Errors_NameTheLine(string text, string error) => Assert.Contains(error, Errors(Parse(text))); + + // Review focus 5: CRLF, BOM and trailing spaces parse as LF. + [Fact] + public void CrlfBomTrailingSpaces_SameAsLf() + { + var lf = Parse("format 1\nmode debug\nstep a:\n click 1,2\n"); + var crlf = Parse("\uFEFFformat 1 \r\nmode debug\r\nstep a: \r\n click 1,2 \r\n"); + Assert.Equal("", Errors(crlf)); + var a = Assert.IsType<BlockNode>(lf.Body[0]); + var b = Assert.IsType<BlockNode>(crlf.Body[0]); + Assert.Equal(a.Text, b.Text); + Assert.Equal(((VerbNode)a.Body[0]).Text, ((VerbNode)b.Body[0]).Text); + Assert.Equal(((VerbNode)a.Body[0]).At, ((VerbNode)b.Body[0]).At); + } + + [Fact] + public void ReportsEveryError_NotOnlyTheFirst() + { + var f = Parse("format 1\nmode debug\nwait x 2m\nset = 1\nloop:\n key R"); + Assert.Equal(3, f.Errors.Count); + } +} diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunFlagsTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunFlagsTests.cs new file mode 100644 index 0000000..6f3be4e --- /dev/null +++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunFlagsTests.cs @@ -0,0 +1,54 @@ +using Godosa.Core.Runs; +using Godosa.Core.Runs.Scripts; +using Xunit; + +namespace Godosa.Core.Tests.Runs.Scripts; + +public class ScriptRunFlagsTests +{ + private static ScriptRunFlags Parse(params string[] args) + { + var f = new ScriptRunFlags(); + for (var i = 0; i < args.Length; i++) + Assert.True(ScriptRunFlags.TryTake(args, ref i, f), args[i]); + return f; + } + + [Fact] + public void ParsesEveryOption() + { + var f = Parse("--script", "main.steps", "--var", "res=1920x1080", "--var", "who=gil", "--output", "trace", + "--no-cache", "--no-snapshots", "--from", "one", "--until", "two", "--step-cap", "500", "--report-dir", "out/runs/a"); + Assert.True(f.NoSnapshots); + Assert.False(f.ToOptions(new RunFlags(), null).Snapshots); + Assert.Equal("main.steps", f.Script); + Assert.Equal(new Dictionary<string, string> { ["res"] = "1920x1080", ["who"] = "gil" }, f.Vars); + Assert.Equal((OutputLevel.Trace, true, "one", "two", 500, "out/runs/a"), (f.Output!.Value, f.NoCache, f.From, f.Until, f.StepCap!.Value, f.ReportDir)); + } + + [Fact] + public void NotOurs_ReturnsFalse_IndexUnchanged() + { + var i = 0; + Assert.False(ScriptRunFlags.TryTake(["--mute"], ref i, new ScriptRunFlags())); + Assert.Equal(0, i); + } + + [Theory] + [InlineData("--var", "novalue")] + [InlineData("--output", "loud")] + [InlineData("--step-cap", "x")] + public void BadValues_Throw(string option, string value) + { + var i = 0; + Assert.Throws<ArgumentException>(() => ScriptRunFlags.TryTake([option, value], ref i, new ScriptRunFlags())); + } + + [Fact] + public void ToOptions_DeterministicSeedOutput() + { + var o = Parse("--output", "steps", "--no-cache").ToOptions(new RunFlags(Deterministic: true, Seed: 7), "out/runs/cache"); + Assert.Equal((true, (ulong?)7UL, OutputLevel.Steps, OutputLevel.Steps, false, "out/runs/cache"), (o.Deterministic, o.Seed, o.Output, o.Floor, o.UseCache, o.CacheDir)); + Assert.False(Parse("--nondeterministic").ToOptions(new RunFlags(), null).Deterministic); + } +} diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerStructureTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerStructureTests.cs new file mode 100644 index 0000000..9129456 --- /dev/null +++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerStructureTests.cs @@ -0,0 +1,85 @@ +using Godosa.Core.Runs.Scripts; +using Xunit; +using static Godosa.Core.Tests.Runs.Scripts.ScriptRunnerTests; + +namespace Godosa.Core.Tests.Runs.Scripts; + +public class ScriptRunnerStructureTests +{ + [Fact] + public void Output_DefaultLabels_StepsLevelShowsEveryStep() + { + var (_, labels) = Run("format 1\nmode player\nstep a:\n key R\n log \"hi\"\nkey Q", new FakeGame()); + Assert.Equal(["hi", "[main.steps:3 a] done f=2 (+2)", "digest 2 0.033 0000000000000002"], labels); + var (_, steps) = Run("format 1\nmode player\noutput steps\nstep a:\n key R\nkey Q", new FakeGame()); + Assert.Equal(["[main.steps:5 a] key R done f=2 (+2)", "[main.steps:4 a] done f=2 (+2)", "digest 2 0.033 0000000000000002", "[main.steps:6] key Q done f=4 (+2)"], steps); + } + + [Fact] + public void Output_ScopedToIncludedFile() + { + var (_, output) = Run("format 1\nmode player\ninclude loud.steps\nkey Q", new FakeGame(), + files: new() { ["loud.steps"] = "format 1\noutput steps\nkey R" }); + Assert.Equal(["[loud.steps:3] key R done f=2 (+2)"], output); // key Q at labels level: silent + } + + [Fact] + public void Output_FloorCannotBeLowered() + { + var (_, output) = Run("format 1\nmode player\noutput quiet\nkey R", new FakeGame(), new ScriptRunOptions { Floor = OutputLevel.Steps }); + Assert.Equal(["[main.steps:4] key R done f=2 (+2)"], output); + } + + [Fact] + public void DefCall_BindsParams_ParamUnknownOutside() + { + var g = new FakeGame(); + var a = new FakeAction(VerbKind.Debug, _ => new ActionStart(Status: StepStatus.Done), min: 1, max: 3); + g.ActionTable["cheat"] = a; + var (ok, _) = Run("format 1\nmode debug\ndef d(x):\n set local = in\n cheat ${x} ${local}\ncall d(gold)", g); + Assert.Equal(RunOutcome.Passed, ok.Outcome); + Assert.Equal(["gold", "in"], a.Begun[0]); + var (bad, output) = Run("format 1\nmode debug\ndef d(x):\n cheat ${x}\ncall d(gold)\ncheat ${x}", g); + Assert.Equal(RunOutcome.Invalid, bad.Outcome); + Assert.Contains(output, l => l == "main.steps:6: unresolved variable ${x}"); + } + + [Fact] + public void Try_FaultInsideContinuesAfterTheBlock_NonDeterministic() + { + var g = new FakeGame(); + g.ProbeFns["never"] = _ => RunValue.Of(false); + var (r, output) = Run("format 1\nmode player\ntry:\n wait never 3\n key A\nkey B", g, new ScriptRunOptions { Deterministic = false }); + Assert.Equal(RunOutcome.Passed, r.Outcome); + Assert.Contains(output, l => l == "try failed at main.steps:4: timeout after 3 frames: never (never=0)"); + Assert.DoesNotContain(g.Sent, s => s.Event.Key == "A"); + Assert.Contains(g.Sent, s => s.Event.Key == "B"); + } + + // Review focus 1: a fault while a button is down releases it. + [Fact] + public void Fault_ReleasesHeldButtonAndKey() + { + var g = new FakeGame(); + g.ProbeFns["never"] = _ => RunValue.Of(false); + g.ActionTable["walk-to"] = new FakeAction(VerbKind.Player, + _ => new ActionStart(new InputPlan([new PlannedInput(0, new InputEvent(InputKind.KeyDown, Key: "Shift")), new PlannedInput(0, new InputEvent(InputKind.Down, 9, 9)), + new PlannedInput(5, new InputEvent(InputKind.Up, 9, 9)), new PlannedInput(5, new InputEvent(InputKind.KeyUp, Key: "Shift"))])), + polls => StepStatus.Running, 1, 1); + g.OnAdvance = f => { if (f == 2) throw new InvalidOperationException("crash"); }; + var (r, _) = Run("format 1\nmode player\nwalk-to 9,9", g); + Assert.Equal(RunOutcome.Failed, r.Outcome); + Assert.Contains(g.Sent, s => s.Event == new InputEvent(InputKind.Up, 9, 9)); + Assert.Contains(g.Sent, s => s.Event == new InputEvent(InputKind.KeyUp, Key: "Shift")); + } + + [Fact] + public void Setup_RunsFirstAtFrameZero() + { + var g = new FakeGame(); + var order = new List<string>(); + g.ActionTable["cheat"] = new FakeAction(VerbKind.Debug, a => { order.Add($"cheat@{g.Frame}"); return new ActionStart(Status: StepStatus.Done); }, min: 1, max: 3); + Run("format 1\nmode player\nsetup:\n cheat stats max\nkey R", g); + Assert.Equal(["cheat@0"], order); + } +} diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerTests.cs new file mode 100644 index 0000000..0738f7b --- /dev/null +++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerTests.cs @@ -0,0 +1,224 @@ +using Godosa.Core.Runs.Scripts; +using Xunit; + +namespace Godosa.Core.Tests.Runs.Scripts; + +public class ScriptRunnerTests +{ + internal static (RunResult Result, List<string> Output) Run(string main, FakeGame game, ScriptRunOptions? options = null, + Dictionary<string, string>? files = null, Dictionary<string, string>? vars = null) + { + var all = new Dictionary<string, string>(files ?? []) { ["main.steps"] = main }; + var program = ScriptLoader.Load("main.steps", new MemoryScriptSource(all), vars ?? []); + var output = new List<string>(); + var runner = new ScriptRunner(program, game, options ?? new ScriptRunOptions(), output.Add); + return (runner.Run(cap: 10_000), output); + } + + [Fact] + public void Click_DownAtStepFrameUpNextFrame() + { + var g = new FakeGame(); + var (r, _) = Run("format 1\nmode player\nclick 100,200\nkey R", g); + Assert.Equal((RunOutcome.Passed, 0), (r.Outcome, r.ExitCode)); + // Hand-derived: frame 0 (game.Frame before first Advance) move+down, frame 1 up; key starts on frame 2: down, frame 3 up. + Assert.Equal( + [ + (0L, new InputEvent(InputKind.Move, 100, 200)), (0L, new InputEvent(InputKind.Down, 100, 200)), + (1L, new InputEvent(InputKind.Up, 100, 200)), + (2L, new InputEvent(InputKind.KeyDown, Key: "R")), (3L, new InputEvent(InputKind.KeyUp, Key: "R")), + ], g.Sent); + Assert.Equal(4, r.Frames); + } + + // A window host ends its batch of game frames when a step completes, so the next step resolves on a fresh draw. + [Fact] + public void StepsDone_CountsCompletedSteps() + { + var program = ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> { ["main.steps"] = "format 1\nmode player\nclick 100,200\nkey R" }), new Dictionary<string, string>()); + var runner = new ScriptRunner(program, new FakeGame(), new ScriptRunOptions(), _ => { }); + Assert.Equal(0, runner.StepsDone); + runner.Run(cap: 10_000); + Assert.Equal(2, runner.StepsDone); + } + + // A window host skips the poll of a frame drawn later (a batch frame that closed a panel: a click resolved then + // would aim on the draw from before): the frame counts, the step is polled on the next one. + [Fact] + public void AfterFrame_NoPoll_CountsTheFrameOnly() + { + var program = ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> { ["main.steps"] = "format 1\nmode player\nwait-frames 1" }), new Dictionary<string, string>()); + var runner = new ScriptRunner(program, new FakeGame(), new ScriptRunOptions(), _ => { }); + runner.BeforeFrame(); + runner.AfterFrame(poll: false); + Assert.Equal((1, 0), (runner.FrameCount, runner.StepsDone)); + runner.BeforeFrame(); + runner.AfterFrame(); + Assert.Equal((2, 1), (runner.FrameCount, runner.StepsDone)); + } + + [Fact] + public void Locator_AimsTheClick_OffScreenFaults() + { + var g = new FakeGame(); + g.Objects["Virgil"] = (640, 360); + var (ok, _) = Run("format 1\nmode player\nclick @obj(Virgil)", g); + Assert.Equal(RunOutcome.Passed, ok.Outcome); + Assert.Contains((0L, new InputEvent(InputKind.Down, 640, 360)), g.Sent); + var (bad, output) = Run("format 1\nmode player\nclick @obj(Bates)", new FakeGame()); + Assert.Equal((RunOutcome.Failed, 1), (bad.Outcome, bad.ExitCode)); + Assert.Contains(output, l => l == "FAIL main.steps:3 click @obj(Bates): locator @obj(Bates): no object Bates on screen"); + } + + [Fact] + public void Wait_HoldsUntilConditionOrTimesOut() + { + var g = new FakeGame(); + g.ProbeFns["ready"] = _ => RunValue.Of(g.Frame >= 5); + var (r, _) = Run("format 1\nmode player\nwait ready 10\nkey A", g); + Assert.Equal(RunOutcome.Passed, r.Outcome); + Assert.Equal(5L, g.Sent[0].Frame); // condition true after the 5th advance → key starts at frame 5 + + var g2 = new FakeGame(); + g2.ProbeFns["ready"] = _ => RunValue.Of(false); + g2.ProbeFns["quest"] = _ => RunValue.Of(1); + var (t, output) = Run("format 1\nmode player\nwait ready and quest(3) > 1 4", g2); + Assert.Equal(RunOutcome.Failed, t.Outcome); + Assert.Equal(4, t.Frames); + Assert.Contains(output, l => l == "FAIL main.steps:3 wait ready and quest(3) > 1 4: timeout after 4 frames: ready and quest(3) > 1 (ready=0 quest(3)=1)"); + } + + [Fact] + public void Wait_AlreadyTrue_TakesNoFrame() + { + var g = new FakeGame(); + g.ProbeFns["ready"] = _ => RunValue.Of(true); + var (r, _) = Run("format 1\nmode player\nwait ready 10\nkey A", g); + Assert.Equal(0L, g.Sent[0].Frame); + Assert.Equal(2, r.Frames); + } + + [Fact] + public void Expect_FailsWithValues() + { + var g = new FakeGame(); + g.ProbeFns["quest"] = _ => RunValue.Of(1); + var (r, output) = Run("format 1\nmode player\nexpect quest(1) >= accepted", g); + Assert.Equal(RunOutcome.Failed, r.Outcome); + Assert.Contains(output, l => l == "FAIL main.steps:3 expect quest(1) >= accepted: expect failed: quest(1) >= accepted (quest(1)=1)"); + } + + [Fact] + public void Action_PlanThenPollUntilDone() + { + var g = new FakeGame(); + var talk = new FakeAction(VerbKind.Player, args => new ActionStart(new InputPlan([new PlannedInput(0, new InputEvent(InputKind.Down, 5, 6)), new PlannedInput(1, new InputEvent(InputKind.Up, 5, 6))])), + polls => polls >= 3 ? StepStatus.Done : StepStatus.Running, 1, 1); + g.ActionTable["talk"] = talk; + var (r, _) = Run("format 1\nmode player\ntalk @obj(x)\nkey Z", g); + Assert.Equal(RunOutcome.Passed, r.Outcome); + Assert.Equal(["@obj(x)"], talk.Begun.Single()); + // Down sent f0, Up f1; polls run after advances to 2, 3, 4 (Done on the 3rd) → key starts at frame 4. + Assert.Equal(4L, g.Sent.Single(s => s.Event.Kind == InputKind.KeyDown).Frame); + } + + [Fact] + public void Action_ArgsGetVariablesExpanded_LocatorsLeftToTheGame() + { + var g = new FakeGame(); + var a = new FakeAction(VerbKind.Debug, _ => new ActionStart(Status: StepStatus.Done), min: 1, max: 3); + g.ActionTable["cheat"] = a; + Run("format 1\nmode debug\nset n = 50\ncheat gold ${n}", g); + Assert.Equal(["gold", "50"], a.Begun.Single()); + } + + [Fact] + public void Action_FaultStatusFailsTheRun() + { + var g = new FakeGame(); + g.ActionTable["walk-to"] = new FakeAction(VerbKind.Player, _ => new ActionStart(), polls => polls == 2 ? StepStatus.Fault("stuck at 3,4") : StepStatus.Running, 1, 1); + var (r, output) = Run("format 1\nmode player\nwalk-to 3,4", g); + Assert.Equal((RunOutcome.Failed, 2L), (r.Outcome, r.Frames)); + Assert.Contains(output, l => l == "FAIL main.steps:3 walk-to 3,4: stuck at 3,4"); + } + + // Review focus 2: a hook that never finishes hits the step cap. + [Fact] + public void StepCap_StopsAHangingAction() + { + var g = new FakeGame(); + g.ActionTable["walk-to"] = new FakeAction(VerbKind.Player, _ => new ActionStart(), _ => StepStatus.Running, 1, 1); + var (r, output) = Run("format 1\nmode player\nwalk-to 3,4", g, new ScriptRunOptions { StepCap = 50 }); + Assert.Equal((RunOutcome.Failed, 50L), (r.Outcome, r.Frames)); + Assert.Contains(output, l => l == "FAIL main.steps:3 walk-to 3,4: step exceeded 50 frames"); + } + + [Fact] + public void GameHookException_IsAFailureNotACrash() + { + var g = new FakeGame(); + g.ActionTable["talk"] = new FakeAction(VerbKind.Player, _ => throw new InvalidOperationException("boom"), min: 1, max: 1); + var (r, output) = Run("format 1\nmode player\ntalk x", g); + Assert.Equal(RunOutcome.Failed, r.Outcome); + Assert.Contains(output, l => l == "FAIL main.steps:3 talk x: InvalidOperationException: boom"); + } + + [Fact] + public void InvalidScript_Exit2_NoFrames() + { + var g = new FakeGame(); + var (r, output) = Run("format 1\nmode player\ndance", g); + Assert.Equal((RunOutcome.Invalid, 2, 0L), (r.Outcome, r.ExitCode, r.Frames)); + Assert.Contains(output, l => l == "main.steps:3: unknown verb dance"); + Assert.Equal(0, g.Frame); + } + + [Fact] + public void Rng_CallsTheGameHook_NoFrame() + { + var g = new FakeGame(); + var (r, _) = Run("format 1\nmode player\nrng seed 9\nrng next hit 1", g); + Assert.Equal((RunOutcome.Passed, 0L), (r.Outcome, r.Frames)); + Assert.Equal(["seed 9", "next hit 1"], g.RngImpl!.Calls); + } + + [Fact] + public void WaitFrames_CountsFrames() + { + var g = new FakeGame(); + var (r, _) = Run("format 1\nmode player\nwait-frames 7\nwait-frames 0\nwait-frames 0.5s", g); + Assert.Equal(37, r.Frames); + } + + [Theory] + [InlineData("dclick 1,2", "Move@0 Down@0 Up@1 Down@2 Up@3")] + [InlineData("rclick 1,2", "Move@0 Down(R)@0 Up(R)@1")] + [InlineData("drag 0,0 30,60", "Move(0,0)@0 Down@0 Move(10,20)@1 Move(20,40)@2 Move(30,60)@3 Up@4")] + [InlineData("hold W 3", "KeyDown(W)@0 KeyUp(W)@3")] + [InlineData("chord Ctrl+S", "KeyDown(Ctrl)@0 KeyDown(S)@0 KeyUp(S)@1 KeyUp(Ctrl)@1")] + [InlineData("type \"hi there\"", "Text(hi there)@0")] + [InlineData("wheel -2 5,5", "Move@0 Wheel(-2)@0")] + public void InputPlans_Shapes(string line, string expected) + { + var g = new FakeGame(); + Run($"format 1\nmode player\n{line}", g); + string Show((long Frame, InputEvent E) s) => s.E.Kind switch + { + InputKind.Move when line.StartsWith("drag") => $"Move({s.E.X},{s.E.Y})@{s.Frame}", + InputKind.Down or InputKind.Up when s.E.Button == MouseButton.Right => $"{s.E.Kind}(R)@{s.Frame}", + InputKind.KeyDown or InputKind.KeyUp => $"{s.E.Kind}({s.E.Key})@{s.Frame}", + InputKind.Text => $"Text({s.E.Text})@{s.Frame}", + InputKind.Wheel => $"Wheel({s.E.Delta})@{s.Frame}", + _ => $"{s.E.Kind}@{s.Frame}", + }; + Assert.Equal(expected, string.Join(" ", g.Sent.Select(Show))); + } + + [Fact] + public void BadPosition_Faults() + { + var (r, output) = Run("format 1\nmode player\nclick 10;20", new FakeGame()); + Assert.Equal(RunOutcome.Failed, r.Outcome); + Assert.Contains(output, l => l == "FAIL main.steps:3 click 10;20: bad position '10;20'"); + } +} diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptValidatorTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptValidatorTests.cs new file mode 100644 index 0000000..98dcc73 --- /dev/null +++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptValidatorTests.cs @@ -0,0 +1,63 @@ +using Godosa.Core.Runs.Scripts; +using Xunit; + +namespace Godosa.Core.Tests.Runs.Scripts; + +public class ScriptValidatorTests +{ + private static FakeGame Game() + { + var g = new FakeGame(); + g.ProbeFns["quest"] = _ => RunValue.Of(0); + g.ProbeFns["dialog-open"] = _ => RunValue.Of(false); + g.ActionTable["talk"] = new FakeAction(VerbKind.Player, _ => new ActionStart(), min: 1, max: 1); + g.ActionTable["cheat"] = new FakeAction(VerbKind.Debug, _ => new ActionStart(Status: StepStatus.Done), min: 1, max: 20); + return g; + } + + private static string Validate(string main, bool deterministic = true, FakeGame? game = null) => + string.Join(" | ", ScriptValidator.Validate( + ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> { ["main.steps"] = main }), new Dictionary<string, string>()), + game ?? Game(), deterministic).Select(e => e.ToString())); + + [Fact] + public void ValidPlayerScript_NoErrors() => + Assert.Equal("", Validate("format 1\nmode player\nsetup:\n cheat gold 5\ntalk @obj(Virgil)\nwait dialog-open 2s\nexpect quest(1) >= accepted\nclick 1,2\nkey R\nlog \"hi\"")); + + [Theory] + [InlineData("format 1\nmode player\ndance", "main.steps:3: unknown verb dance")] + [InlineData("format 1\nmode player\nwalk-to 3,4", "main.steps:3: verb walk-to not supported by game fake")] + [InlineData("format 1\nmode player\nclick", "main.steps:3: verb click takes 1..1 arguments, got 0")] + [InlineData("format 1\nmode player\ncheat gold 5", "main.steps:3: debug verb cheat outside setup in player mode")] + [InlineData("format 1\nmode player\nwait nope 10", "main.steps:3: unknown probe or constant nope")] + [InlineData("format 1\nmode player\nexpect quest(1) >=", "main.steps:3: condition ends early")] + [InlineData("format 1\nmode player\ntalk @npc(Virgil)", "main.steps:3: unknown locator @npc")] + [InlineData("format 1\nmode player\ntry:\n key R", "main.steps:3: try needs a non-deterministic run")] + [InlineData("format 1\nmode player\noutput loud", "main.steps:3: output level must be quiet|checkpoints|labels|steps|trace")] + public void Errors(string main, string error) => Assert.Contains(error, Validate(main)); + + [Fact] + public void DebugMode_DebugVerbAnywhere() => Assert.Equal("", Validate("format 1\nmode debug\ncheat gold 5\nkey R")); + + [Fact] + public void Try_AllowedNonDeterministic() => Assert.Equal("", Validate("format 1\nmode player\ntry:\n key R", deterministic: false)); + + [Fact] + public void Rng_NeedsTheGameHook() + { + var g = Game(); + g.RngImpl = null; + Assert.Contains("main.steps:3: verb rng not supported by game fake", Validate("format 1\nmode player\nrng seed 4", game: g)); + } + + [Fact] + public void GameVerb_ShadowingALibVerbWithAnotherKind_Rejected() + { + var g = Game(); + g.ActionTable["click"] = new FakeAction(VerbKind.Debug, _ => new ActionStart()); + Assert.Contains("game verb click shadows a lib verb", Validate("format 1\nmode debug\nkey R", game: g)); + } + + [Fact] + public void LoaderErrorsComeThrough() => Assert.Contains("main.steps:3: unknown def x", Validate("format 1\nmode debug\ncall x()")); +} diff --git a/src/Godosa.Core.Tests/Runs/Scripts/StepLexerTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/StepLexerTests.cs new file mode 100644 index 0000000..0f787dc --- /dev/null +++ b/src/Godosa.Core.Tests/Runs/Scripts/StepLexerTests.cs @@ -0,0 +1,58 @@ +using Godosa.Core.Runs.Scripts; +using Xunit; + +namespace Godosa.Core.Tests.Runs.Scripts; + +public class StepLexerTests +{ + [Theory] + [InlineData("click 10,20 # aim", "click 10,20 ")] + [InlineData("log \"a # b\" # c", "log \"a # b\" ")] + [InlineData("# only", "")] + [InlineData("type \"say \\\"#1\\\"\"", "type \"say \\\"#1\\\"\"")] + public void StripComment_OutsideQuotesOnly(string line, string expected) => + Assert.Equal(expected, StepLexer.StripComment(line)); + + [Fact] + public void Tokens_SplitOnSpaceQuotedStaysWhole() + { + var t = StepLexer.Tokens("use @inv(Lockpicks) on \"Big Door\""); + Assert.Equal(["use", "@inv(Lockpicks)", "on", "Big Door"], t.Select(x => x.Text)); + Assert.Equal([false, false, false, true], t.Select(x => x.Quoted)); + } + + [Fact] + public void Tokens_EscapesInQuotes() + { + var t = StepLexer.Tokens("type \"a \\\"b\\\" \\\\ c\""); + Assert.Equal("a \"b\" \\ c", t[1].Text); + } + + [Fact] + public void Tokens_ParenthesesKeepSpacesInOneToken() + { + // locator args may contain spaces: @npc(Gilbert Bates) + var t = StepLexer.Tokens("talk @npc(Gilbert Bates)"); + Assert.Equal(["talk", "@npc(Gilbert Bates)"], t.Select(x => x.Text)); + } + + [Fact] + public void Tokens_UnterminatedQuote_Throws() => + Assert.Equal("unterminated quote", Assert.Throws<FormatException>(() => StepLexer.Tokens("log \"abc")).Message); + + [Theory] + [InlineData("120", 120)] + [InlineData("2s", 120)] + [InlineData("0.5s", 30)] + [InlineData("0", 0)] + [InlineData("1.25s", 75)] + public void Durations_FramesAndSeconds(string text, int frames) => Assert.Equal(frames, Durations.Frames(text)); + + [Theory] + [InlineData("0.01s")] // 0.6 frames: not whole + [InlineData("-5")] + [InlineData("2m")] + [InlineData("")] + [InlineData("1e3")] + public void Durations_Invalid_Null(string text) => Assert.Null(Durations.Frames(text)); +} diff --git a/src/Godosa.Core.Tests/Runs/Scripts/VarsTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/VarsTests.cs new file mode 100644 index 0000000..c6d3286 --- /dev/null +++ b/src/Godosa.Core.Tests/Runs/Scripts/VarsTests.cs @@ -0,0 +1,84 @@ +using Godosa.Core.Runs.Scripts; +using Xunit; + +namespace Godosa.Core.Tests.Runs.Scripts; + +public class VarsTests +{ + private static readonly Dictionary<string, string> Values = new() + { + ["res"] = "1920x1080", ["hours"] = "8", ["ui.rest.8"] = "400,300", ["a"] = "b", ["b"] = "deep", + }; + + private static string Expand(string text) => VarExpander.Expand(text, n => Values.GetValueOrDefault(n), + p => p == "global(gold)" ? RunValue.Of(1250) : throw new ScriptFault($"no probe {p}")); + + [Theory] + [InlineData("ui/${res}.vars", "ui/1920x1080.vars")] + [InlineData("click ${ui.rest.${hours}}", "click 400,300")] + [InlineData("${${a}}", "deep")] + [InlineData("gold ${probe:global(gold)}", "gold 1250")] + [InlineData("cost $5 and $", "cost $5 and $")] // review focus 4: lone $ is literal + [InlineData("no vars", "no vars")] + public void Expand_Values(string text, string expected) => Assert.Equal(expected, Expand(text)); + + [Theory] + [InlineData("${missing}", "unresolved variable ${missing}")] + [InlineData("x ${a", "unclosed ${ in 'x ${a'")] + [InlineData("${}", "empty ${}")] + public void Expand_Errors_AreFaults(string text, string message) => + Assert.Equal(message, Assert.Throws<ScriptFault>(() => Expand(text)).Message); + + [Fact] + public void Names_InnermostPlainOnly() => + Assert.Equal(["hours", "res"], VarExpander.Names("${ui.${hours}} ${res} ${probe:quest(1)}").Order()); + + [Fact] + public void VarsFile_ParsesAndReportsBadLines() + { + var (values, errors) = VarsFile.Parse("ui/hd.vars", "# hd\nui.answer1 = 412,520\n\nui.rest.8=400,300\nbroken line\n"); + Assert.Equal("412,520", values["ui.answer1"]); + Assert.Equal("400,300", values["ui.rest.8"]); + Assert.Equal(["ui/hd.vars:5: bad vars line (name = value)"], errors.Select(e => e.ToString())); + } + + [Fact] + public void Scopes_SetGoesToLocalOrRoot() + { + var s = new VarScopes(OutputLevel.Labels); + s.Set("g", "1"); + s.Push(local: false, OutputLevel.Labels, []); // include + s.Set("inc", "2"); // global: root + s.Push(local: true, OutputLevel.Labels, [new("p", "x")]); // def call + s.Set("loc", "3"); + Assert.Equal(("1", "2", "3", "x"), (s.Get("g"), s.Get("inc"), s.Get("loc"), s.Get("p"))); + s.Pop(); + Assert.Null(s.Get("loc")); + Assert.Null(s.Get("p")); + s.Pop(); + Assert.Equal("2", s.Get("inc")); + } + + [Fact] + public void Scopes_OutputLevelRestoredOnPop() + { + var s = new VarScopes(OutputLevel.Labels); + s.Push(local: false, s.Level, []); + s.Level = OutputLevel.Trace; + s.Pop(); + Assert.Equal(OutputLevel.Labels, s.Level); + } + + [Fact] + public void Scopes_SnapshotRestore_RoundTrips() + { + var s = new VarScopes(OutputLevel.Labels); + s.Set("g", "1"); + s.Push(local: true, OutputLevel.Steps, [new("p", "x")]); + var copy = new VarScopes(OutputLevel.Quiet); + copy.Restore(s.Snapshot()); + Assert.Equal(("1", "x", OutputLevel.Steps), (copy.Get("g"), copy.Get("p"), copy.Level)); + s.Set("p", "changed"); + Assert.Equal("x", copy.Get("p")); // deep copy + } +} |
