diff --git a/recordingtest.sln b/recordingtest.sln
index c63bf8c..6f58f37 100644
--- a/recordingtest.sln
+++ b/recordingtest.sln
@@ -57,6 +57,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Hmeg.Catalog.
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Architecture.Tests", "tests\Recordingtest.Architecture.Tests\Recordingtest.Architecture.Tests.csproj", "{D35B233B-267B-40DB-87EF-689AEE5C9399}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.LauncherUI", "src\Recordingtest.LauncherUI\Recordingtest.LauncherUI.csproj", "{7D85A705-8410-41FC-AFB9-10B8F9D2DDF6}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -343,6 +345,18 @@ Global
{D35B233B-267B-40DB-87EF-689AEE5C9399}.Release|x64.Build.0 = Release|Any CPU
{D35B233B-267B-40DB-87EF-689AEE5C9399}.Release|x86.ActiveCfg = Release|Any CPU
{D35B233B-267B-40DB-87EF-689AEE5C9399}.Release|x86.Build.0 = Release|Any CPU
+ {7D85A705-8410-41FC-AFB9-10B8F9D2DDF6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7D85A705-8410-41FC-AFB9-10B8F9D2DDF6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7D85A705-8410-41FC-AFB9-10B8F9D2DDF6}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {7D85A705-8410-41FC-AFB9-10B8F9D2DDF6}.Debug|x64.Build.0 = Debug|Any CPU
+ {7D85A705-8410-41FC-AFB9-10B8F9D2DDF6}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {7D85A705-8410-41FC-AFB9-10B8F9D2DDF6}.Debug|x86.Build.0 = Debug|Any CPU
+ {7D85A705-8410-41FC-AFB9-10B8F9D2DDF6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7D85A705-8410-41FC-AFB9-10B8F9D2DDF6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7D85A705-8410-41FC-AFB9-10B8F9D2DDF6}.Release|x64.ActiveCfg = Release|Any CPU
+ {7D85A705-8410-41FC-AFB9-10B8F9D2DDF6}.Release|x64.Build.0 = Release|Any CPU
+ {7D85A705-8410-41FC-AFB9-10B8F9D2DDF6}.Release|x86.ActiveCfg = Release|Any CPU
+ {7D85A705-8410-41FC-AFB9-10B8F9D2DDF6}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -373,5 +387,6 @@ Global
{A9894277-E1F3-4B86-AAE4-041116FBBE1D} = {7CC28442-33DD-D811-CEDA-9CC787317768}
{3D981C63-0D1E-466C-9BD6-3DAF46936A45} = {7CC28442-33DD-D811-CEDA-9CC787317768}
{D35B233B-267B-40DB-87EF-689AEE5C9399} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ {7D85A705-8410-41FC-AFB9-10B8F9D2DDF6} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection
EndGlobal
diff --git a/src/Recordingtest.LauncherUI/App.xaml b/src/Recordingtest.LauncherUI/App.xaml
new file mode 100644
index 0000000..a2caff4
--- /dev/null
+++ b/src/Recordingtest.LauncherUI/App.xaml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
diff --git a/src/Recordingtest.LauncherUI/App.xaml.cs b/src/Recordingtest.LauncherUI/App.xaml.cs
new file mode 100644
index 0000000..c812c06
--- /dev/null
+++ b/src/Recordingtest.LauncherUI/App.xaml.cs
@@ -0,0 +1,5 @@
+using System.Windows;
+
+namespace Recordingtest.LauncherUI;
+
+public partial class App : Application { }
diff --git a/src/Recordingtest.LauncherUI/MainWindow.xaml b/src/Recordingtest.LauncherUI/MainWindow.xaml
new file mode 100644
index 0000000..2d39ddf
--- /dev/null
+++ b/src/Recordingtest.LauncherUI/MainWindow.xaml
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Recordingtest.LauncherUI/MainWindow.xaml.cs b/src/Recordingtest.LauncherUI/MainWindow.xaml.cs
new file mode 100644
index 0000000..6f0c788
--- /dev/null
+++ b/src/Recordingtest.LauncherUI/MainWindow.xaml.cs
@@ -0,0 +1,219 @@
+using System.Diagnostics;
+using System.IO;
+using System.Text;
+using System.Windows;
+using System.Windows.Media;
+using Microsoft.Win32;
+using Recordingtest.Player;
+using Recordingtest.Player.Model;
+
+namespace Recordingtest.LauncherUI;
+
+public partial class MainWindow : Window
+{
+ private CancellationTokenSource? _cts;
+ private bool _refreshingPath;
+
+ public MainWindow()
+ {
+ InitializeComponent();
+ ScenariosPathBox.Text = FindScenariosDir();
+ RefreshScenarioList();
+ RefreshSutStatus();
+ }
+
+ // ββ Scenario folder βββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ private static string FindScenariosDir()
+ {
+ var candidates = new[]
+ {
+ Path.Combine(AppContext.BaseDirectory, "scenarios"),
+ Path.Combine(Directory.GetCurrentDirectory(), "scenarios"),
+ // walk up 3 levels from exe (dev layout: bin/Debug/net8.0-windows)
+ Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "scenarios")),
+ Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "scenarios")),
+ };
+ foreach (var c in candidates)
+ if (Directory.Exists(c)) return c;
+ return Path.Combine(Directory.GetCurrentDirectory(), "scenarios");
+ }
+
+ private void RefreshScenarioList()
+ {
+ var dir = ScenariosPathBox.Text?.Trim() ?? "";
+ ScenarioListBox.Items.Clear();
+ if (!Directory.Exists(dir)) return;
+ foreach (var f in Directory.GetFiles(dir, "*.yaml").OrderBy(x => x))
+ ScenarioListBox.Items.Add(Path.GetFileNameWithoutExtension(f));
+ if (ScenarioListBox.Items.Count > 0)
+ ScenarioListBox.SelectedIndex = ScenarioListBox.Items.Count - 1; // select latest
+ }
+
+ private void ScenariosPath_TextChanged(object sender,
+ System.Windows.Controls.TextChangedEventArgs e)
+ {
+ if (_refreshingPath) return;
+ RefreshScenarioList();
+ }
+
+ private void BrowseScenarios_Click(object sender, RoutedEventArgs e)
+ {
+ var dlg = new OpenFolderDialog { Title = "μλλ¦¬μ€ ν΄λ μ ν" };
+ if (dlg.ShowDialog() == true)
+ {
+ _refreshingPath = true;
+ ScenariosPathBox.Text = dlg.FolderName;
+ _refreshingPath = false;
+ RefreshScenarioList();
+ }
+ }
+
+ private void RefreshScenarios_Click(object sender, RoutedEventArgs e) =>
+ RefreshScenarioList();
+
+ // ββ SUT status βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ private void RefreshSutStatus()
+ {
+ var procs = Process.GetProcessesByName("EG-BIM Modeler");
+ if (procs.Length > 0)
+ {
+ SutStatusText.Text = $"β EG-BIM Modeler μ€ν μ€ (PID {procs[0].Id})";
+ SutStatusText.Foreground = new SolidColorBrush(Color.FromRgb(0x2E, 0x7D, 0x32));
+ }
+ else
+ {
+ SutStatusText.Text = "β SUT μμ β EG-BIM Modelerλ₯Ό λ¨Όμ μ€ννμΈμ";
+ SutStatusText.Foreground = new SolidColorBrush(Color.FromRgb(0xC6, 0x28, 0x28));
+ }
+ }
+
+ private void RefreshSut_Click(object sender, RoutedEventArgs e) => RefreshSutStatus();
+
+ // ββ Run / Stop βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ private async void Run_Click(object sender, RoutedEventArgs e)
+ {
+ if (ScenarioListBox.SelectedItem is not string scenarioName)
+ {
+ AppendLog("[warn] μλ리μ€λ₯Ό μ ννμΈμ.");
+ return;
+ }
+
+ var procs = Process.GetProcessesByName("EG-BIM Modeler");
+ if (procs.Length == 0)
+ {
+ AppendLog("[error] EG-BIM Modelerκ° μ€ν μ€μ΄ μλλλ€.");
+ return;
+ }
+
+ RunButton.IsEnabled = false;
+ StopButton.IsEnabled = true;
+ LogBox.Clear();
+ AppendLog($"[launcher] μλ리μ€: {scenarioName}");
+
+ // 3-second countdown so user can move cursor away
+ CountdownText.Visibility = Visibility.Visible;
+ for (int i = 3; i >= 1; i--)
+ {
+ CountdownText.Text = $"βΆ {i}μ΄ ν μμ...";
+ await Task.Delay(1000);
+ }
+ CountdownText.Visibility = Visibility.Collapsed;
+
+ // Minimize launcher so it can't steal focus during playback
+ var prevState = WindowState;
+ WindowState = WindowState.Minimized;
+
+ _cts = new CancellationTokenSource();
+ var scenariosDir = ScenariosPathBox.Text?.Trim() ?? "";
+
+ try
+ {
+ await Task.Run(() => RunScenario(scenarioName, scenariosDir, _cts.Token));
+ }
+ catch (OperationCanceledException)
+ {
+ AppendLog("[launcher] μ€λ¨λ¨.");
+ }
+ catch (Exception ex)
+ {
+ AppendLog($"[launcher] μ€λ₯: {ex.Message}");
+ }
+ finally
+ {
+ WindowState = prevState;
+ RunButton.IsEnabled = true;
+ StopButton.IsEnabled = false;
+ }
+ }
+
+ private void Stop_Click(object sender, RoutedEventArgs e) => _cts?.Cancel();
+
+ // ββ Playback βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ private void RunScenario(string scenarioName, string scenariosDir,
+ CancellationToken ct)
+ {
+ var yamlPath = Path.Combine(scenariosDir, scenarioName + ".yaml");
+ if (!File.Exists(yamlPath))
+ {
+ AppendLog($"[error] νμΌ μμ: {yamlPath}");
+ return;
+ }
+
+ Scenario scenario;
+ try { scenario = ScenarioLoader.LoadFromFile(yamlPath); }
+ catch (Exception ex) { AppendLog($"[error] yaml νμ± μ€ν¨: {ex.Message}"); return; }
+
+ var app = UiaPlayerHost.AttachByExeName("EG-BIM Modeler.exe");
+ if (app is null)
+ {
+ AppendLog("[error] EG-BIM Modeler νλ‘μΈμ€μ μ°κ²°ν μ μμ΅λλ€.");
+ return;
+ }
+
+ var artifactDir = Path.Combine("artifacts", "launcher-out", scenarioName);
+ Directory.CreateDirectory(artifactDir);
+
+ using var host = new UiaPlayerHost(app, artifactDir);
+
+ // Redirect Console.WriteLine β WPF log box
+ var prevOut = Console.Out;
+ Console.SetOut(new DispatcherWriter(AppendLog));
+ try
+ {
+ host.BringSutToForeground();
+ ct.ThrowIfCancellationRequested();
+
+ var engine = new PlayerEngine(new PlayerEngineOptions { PreserveTiming = true });
+ engine.Run(scenario, host);
+
+ AppendLog($"[launcher] β {scenarioName} μλ£.");
+ }
+ finally
+ {
+ Console.SetOut(prevOut);
+ }
+ }
+
+ // ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ private void AppendLog(string msg) =>
+ Dispatcher.Invoke(() =>
+ {
+ LogBox.AppendText(msg + "\n");
+ LogScroll.ScrollToBottom();
+ });
+
+ /// Redirects Console.WriteLine output to the WPF log box.
+ private sealed class DispatcherWriter : TextWriter
+ {
+ private readonly Action _log;
+ public override Encoding Encoding => Encoding.UTF8;
+ public DispatcherWriter(Action log) => _log = log;
+ public override void WriteLine(string? value) => _log(value ?? "");
+ public override void Write(string? value) { /* absorb partial writes */ }
+ }
+}
diff --git a/src/Recordingtest.LauncherUI/Recordingtest.LauncherUI.csproj b/src/Recordingtest.LauncherUI/Recordingtest.LauncherUI.csproj
new file mode 100644
index 0000000..1be70f4
--- /dev/null
+++ b/src/Recordingtest.LauncherUI/Recordingtest.LauncherUI.csproj
@@ -0,0 +1,16 @@
+
+
+ WinExe
+ net8.0-windows
+ true
+ Recordingtest.LauncherUI
+ Recordingtest.LauncherUI
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/src/Recordingtest.Player/UiaPlayerHost.cs b/src/Recordingtest.Player/UiaPlayerHost.cs
index ff1c3ac..0796b8a 100644
--- a/src/Recordingtest.Player/UiaPlayerHost.cs
+++ b/src/Recordingtest.Player/UiaPlayerHost.cs
@@ -67,10 +67,17 @@ public sealed class UiaPlayerHost : IPlayerHost, IDisposable
return result.Result;
}
- public void Click(ScreenPoint point) =>
+ public void Click(ScreenPoint point)
+ {
+ EnsureSutForegroundQuick();
Mouse.Click(new System.Drawing.Point(point.X, point.Y));
+ }
- public void Type(string text) => Keyboard.Type(text);
+ public void Type(string text)
+ {
+ EnsureSutForegroundQuick();
+ Keyboard.Type(text);
+ }
public void Drag(ScreenPoint from, ScreenPoint to) =>
Mouse.Drag(
@@ -126,6 +133,7 @@ public sealed class UiaPlayerHost : IPlayerHost, IDisposable
public void Hotkey(string keys)
{
+ EnsureSutForegroundQuick();
var parsed = ParseHotkey(keys);
foreach (var m in parsed.Modifiers) Keyboard.Press(m);
if (parsed.Main is not null) Keyboard.Type(parsed.Main.Value);
@@ -215,36 +223,51 @@ public sealed class UiaPlayerHost : IPlayerHost, IDisposable
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern IntPtr GetForegroundWindow();
+ [System.Runtime.InteropServices.DllImport("user32.dll")]
+ private static extern bool SetForegroundWindow(IntPtr hWnd);
+
+ // Cached SUT HWND after first BringSutToForeground call.
+ private IntPtr _sutHwnd = IntPtr.Zero;
+
public void BringSutToForeground()
{
try
{
var w = _app?.GetMainWindow(_automation, TimeSpan.FromSeconds(5));
if (w is null) return;
- var targetHwnd = w.Properties.NativeWindowHandle.ValueOrDefault;
+ _sutHwnd = w.Properties.NativeWindowHandle.ValueOrDefault;
try { w.SetForeground(); } catch { /* best-effort */ }
try { w.Focus(); } catch { /* best-effort */ }
// Issue #14 follow-up: active wait instead of fixed 600ms sleep.
- // Poll until the OS reports the SUT as the foreground window, up
- // to 2s. Previously a 600ms fixed sleep was threshold-sensitive
- // and caused the first "BOX" keystroke to get dropped on a cold
- // first run.
var deadline = DateTime.UtcNow.AddSeconds(2);
while (DateTime.UtcNow < deadline)
{
- if (targetHwnd != IntPtr.Zero && GetForegroundWindow() == targetHwnd)
+ if (_sutHwnd != IntPtr.Zero && GetForegroundWindow() == _sutHwnd)
break;
System.Threading.Thread.Sleep(25);
}
- // Tiny additional settle for the OS keyboard-focus IPC to finish
- // after the foreground transition is observed.
System.Threading.Thread.Sleep(100);
}
- catch
+ catch { /* best-effort */ }
+ }
+
+ ///
+ /// Re-ensure SUT is in foreground before each input step.
+ /// Called from Click/Type/Hotkey. Quick check (β€300ms) so it
+ /// doesn't slow normal playback when focus is already correct.
+ ///
+ private void EnsureSutForegroundQuick()
+ {
+ if (_sutHwnd == IntPtr.Zero) return;
+ if (GetForegroundWindow() == _sutHwnd) return;
+
+ SetForegroundWindow(_sutHwnd);
+ var deadline = DateTime.UtcNow.AddMilliseconds(300);
+ while (DateTime.UtcNow < deadline)
{
- // best-effort; if this fails the user will see the BOX text land
- // in the wrong window and can re-run with the SUT focused manually.
+ if (GetForegroundWindow() == _sutHwnd) break;
+ System.Threading.Thread.Sleep(20);
}
}