Implement player PoC (#7)
This commit is contained in:
40
docs/history/2026-04-07_이슈7-player-generator.md
Normal file
40
docs/history/2026-04-07_이슈7-player-generator.md
Normal file
@@ -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)
|
||||
28
src/Recordingtest.Player/IPlayerHost.cs
Normal file
28
src/Recordingtest.Player/IPlayerHost.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using Recordingtest.Player.Model;
|
||||
|
||||
namespace Recordingtest.Player;
|
||||
|
||||
/// <summary>Element bounds in screen pixels.</summary>
|
||||
public readonly record struct ElementBounds(double X, double Y, double Width, double Height);
|
||||
|
||||
/// <summary>Resolved element handle (opaque to the engine).</summary>
|
||||
public readonly record struct ResolvedElement(ElementBounds Bounds, object? Native);
|
||||
|
||||
public readonly record struct ScreenPoint(int X, int Y);
|
||||
|
||||
public interface IPlayerHost
|
||||
{
|
||||
/// <summary>Resolve a UIA path with retry/timeout. Returns null if not found.</summary>
|
||||
ResolvedElement? ResolveElement(string uiaPath, TimeSpan timeout);
|
||||
|
||||
/// <summary>Wait for a wait_for hint to be satisfied. Returns false on timeout.</summary>
|
||||
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);
|
||||
}
|
||||
28
src/Recordingtest.Player/Model/Scenario.cs
Normal file
28
src/Recordingtest.Player/Model/Scenario.cs
Normal file
@@ -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<Step> Steps { get; set; } = new();
|
||||
public List<Checkpoint> Checkpoints { get; set; } = new();
|
||||
public List<Baseline> 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;
|
||||
}
|
||||
28
src/Recordingtest.Player/Model/Step.cs
Normal file
28
src/Recordingtest.Player/Model/Step.cs
Normal file
@@ -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 };
|
||||
}
|
||||
115
src/Recordingtest.Player/PlayerEngine.cs
Normal file
115
src/Recordingtest.Player/PlayerEngine.cs
Normal file
@@ -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));
|
||||
}
|
||||
}
|
||||
72
src/Recordingtest.Player/Program.cs
Normal file
72
src/Recordingtest.Player/Program.cs
Normal file
@@ -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 <path> [--output-dir <path>] [--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;
|
||||
}
|
||||
15
src/Recordingtest.Player/Recordingtest.Player.csproj
Normal file
15
src/Recordingtest.Player/Recordingtest.Player.csproj
Normal file
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<UseWPF>false</UseWPF>
|
||||
<UseWindowsForms>false</UseWindowsForms>
|
||||
<AssemblyName>Recordingtest.Player</AssemblyName>
|
||||
<RootNamespace>Recordingtest.Player</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FlaUI.Core" Version="4.0.0" />
|
||||
<PackageReference Include="FlaUI.UIA3" Version="4.0.0" />
|
||||
<PackageReference Include="YamlDotNet" Version="16.1.3" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
24
src/Recordingtest.Player/ScenarioLoader.cs
Normal file
24
src/Recordingtest.Player/ScenarioLoader.cs
Normal file
@@ -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<Scenario>(yaml);
|
||||
return s ?? new Scenario();
|
||||
}
|
||||
}
|
||||
160
src/Recordingtest.Player/UiaPlayerHost.cs
Normal file
160
src/Recordingtest.Player/UiaPlayerHost.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Real FlaUI/UIA implementation. Compile-only in PoC; not unit tested
|
||||
/// (real SUT is required). The engine talks to <see cref="IPlayerHost"/>
|
||||
/// so all retry/timing semantics live in PlayerEngine, not here.
|
||||
/// </summary>
|
||||
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>();
|
||||
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]);
|
||||
}
|
||||
}
|
||||
38
tests/Recordingtest.Player.Tests/FakePlayerHost.cs
Normal file
38
tests/Recordingtest.Player.Tests/FakePlayerHost.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
namespace Recordingtest.Player.Tests;
|
||||
|
||||
internal sealed class FakePlayerHost : IPlayerHost
|
||||
{
|
||||
public Func<string, ResolvedElement?> ResolveImpl { get; set; } =
|
||||
_ => new ResolvedElement(new ElementBounds(100, 200, 50, 40), null);
|
||||
public Func<string, bool> WaitForImpl { get; set; } = _ => true;
|
||||
|
||||
public List<ScreenPoint> Clicks { get; } = new();
|
||||
public List<string> Types { get; } = new();
|
||||
public List<(ScreenPoint From, ScreenPoint To)> Drags { get; } = new();
|
||||
public List<string> Hotkeys { get; } = new();
|
||||
public List<(int AfterStep, string SaveAs)> Checkpoints { get; } = new();
|
||||
public List<(int StepIndex, string Reason)> Failures { get; } = new();
|
||||
public List<string> Resolved { get; } = new();
|
||||
public List<string> 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));
|
||||
}
|
||||
163
tests/Recordingtest.Player.Tests/PlayerEngineTests.cs
Normal file
163
tests/Recordingtest.Player.Tests/PlayerEngineTests.cs
Normal file
@@ -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<InvalidOperationException>(
|
||||
() => 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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>Recordingtest.Player.Tests</RootNamespace>
|
||||
<AssemblyName>Recordingtest.Player.Tests</AssemblyName>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Recordingtest.Player\Recordingtest.Player.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user