Implement player PoC (#7)

This commit is contained in:
minsung
2026-04-07 14:28:11 +09:00
parent d486cbb4d9
commit f17e764678
12 changed files with 727 additions and 0 deletions

View 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)

View 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);
}

View 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;
}

View 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 };
}

View 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));
}
}

View 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;
}

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

View 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();
}
}

View 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]);
}
}

View 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));
}

View 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");
}
}

View File

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