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 + + + + + + + + + +