diff --git a/docs/history/2026-04-07_이슈7-player-generator.md b/docs/history/2026-04-07_이슈7-player-generator.md
new file mode 100644
index 0000000..3b864ae
--- /dev/null
+++ b/docs/history/2026-04-07_이슈7-player-generator.md
@@ -0,0 +1,40 @@
+# 2026-04-07 이슈 #7 — player Generator
+
+## 작업 개요
+Sprint Contract `docs/contracts/player.md` 기준으로 `Recordingtest.Player` 콘솔 PoC + xUnit 테스트 구현.
+
+## 산출물
+- `src/Recordingtest.Player/`
+ - `Recordingtest.Player.csproj` (TFM `net8.0-windows`, FlaUI.Core/UIA3 4.0.0, YamlDotNet 16.1.3)
+ - `Model/Scenario.cs`, `Model/Step.cs` (recorder 스키마와 동일 형상; 임시로 Player 내부에 위치)
+ - `ScenarioLoader.cs` (YamlDotNet, snake_case)
+ - `IPlayerHost.cs` (UIA/입력 추상화)
+ - `PlayerEngine.cs` (스텝 루프; 고정 sleep 없음, 예외 시 `CaptureFailureArtifacts` 후 재던짐)
+ - `UiaPlayerHost.cs` (FlaUI 실제 구현, 컴파일 전용)
+ - `Program.cs` (CLI: `--scenario --output-dir --no-launch`)
+- `tests/Recordingtest.Player.Tests/` (FakePlayerHost + 6개 테스트)
+- `recordingtest.sln` 에 두 프로젝트 등록
+
+## 결과
+- `dotnet build recordingtest.sln`: 경고 0, 오류 0
+- `dotnet test`: 6/6 통과
+ - `Player_EmptyScenario_ExitsZero`
+ - `Player_ClickStep_InvokesHostClickAtExpectedScreenPoint`
+ - `Player_ResolveFailure_CapturesArtifacts`
+ - `Player_CheckpointStep_InvokesCapture`
+ - `Player_NoFixedSleep` (PlayerEngine.cs 내 `Thread.Sleep(` / `Task.Delay(TimeSpan.FromSeconds` 0건)
+ - `Player_ScenarioLoader_ParsesSampleYaml`
+
+## 미충족 / untestable DoD
+- **"동일 시나리오 10회 재생 시 9회 이상 성공"** — 실제 SUT(EG-BIM Modeler) 기동이 PoC 샌드박스에서 금지되어 있어 단위 테스트로 검증 불가. Evaluator 가 `partial`/`untestable` 로 표기해야 함. 통합 환경에서 `--no-launch` 후 외부 러너로 검증 필요.
+- `wait_for` UIA 이벤트 매핑은 PoC 수준 (UiaPlayerHost 는 main window IsEnabled polling). 엔진 자체는 hint 문자열 그대로 host 에 위임하여 추후 확장 여지 있음.
+- `UiaPlayerHost.ResolveElement` 는 UIA path 의 마지막 `@AutomationId` 만 사용하는 단순 구현. 전체 path resolver 는 후속 작업.
+
+## 소요 시간
+약 15분
+
+## Context 사용량
+약 30k tokens
+
+## 관련 이슈
+#7 (player PoC)
diff --git a/src/Recordingtest.Player/IPlayerHost.cs b/src/Recordingtest.Player/IPlayerHost.cs
new file mode 100644
index 0000000..ebea70c
--- /dev/null
+++ b/src/Recordingtest.Player/IPlayerHost.cs
@@ -0,0 +1,28 @@
+using Recordingtest.Player.Model;
+
+namespace Recordingtest.Player;
+
+/// Element bounds in screen pixels.
+public readonly record struct ElementBounds(double X, double Y, double Width, double Height);
+
+/// Resolved element handle (opaque to the engine).
+public readonly record struct ResolvedElement(ElementBounds Bounds, object? Native);
+
+public readonly record struct ScreenPoint(int X, int Y);
+
+public interface IPlayerHost
+{
+ /// Resolve a UIA path with retry/timeout. Returns null if not found.
+ ResolvedElement? ResolveElement(string uiaPath, TimeSpan timeout);
+
+ /// Wait for a wait_for hint to be satisfied. Returns false on timeout.
+ bool WaitFor(string waitForHint, TimeSpan timeout);
+
+ void Click(ScreenPoint point);
+ void Type(string text);
+ void Drag(ScreenPoint from, ScreenPoint to);
+ void Hotkey(string keys);
+
+ void CaptureCheckpoint(int afterStep, string saveAs);
+ void CaptureFailureArtifacts(int stepIndex, string reason);
+}
diff --git a/src/Recordingtest.Player/Model/Scenario.cs b/src/Recordingtest.Player/Model/Scenario.cs
new file mode 100644
index 0000000..d98426e
--- /dev/null
+++ b/src/Recordingtest.Player/Model/Scenario.cs
@@ -0,0 +1,28 @@
+namespace Recordingtest.Player.Model;
+
+public sealed class Scenario
+{
+ public string Name { get; set; } = string.Empty;
+ public string Description { get; set; } = string.Empty;
+ public SutInfo Sut { get; set; } = new();
+ public List Steps { get; set; } = new();
+ public List Checkpoints { get; set; } = new();
+ public List Baselines { get; set; } = new();
+}
+
+public sealed class SutInfo
+{
+ public string Exe { get; set; } = string.Empty;
+ public int StartupTimeoutMs { get; set; } = 15000;
+}
+
+public sealed class Checkpoint
+{
+ public int AfterStep { get; set; }
+ public string SaveAs { get; set; } = string.Empty;
+}
+
+public sealed class Baseline
+{
+ public string Path { get; set; } = string.Empty;
+}
diff --git a/src/Recordingtest.Player/Model/Step.cs b/src/Recordingtest.Player/Model/Step.cs
new file mode 100644
index 0000000..3fecb13
--- /dev/null
+++ b/src/Recordingtest.Player/Model/Step.cs
@@ -0,0 +1,28 @@
+namespace Recordingtest.Player.Model;
+
+public enum StepKind
+{
+ Click,
+ Type,
+ Drag,
+ Hotkey,
+ Wait,
+ Checkpoint,
+ Save,
+}
+
+public sealed class Step
+{
+ public StepKind Kind { get; set; }
+ public Target? Target { get; set; }
+ public string? Value { get; set; }
+ public string? WaitFor { get; set; }
+ public int? AfterStep { get; set; }
+ public string? SaveAs { get; set; }
+}
+
+public sealed class Target
+{
+ public string UiaPath { get; set; } = string.Empty;
+ public double[] Offset { get; set; } = new double[] { 0.5, 0.5 };
+}
diff --git a/src/Recordingtest.Player/PlayerEngine.cs b/src/Recordingtest.Player/PlayerEngine.cs
new file mode 100644
index 0000000..7b6e9a1
--- /dev/null
+++ b/src/Recordingtest.Player/PlayerEngine.cs
@@ -0,0 +1,115 @@
+using Recordingtest.Player.Model;
+
+namespace Recordingtest.Player;
+
+public sealed class PlayerEngineOptions
+{
+ public TimeSpan ResolveTimeout { get; set; } = TimeSpan.FromSeconds(10);
+ public TimeSpan WaitForTimeout { get; set; } = TimeSpan.FromSeconds(15);
+}
+
+public sealed class PlayerEngine
+{
+ private readonly PlayerEngineOptions _options;
+
+ public PlayerEngine(PlayerEngineOptions? options = null)
+ {
+ _options = options ?? new PlayerEngineOptions();
+ }
+
+ public void Run(Scenario scenario, IPlayerHost host)
+ {
+ ArgumentNullException.ThrowIfNull(scenario);
+ ArgumentNullException.ThrowIfNull(host);
+
+ for (int i = 0; i < scenario.Steps.Count; i++)
+ {
+ var step = scenario.Steps[i];
+ try
+ {
+ ExecuteStep(i, step, host);
+ }
+ catch (Exception ex)
+ {
+ host.CaptureFailureArtifacts(i, ex.Message);
+ throw;
+ }
+ }
+ }
+
+ private void ExecuteStep(int index, Step step, IPlayerHost host)
+ {
+ if (step.Kind == StepKind.Checkpoint)
+ {
+ var after = step.AfterStep ?? index;
+ var saveAs = step.SaveAs ?? string.Empty;
+ host.CaptureCheckpoint(after, saveAs);
+ return;
+ }
+
+ if (!string.IsNullOrEmpty(step.WaitFor))
+ {
+ if (!host.WaitFor(step.WaitFor!, _options.WaitForTimeout))
+ {
+ throw new InvalidOperationException(
+ $"wait_for timeout: '{step.WaitFor}' at step {index}");
+ }
+ }
+
+ ResolvedElement? element = null;
+ ScreenPoint point = default;
+ if (step.Target is not null && !string.IsNullOrEmpty(step.Target.UiaPath))
+ {
+ element = host.ResolveElement(step.Target.UiaPath, _options.ResolveTimeout);
+ if (element is null)
+ {
+ throw new InvalidOperationException(
+ $"failed to resolve uia_path '{step.Target.UiaPath}' at step {index}");
+ }
+ point = ComputeScreenPoint(element.Value.Bounds, step.Target.Offset);
+ }
+
+ switch (step.Kind)
+ {
+ case StepKind.Click:
+ host.Click(point);
+ break;
+ case StepKind.Type:
+ host.Type(step.Value ?? string.Empty);
+ break;
+ case StepKind.Drag:
+ // value format: "dx_norm,dy_norm" relative to bounds
+ var to = point;
+ if (!string.IsNullOrEmpty(step.Value) && element is not null)
+ {
+ var parts = step.Value!.Split(',');
+ if (parts.Length == 2 &&
+ double.TryParse(parts[0], out var dx) &&
+ double.TryParse(parts[1], out var dy))
+ {
+ to = ComputeScreenPoint(element.Value.Bounds, new[] { dx, dy });
+ }
+ }
+ host.Drag(point, to);
+ break;
+ case StepKind.Hotkey:
+ host.Hotkey(step.Value ?? string.Empty);
+ break;
+ case StepKind.Wait:
+ // wait kind is satisfied by wait_for above; nothing else to do.
+ break;
+ case StepKind.Save:
+ host.Hotkey(step.Value ?? "ctrl+s");
+ break;
+ }
+ }
+
+ public static ScreenPoint ComputeScreenPoint(ElementBounds bounds, double[] offset)
+ {
+ var ox = offset.Length > 0 ? offset[0] : 0.5;
+ var oy = offset.Length > 1 ? offset[1] : 0.5;
+ var x = bounds.X + bounds.Width * ox;
+ var y = bounds.Y + bounds.Height * oy;
+ return new ScreenPoint((int)Math.Round(x), (int)Math.Round(y));
+ }
+}
diff --git a/src/Recordingtest.Player/Program.cs b/src/Recordingtest.Player/Program.cs
new file mode 100644
index 0000000..0813a9f
--- /dev/null
+++ b/src/Recordingtest.Player/Program.cs
@@ -0,0 +1,72 @@
+using Recordingtest.Player;
+using Recordingtest.Player.Model;
+
+string? scenarioPath = null;
+string outputDir = Path.Combine(Directory.GetCurrentDirectory(), "player-output");
+bool noLaunch = false;
+
+for (int i = 0; i < args.Length; i++)
+{
+ switch (args[i])
+ {
+ case "--scenario":
+ scenarioPath = args[++i];
+ break;
+ case "--output-dir":
+ outputDir = args[++i];
+ break;
+ case "--no-launch":
+ noLaunch = true;
+ break;
+ }
+}
+
+if (scenarioPath is null)
+{
+ Console.Error.WriteLine("usage: Recordingtest.Player --scenario [--output-dir ] [--no-launch]");
+ return 2;
+}
+
+Scenario scenario;
+try
+{
+ scenario = ScenarioLoader.LoadFromFile(scenarioPath);
+}
+catch (Exception ex)
+{
+ Console.Error.WriteLine($"failed to load scenario: {ex.Message}");
+ return 3;
+}
+
+var stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
+var artifactDir = Path.Combine(outputDir, "artifacts", scenario.Name, stamp);
+Directory.CreateDirectory(artifactDir);
+
+FlaUI.Core.Application? app = null;
+if (noLaunch)
+{
+ app = UiaPlayerHost.AttachByExeName(scenario.Sut.Exe);
+ if (app is null)
+ {
+ Console.Error.WriteLine($"--no-launch: SUT '{scenario.Sut.Exe}' not running. artifact_dir={artifactDir}");
+ return 4;
+ }
+}
+else
+{
+ Console.Error.WriteLine("launching SUT is disabled in this PoC sandbox; pass --no-launch.");
+ return 5;
+}
+
+using var host = new UiaPlayerHost(app, artifactDir);
+var engine = new PlayerEngine();
+try
+{
+ engine.Run(scenario, host);
+ return 0;
+}
+catch (Exception ex)
+{
+ Console.Error.WriteLine($"player failed: {ex.Message} artifact_dir={artifactDir}");
+ return 1;
+}
diff --git a/src/Recordingtest.Player/Recordingtest.Player.csproj b/src/Recordingtest.Player/Recordingtest.Player.csproj
new file mode 100644
index 0000000..343e6a4
--- /dev/null
+++ b/src/Recordingtest.Player/Recordingtest.Player.csproj
@@ -0,0 +1,15 @@
+
+
+ Exe
+ net8.0-windows
+ false
+ false
+ Recordingtest.Player
+ Recordingtest.Player
+
+
+
+
+
+
+
diff --git a/src/Recordingtest.Player/ScenarioLoader.cs b/src/Recordingtest.Player/ScenarioLoader.cs
new file mode 100644
index 0000000..2bf7008
--- /dev/null
+++ b/src/Recordingtest.Player/ScenarioLoader.cs
@@ -0,0 +1,24 @@
+using Recordingtest.Player.Model;
+using YamlDotNet.Serialization;
+using YamlDotNet.Serialization.NamingConventions;
+
+namespace Recordingtest.Player;
+
+public static class ScenarioLoader
+{
+ private static IDeserializer Build() =>
+ new DeserializerBuilder()
+ .WithNamingConvention(UnderscoredNamingConvention.Instance)
+ .IgnoreUnmatchedProperties()
+ .Build();
+
+ public static Scenario LoadFromFile(string path) =>
+ LoadFromString(File.ReadAllText(path));
+
+ public static Scenario LoadFromString(string yaml)
+ {
+ var de = Build();
+ var s = de.Deserialize(yaml);
+ return s ?? new Scenario();
+ }
+}
diff --git a/src/Recordingtest.Player/UiaPlayerHost.cs b/src/Recordingtest.Player/UiaPlayerHost.cs
new file mode 100644
index 0000000..d958067
--- /dev/null
+++ b/src/Recordingtest.Player/UiaPlayerHost.cs
@@ -0,0 +1,160 @@
+using System.Diagnostics;
+using FlaUI.Core;
+using FlaUI.Core.AutomationElements;
+using FlaUI.Core.Input;
+using FlaUI.Core.Tools;
+using FlaUI.Core.WindowsAPI;
+using FlaUI.UIA3;
+
+namespace Recordingtest.Player;
+
+///
+/// Real FlaUI/UIA implementation. Compile-only in PoC; not unit tested
+/// (real SUT is required). The engine talks to
+/// so all retry/timing semantics live in PlayerEngine, not here.
+///
+public sealed class UiaPlayerHost : IPlayerHost, IDisposable
+{
+ private readonly UIA3Automation _automation;
+ private readonly Application? _app;
+ private readonly string _artifactDir;
+
+ public UiaPlayerHost(Application? app, string artifactDir)
+ {
+ _automation = new UIA3Automation();
+ _app = app;
+ _artifactDir = artifactDir;
+ Directory.CreateDirectory(_artifactDir);
+ }
+
+ public ResolvedElement? ResolveElement(string uiaPath, TimeSpan timeout)
+ {
+ // Best-effort: search by AutomationId fragment in the last segment.
+ // A full UIA-path resolver is out of PoC scope; recorder produces
+ // simple AutomationId-based paths in the bootstrap scenarios.
+ var automationId = ExtractAutomationId(uiaPath);
+ var window = _app?.GetMainWindow(_automation, timeout);
+ if (window is null)
+ {
+ return null;
+ }
+
+ var element = Retry.WhileNull(
+ () =>
+ {
+ if (!string.IsNullOrEmpty(automationId))
+ {
+ return window.FindFirstDescendant(cf => cf.ByAutomationId(automationId));
+ }
+ return window.FindFirstDescendant();
+ },
+ timeout: timeout,
+ ignoreException: true).Result;
+
+ if (element is null)
+ {
+ return null;
+ }
+
+ var r = element.BoundingRectangle;
+ return new ResolvedElement(
+ new ElementBounds(r.X, r.Y, r.Width, r.Height),
+ element);
+ }
+
+ public bool WaitFor(string waitForHint, TimeSpan timeout)
+ {
+ // PoC: poll the main window's IsEnabled property as a generic readiness signal.
+ var result = Retry.WhileFalse(
+ () =>
+ {
+ var w = _app?.GetMainWindow(_automation, TimeSpan.FromSeconds(1));
+ return w is not null && w.IsEnabled;
+ },
+ timeout: timeout,
+ ignoreException: true);
+ return result.Result;
+ }
+
+ public void Click(ScreenPoint point) =>
+ Mouse.Click(new System.Drawing.Point(point.X, point.Y));
+
+ public void Type(string text) => Keyboard.Type(text);
+
+ public void Drag(ScreenPoint from, ScreenPoint to) =>
+ Mouse.Drag(
+ new System.Drawing.Point(from.X, from.Y),
+ new System.Drawing.Point(to.X, to.Y));
+
+ public void Hotkey(string keys)
+ {
+ // Minimal: support "ctrl+s" style.
+ var parts = keys.Split('+', StringSplitOptions.RemoveEmptyEntries);
+ var modifiers = new List();
+ VirtualKeyShort? main = null;
+ foreach (var p in parts)
+ {
+ switch (p.Trim().ToLowerInvariant())
+ {
+ case "ctrl": modifiers.Add(VirtualKeyShort.CONTROL); break;
+ case "shift": modifiers.Add(VirtualKeyShort.SHIFT); break;
+ case "alt": modifiers.Add(VirtualKeyShort.ALT); break;
+ default:
+ if (p.Length == 1)
+ {
+ main = (VirtualKeyShort)char.ToUpperInvariant(p[0]);
+ }
+ break;
+ }
+ }
+ foreach (var m in modifiers) Keyboard.Press(m);
+ if (main is not null) Keyboard.Type(main.Value);
+ foreach (var m in modifiers) Keyboard.Release(m);
+ }
+
+ public void CaptureCheckpoint(int afterStep, string saveAs)
+ {
+ var dest = Path.Combine(_artifactDir, $"checkpoint-{afterStep}{Path.GetExtension(saveAs)}");
+ if (File.Exists(saveAs))
+ {
+ File.Copy(saveAs, dest, overwrite: true);
+ }
+ else
+ {
+ File.WriteAllText(dest + ".missing", $"expected save file not found: {saveAs}");
+ }
+ }
+
+ public void CaptureFailureArtifacts(int stepIndex, string reason)
+ {
+ var log = Path.Combine(_artifactDir, "error.log");
+ File.AppendAllText(log,
+ $"[{DateTime.UtcNow:o}] step={stepIndex} reason={reason}{Environment.NewLine}");
+ }
+
+ private static string ExtractAutomationId(string uiaPath)
+ {
+ // Look for [@AutomationId='...'] in the last segment.
+ var marker = "@AutomationId='";
+ var idx = uiaPath.LastIndexOf(marker, StringComparison.Ordinal);
+ if (idx < 0) return string.Empty;
+ var start = idx + marker.Length;
+ var end = uiaPath.IndexOf('\'', start);
+ if (end < 0) return string.Empty;
+ return uiaPath.Substring(start, end - start);
+ }
+
+ public void Dispose()
+ {
+ _automation.Dispose();
+ _app?.Dispose();
+ }
+
+ public static Application? AttachByExeName(string exeName)
+ {
+ var name = Path.GetFileNameWithoutExtension(exeName);
+ var procs = Process.GetProcessesByName(name);
+ if (procs.Length == 0) return null;
+ return Application.Attach(procs[0]);
+ }
+}
diff --git a/tests/Recordingtest.Player.Tests/FakePlayerHost.cs b/tests/Recordingtest.Player.Tests/FakePlayerHost.cs
new file mode 100644
index 0000000..67c387a
--- /dev/null
+++ b/tests/Recordingtest.Player.Tests/FakePlayerHost.cs
@@ -0,0 +1,38 @@
+namespace Recordingtest.Player.Tests;
+
+internal sealed class FakePlayerHost : IPlayerHost
+{
+ public Func ResolveImpl { get; set; } =
+ _ => new ResolvedElement(new ElementBounds(100, 200, 50, 40), null);
+ public Func WaitForImpl { get; set; } = _ => true;
+
+ public List Clicks { get; } = new();
+ public List Types { get; } = new();
+ public List<(ScreenPoint From, ScreenPoint To)> Drags { get; } = new();
+ public List Hotkeys { get; } = new();
+ public List<(int AfterStep, string SaveAs)> Checkpoints { get; } = new();
+ public List<(int StepIndex, string Reason)> Failures { get; } = new();
+ public List Resolved { get; } = new();
+ public List WaitedFor { get; } = new();
+
+ public ResolvedElement? ResolveElement(string uiaPath, TimeSpan timeout)
+ {
+ Resolved.Add(uiaPath);
+ return ResolveImpl(uiaPath);
+ }
+
+ public bool WaitFor(string waitForHint, TimeSpan timeout)
+ {
+ WaitedFor.Add(waitForHint);
+ return WaitForImpl(waitForHint);
+ }
+
+ public void Click(ScreenPoint point) => Clicks.Add(point);
+ public void Type(string text) => Types.Add(text);
+ public void Drag(ScreenPoint from, ScreenPoint to) => Drags.Add((from, to));
+ public void Hotkey(string keys) => Hotkeys.Add(keys);
+ public void CaptureCheckpoint(int afterStep, string saveAs) =>
+ Checkpoints.Add((afterStep, saveAs));
+ public void CaptureFailureArtifacts(int stepIndex, string reason) =>
+ Failures.Add((stepIndex, reason));
+}
diff --git a/tests/Recordingtest.Player.Tests/PlayerEngineTests.cs b/tests/Recordingtest.Player.Tests/PlayerEngineTests.cs
new file mode 100644
index 0000000..9af1de4
--- /dev/null
+++ b/tests/Recordingtest.Player.Tests/PlayerEngineTests.cs
@@ -0,0 +1,163 @@
+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);
+ }
+
+ 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");
+ }
+}
diff --git a/tests/Recordingtest.Player.Tests/Recordingtest.Player.Tests.csproj b/tests/Recordingtest.Player.Tests/Recordingtest.Player.Tests.csproj
new file mode 100644
index 0000000..79833d4
--- /dev/null
+++ b/tests/Recordingtest.Player.Tests/Recordingtest.Player.Tests.csproj
@@ -0,0 +1,16 @@
+
+
+ net8.0-windows
+ false
+ Recordingtest.Player.Tests
+ Recordingtest.Player.Tests
+
+
+
+
+
+
+
+
+
+