using System.Runtime.CompilerServices; using System.Text.RegularExpressions; using Recordingtest.Player.Model; using Xunit; namespace Recordingtest.Player.Tests; public class PlayerEngineTests { [Fact] public void Player_EmptyScenario_ExitsZero() { var engine = new PlayerEngine(); var host = new FakePlayerHost(); var scenario = new Scenario { Name = "empty" }; engine.Run(scenario, host); Assert.Empty(host.Clicks); Assert.Empty(host.Failures); } [Fact] public void Player_ClickStep_InvokesHostClickAtExpectedScreenPoint() { var engine = new PlayerEngine(); var host = new FakePlayerHost { ResolveImpl = _ => new ResolvedElement( new ElementBounds(100, 200, 50, 40), null), }; var scenario = new Scenario { Steps = { new Step { Kind = StepKind.Click, Target = new Target { UiaPath = "Window/Button[@AutomationId='ok']", Offset = new[] { 0.5, 0.25 }, }, }, }, }; engine.Run(scenario, host); // 100 + 50*0.5 = 125 ; 200 + 40*0.25 = 210 Assert.Single(host.Clicks); Assert.Equal(new ScreenPoint(125, 210), host.Clicks[0]); } [Fact] public void Player_ResolveFailure_CapturesArtifacts() { var engine = new PlayerEngine(); var host = new FakePlayerHost { ResolveImpl = _ => null }; var scenario = new Scenario { Steps = { new Step { Kind = StepKind.Click, Target = new Target { UiaPath = "Bogus" }, }, }, }; var ex = Assert.Throws( () => engine.Run(scenario, host)); Assert.Single(host.Failures); Assert.Equal(0, host.Failures[0].StepIndex); Assert.False(string.IsNullOrEmpty(host.Failures[0].Reason)); Assert.Contains("Bogus", ex.Message); } [Fact] public void Player_CheckpointStep_InvokesCapture() { var engine = new PlayerEngine(); var host = new FakePlayerHost(); var scenario = new Scenario { Steps = { new Step { Kind = StepKind.Checkpoint, AfterStep = 3, SaveAs = "out/cp.hmeg", }, }, }; engine.Run(scenario, host); Assert.Single(host.Checkpoints); Assert.Equal(3, host.Checkpoints[0].AfterStep); Assert.Equal("out/cp.hmeg", host.Checkpoints[0].SaveAs); } [Fact] public void Player_NoFixedSleep() { var path = LocateEngineSource(); var src = File.ReadAllText(path); Assert.DoesNotMatch(new Regex(@"Thread\.Sleep\("), src); Assert.DoesNotMatch(new Regex(@"Task\.Delay\(TimeSpan\.FromSeconds"), src); } [Fact] public void Player_ScenarioLoader_ParsesSampleYaml() { const string yaml = """ name: sample description: tiny sut: exe: "EG-BIM Modeler/EG-BIM Modeler.exe" startup_timeout_ms: 12000 steps: - kind: click target: uia_path: "Window/Button[@AutomationId='ok']" offset: [0.5, 0.5] - kind: type value: "hello" - kind: checkpoint after_step: 1 save_as: "out/cp1.hmeg" checkpoints: - after_step: 1 save_as: "out/cp1.hmeg" baselines: - path: "baselines/cp1.approved.hmeg" """; var s = ScenarioLoader.LoadFromString(yaml); Assert.Equal("sample", s.Name); Assert.Equal(12000, s.Sut.StartupTimeoutMs); Assert.Equal(3, s.Steps.Count); Assert.Equal(StepKind.Click, s.Steps[0].Kind); Assert.Equal("Window/Button[@AutomationId='ok']", s.Steps[0].Target!.UiaPath); Assert.Equal(0.5, s.Steps[0].Target!.Offset[0]); Assert.Equal(StepKind.Type, s.Steps[1].Kind); Assert.Equal("hello", s.Steps[1].Value); Assert.Equal(StepKind.Checkpoint, s.Steps[2].Kind); Assert.Equal(1, s.Steps[2].AfterStep); Assert.Single(s.Checkpoints); Assert.Single(s.Baselines); } [Fact] public void PlayerEngine_NullTarget_Fallback_Issue14() { // Issue #14: null-target fallbacks. // - Type → send keystrokes to current focus (no target required) // - Click w/ raw_coord → click at raw_coord screen-absolute // - Click w/o raw_coord → still skipped (no way to click safely) // - Drag → still skipped (no raw_coord pair handling yet) var engine = new PlayerEngine(); var host = new FakePlayerHost(); var scenario = new Scenario { Steps = { new Step { Kind = StepKind.Click, Target = null }, // skip new Step { Kind = StepKind.Click, Target = null, RawCoord = new[] { 123, 456 } }, new Step { Kind = StepKind.Drag, Target = null }, // skip new Step { Kind = StepKind.Type, Target = null, Value = "hello" }, }, }; engine.Run(scenario, host); Assert.Single(host.Clicks); Assert.Equal(new ScreenPoint(123, 456), host.Clicks[0]); Assert.Empty(host.Drags); Assert.Single(host.Types); Assert.Equal("hello", host.Types[0]); Assert.Empty(host.Failures); } [Fact] public void PlayerEngine_WheelKind_DoesNotThrow() { var engine = new PlayerEngine(); var host = new FakePlayerHost(); var scenario = new Scenario { Steps = { new Step { Kind = StepKind.Wheel, Value = "-120" }, new Step { Kind = StepKind.Focus }, }, }; engine.Run(scenario, host); Assert.Empty(host.Failures); } [Fact] public void ScenarioLoader_ParsesWheelAndFocusKinds() { const string yaml = """ name: wheel-focus steps: - kind: wheel value: "-120" - kind: focus target: uia_path: "Window/Edit" offset: [0.5, 0.5] """; var s = ScenarioLoader.LoadFromString(yaml); Assert.Equal(2, s.Steps.Count); Assert.Equal(StepKind.Wheel, s.Steps[0].Kind); Assert.Equal(StepKind.Focus, s.Steps[1].Kind); } // ---- trailing recorder-stop noise stripping -------------------------------- [Fact] public void TrailingRecorderStop_AltTabClickCtrlC_Stripped() { // box-v7 pattern: real work → alt+tab → click → ctrl+c ctrl+c var host = new FakePlayerHost(); var engine = new PlayerEngine(new PlayerEngineOptions { PreserveTiming = false }); var scenario = new Scenario { Steps = { new Step { Kind = StepKind.Type, Value = "BOX" }, // real work new Step { Kind = StepKind.Hotkey, Value = "enter" }, // real work new Step { Kind = StepKind.Hotkey, Value = "alt+tab" }, // recorder stop → new Step { Kind = StepKind.Click, RawCoord = [771, 833] }, // click terminal new Step { Kind = StepKind.Hotkey, Value = "ctrl+c" }, // stop recorder new Step { Kind = StepKind.Hotkey, Value = "ctrl+c" }, // stop recorder }, }; engine.Run(scenario, host); // Only the two real-work steps should have fired — no Clicks, hotkey only "enter" Assert.Empty(host.Clicks); // click (771,833) stripped Assert.Single(host.Types); // only "BOX" Assert.Equal("BOX", host.Types[0]); Assert.Single(host.Hotkeys); // only "enter", not ctrl+c Assert.Equal("enter", host.Hotkeys[0]); } [Fact] public void TrailingCtrlCAlone_NotStripped_RequiresAltTab() { // ctrl+c without preceding alt+tab is a legitimate SUT action (copy) var host = new FakePlayerHost(); var engine = new PlayerEngine(new PlayerEngineOptions { PreserveTiming = false }); var scenario = new Scenario { Steps = { new Step { Kind = StepKind.Type, Value = "BOX" }, new Step { Kind = StepKind.Hotkey, Value = "ctrl+c" }, }, }; engine.Run(scenario, host); // ctrl+c alone at the end must NOT be stripped (it's a copy action) Assert.Single(host.Hotkeys); Assert.Equal("ctrl+c", host.Hotkeys[0]); } // ---- camera restore ------------------------------------------------------- [Fact] public void CameraRestore_NoCameraSnapshot_HostNotCalled() { var engine = new PlayerEngine(new PlayerEngineOptions { PreserveTiming = false }); var host = new FakePlayerHost(); var scenario = new Scenario { Steps = { new Step { Kind = StepKind.Type, Value = "x" } }, CameraSnapshot = null, }; engine.Run(scenario, host); Assert.Empty(host.CameraRestoreCalls); } [Fact] public void CameraRestore_HasCameraSnapshot_HostCalledWithCorrectValues() { var engine = new PlayerEngine(new PlayerEngineOptions { PreserveTiming = false }); var host = new FakePlayerHost { CameraRestoreResult = true }; var scenario = new Scenario { Steps = { new Step { Kind = StepKind.Type, Value = "x" } }, CameraSnapshot = new ScenarioCameraSnapshot { Eye = new[] { 1.0, 2.0, 3.0 }, Target = new[] { 4.0, 5.0, 6.0 }, Up = new[] { 0.0, 1.0, 0.0 }, Fov = 60.0, }, }; engine.Run(scenario, host); Assert.Single(host.CameraRestoreCalls); var call = host.CameraRestoreCalls[0]; Assert.Equal(new[] { 1.0, 2.0, 3.0 }, call.Eye); Assert.Equal(new[] { 4.0, 5.0, 6.0 }, call.Target); Assert.Equal(new[] { 0.0, 1.0, 0.0 }, call.Up); Assert.Equal(60.0, call.Fov); } [Fact] public void CameraRestore_HostReturnsFalse_PlaybackContinues() { var engine = new PlayerEngine(new PlayerEngineOptions { PreserveTiming = false }); var host = new FakePlayerHost { CameraRestoreResult = false }; var scenario = new Scenario { Steps = { new Step { Kind = StepKind.Type, Value = "BOX" }, new Step { Kind = StepKind.Hotkey, Value = "enter" }, }, CameraSnapshot = new ScenarioCameraSnapshot { Eye = new[] { 0.0, 0.0, 10.0 }, Target = new double[3], Up = new[] { 0.0, 1.0, 0.0 }, Fov = 45.0, }, }; engine.Run(scenario, host); // Even though restore returned false, all steps should still run Assert.Single(host.Types); Assert.Equal("BOX", host.Types[0]); Assert.Single(host.Hotkeys); Assert.Empty(host.Failures); } [Fact] public void ScenarioLoader_ParsesCameraSnapshot() { const string yaml = """ name: with-camera camera_snapshot: eye: [1.0, 2.0, 3.0] target: [4.0, 5.0, 6.0] up: [0.0, 1.0, 0.0] fov: 60.0 steps: [] """; var s = ScenarioLoader.LoadFromString(yaml); Assert.NotNull(s.CameraSnapshot); Assert.Equal(new[] { 1.0, 2.0, 3.0 }, s.CameraSnapshot!.Eye); Assert.Equal(60.0, s.CameraSnapshot.Fov); } [Fact] public void ScenarioLoader_NoCameraSnapshot_ReturnsNull() { const string yaml = "name: no-camera\nsteps: []\n"; var s = ScenarioLoader.LoadFromString(yaml); Assert.Null(s.CameraSnapshot); } private static string LocateEngineSource([CallerFilePath] string here = "") { // here = .../tests/Recordingtest.Player.Tests/PlayerEngineTests.cs var dir = Path.GetDirectoryName(here)!; var repo = Path.GetFullPath(Path.Combine(dir, "..", "..")); return Path.Combine(repo, "src", "Recordingtest.Player", "PlayerEngine.cs"); } }