using Godosa.Core.Runs.Scripts; using Xunit; namespace Godosa.Core.Tests.Runs.Scripts; public class ScriptRunnerTests { internal static (RunResult Result, List Output) Run(string main, FakeGame game, ScriptRunOptions? options = null, Dictionary? files = null, Dictionary? vars = null) { var all = new Dictionary(files ?? []) { ["main.steps"] = main }; var program = ScriptLoader.Load("main.steps", new MemoryScriptSource(all), vars ?? []); var output = new List(); 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 { ["main.steps"] = "format 1\nmode player\nclick 100,200\nkey R" }), new Dictionary()); 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 { ["main.steps"] = "format 1\nmode player\nwait-frames 1" }), new Dictionary()); 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'"); } }