feat: WPF LauncherUI + per-step SUT focus enforcement (#14)
LauncherUI (src/Recordingtest.LauncherUI/): - 시나리오 폴더 브라우저 + 목록 선택 - SUT(EG-BIM Modeler) 프로세스 자동 탐지 + 상태 표시 - 3초 카운트다운 후 런처 최소화 → 재생 시작 - 재생 중 Console.WriteLine → WPF 로그 박스 실시간 출력 - 중단(Ctrl+C 대체) 버튼 UiaPlayerHost: - _sutHwnd 캐싱 (BringSutToForeground 이후) - EnsureSutForegroundQuick(): Click/Type/Hotkey 직전 포커스 재확인 (GetForegroundWindow != sutHwnd 시 SetForegroundWindow + 300ms 대기) - 매 입력 스텝마다 SUT가 포커스를 잃으면 자동 복구 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -57,6 +57,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Hmeg.Catalog.
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Architecture.Tests", "tests\Recordingtest.Architecture.Tests\Recordingtest.Architecture.Tests.csproj", "{D35B233B-267B-40DB-87EF-689AEE5C9399}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Architecture.Tests", "tests\Recordingtest.Architecture.Tests\Recordingtest.Architecture.Tests.csproj", "{D35B233B-267B-40DB-87EF-689AEE5C9399}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.LauncherUI", "src\Recordingtest.LauncherUI\Recordingtest.LauncherUI.csproj", "{7D85A705-8410-41FC-AFB9-10B8F9D2DDF6}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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|x64.Build.0 = Release|Any CPU
|
||||||
{D35B233B-267B-40DB-87EF-689AEE5C9399}.Release|x86.ActiveCfg = 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
|
{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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -373,5 +387,6 @@ Global
|
|||||||
{A9894277-E1F3-4B86-AAE4-041116FBBE1D} = {7CC28442-33DD-D811-CEDA-9CC787317768}
|
{A9894277-E1F3-4B86-AAE4-041116FBBE1D} = {7CC28442-33DD-D811-CEDA-9CC787317768}
|
||||||
{3D981C63-0D1E-466C-9BD6-3DAF46936A45} = {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}
|
{D35B233B-267B-40DB-87EF-689AEE5C9399} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
|
{7D85A705-8410-41FC-AFB9-10B8F9D2DDF6} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
16
src/Recordingtest.LauncherUI/App.xaml
Normal file
16
src/Recordingtest.LauncherUI/App.xaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<Application x:Class="Recordingtest.LauncherUI.App"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
StartupUri="MainWindow.xaml">
|
||||||
|
<Application.Resources>
|
||||||
|
<Style TargetType="Button">
|
||||||
|
<Setter Property="Padding" Value="12,6"/>
|
||||||
|
<Setter Property="Margin" Value="4,2"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
</Style>
|
||||||
|
<Style TargetType="TextBox">
|
||||||
|
<Setter Property="Padding" Value="4,3"/>
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center"/>
|
||||||
|
</Style>
|
||||||
|
</Application.Resources>
|
||||||
|
</Application>
|
||||||
5
src/Recordingtest.LauncherUI/App.xaml.cs
Normal file
5
src/Recordingtest.LauncherUI/App.xaml.cs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace Recordingtest.LauncherUI;
|
||||||
|
|
||||||
|
public partial class App : Application { }
|
||||||
87
src/Recordingtest.LauncherUI/MainWindow.xaml
Normal file
87
src/Recordingtest.LauncherUI/MainWindow.xaml
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<Window x:Class="Recordingtest.LauncherUI.MainWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
Title="Recordingtest Launcher" Width="720" Height="620"
|
||||||
|
MinWidth="560" MinHeight="420"
|
||||||
|
WindowStartupLocation="CenterScreen">
|
||||||
|
<Grid Margin="8">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="180"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<Border Grid.Row="0" Background="#1E3A5F" CornerRadius="4" Margin="0,0,0,8" Padding="12,8">
|
||||||
|
<TextBlock Text="🎬 Recordingtest Launcher"
|
||||||
|
Foreground="White" FontSize="16" FontWeight="SemiBold"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Scenarios folder row -->
|
||||||
|
<Grid Grid.Row="1" Margin="0,0,0,4">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Grid.Column="0" Text="시나리오 폴더:" VerticalAlignment="Center" Margin="0,0,8,0"/>
|
||||||
|
<TextBox Grid.Column="1" x:Name="ScenariosPathBox"
|
||||||
|
TextChanged="ScenariosPath_TextChanged"/>
|
||||||
|
<Button Grid.Column="2" Content="📂" Click="BrowseScenarios_Click"
|
||||||
|
ToolTip="폴더 선택" Padding="8,4"/>
|
||||||
|
<Button Grid.Column="3" Content="↻" Click="RefreshScenarios_Click"
|
||||||
|
ToolTip="목록 새로고침" Padding="8,4"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Scenario list -->
|
||||||
|
<ListBox Grid.Row="2" x:Name="ScenarioListBox"
|
||||||
|
Margin="0,0,0,4"
|
||||||
|
FontFamily="Consolas" FontSize="12"
|
||||||
|
ScrollViewer.VerticalScrollBarVisibility="Auto"/>
|
||||||
|
|
||||||
|
<!-- SUT status -->
|
||||||
|
<Border Grid.Row="3" BorderBrush="#DDD" BorderThickness="1"
|
||||||
|
CornerRadius="3" Padding="8,6" Margin="0,0,0,6">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Grid.Column="0" Text="SUT:" FontWeight="SemiBold"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,8,0"/>
|
||||||
|
<TextBlock Grid.Column="1" x:Name="SutStatusText"
|
||||||
|
VerticalAlignment="Center" TextWrapping="Wrap"/>
|
||||||
|
<Button Grid.Column="2" Content="새로고침" Click="RefreshSut_Click"
|
||||||
|
Padding="8,3" Margin="8,0,0,0"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Action buttons + countdown -->
|
||||||
|
<StackPanel Grid.Row="4" Orientation="Horizontal" Margin="0,0,0,6">
|
||||||
|
<Button x:Name="RunButton" Content="▶ 실행" Click="Run_Click"
|
||||||
|
Background="#2196F3" Foreground="White"
|
||||||
|
FontWeight="SemiBold" Width="120"/>
|
||||||
|
<Button x:Name="StopButton" Content="⏹ 중단" Click="Stop_Click"
|
||||||
|
Background="#F44336" Foreground="White"
|
||||||
|
IsEnabled="False" Width="90"/>
|
||||||
|
<TextBlock x:Name="CountdownText" VerticalAlignment="Center"
|
||||||
|
FontSize="14" FontWeight="Bold" Foreground="#E65100"
|
||||||
|
Margin="16,0,0,0" Visibility="Collapsed"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Log output -->
|
||||||
|
<Border Grid.Row="5" BorderBrush="#CCC" BorderThickness="1" CornerRadius="3">
|
||||||
|
<ScrollViewer x:Name="LogScroll" VerticalScrollBarVisibility="Auto">
|
||||||
|
<TextBox x:Name="LogBox" IsReadOnly="True"
|
||||||
|
FontFamily="Consolas" FontSize="11"
|
||||||
|
TextWrapping="Wrap" Background="#FAFAFA"
|
||||||
|
BorderThickness="0" Padding="6"/>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
219
src/Recordingtest.LauncherUI/MainWindow.xaml.cs
Normal file
219
src/Recordingtest.LauncherUI/MainWindow.xaml.cs
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// <summary>Redirects Console.WriteLine output to the WPF log box.</summary>
|
||||||
|
private sealed class DispatcherWriter : TextWriter
|
||||||
|
{
|
||||||
|
private readonly Action<string> _log;
|
||||||
|
public override Encoding Encoding => Encoding.UTF8;
|
||||||
|
public DispatcherWriter(Action<string> log) => _log = log;
|
||||||
|
public override void WriteLine(string? value) => _log(value ?? "");
|
||||||
|
public override void Write(string? value) { /* absorb partial writes */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/Recordingtest.LauncherUI/Recordingtest.LauncherUI.csproj
Normal file
16
src/Recordingtest.LauncherUI/Recordingtest.LauncherUI.csproj
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net8.0-windows</TargetFramework>
|
||||||
|
<UseWPF>true</UseWPF>
|
||||||
|
<AssemblyName>Recordingtest.LauncherUI</AssemblyName>
|
||||||
|
<RootNamespace>Recordingtest.LauncherUI</RootNamespace>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<ApplicationIcon />
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Recordingtest.Player\Recordingtest.Player.csproj" />
|
||||||
|
<ProjectReference Include="..\Recordingtest.Runner\Recordingtest.Runner.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -67,10 +67,17 @@ public sealed class UiaPlayerHost : IPlayerHost, IDisposable
|
|||||||
return result.Result;
|
return result.Result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Click(ScreenPoint point) =>
|
public void Click(ScreenPoint point)
|
||||||
|
{
|
||||||
|
EnsureSutForegroundQuick();
|
||||||
Mouse.Click(new System.Drawing.Point(point.X, point.Y));
|
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) =>
|
public void Drag(ScreenPoint from, ScreenPoint to) =>
|
||||||
Mouse.Drag(
|
Mouse.Drag(
|
||||||
@@ -126,6 +133,7 @@ public sealed class UiaPlayerHost : IPlayerHost, IDisposable
|
|||||||
|
|
||||||
public void Hotkey(string keys)
|
public void Hotkey(string keys)
|
||||||
{
|
{
|
||||||
|
EnsureSutForegroundQuick();
|
||||||
var parsed = ParseHotkey(keys);
|
var parsed = ParseHotkey(keys);
|
||||||
foreach (var m in parsed.Modifiers) Keyboard.Press(m);
|
foreach (var m in parsed.Modifiers) Keyboard.Press(m);
|
||||||
if (parsed.Main is not null) Keyboard.Type(parsed.Main.Value);
|
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")]
|
[System.Runtime.InteropServices.DllImport("user32.dll")]
|
||||||
private static extern IntPtr GetForegroundWindow();
|
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()
|
public void BringSutToForeground()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var w = _app?.GetMainWindow(_automation, TimeSpan.FromSeconds(5));
|
var w = _app?.GetMainWindow(_automation, TimeSpan.FromSeconds(5));
|
||||||
if (w is null) return;
|
if (w is null) return;
|
||||||
var targetHwnd = w.Properties.NativeWindowHandle.ValueOrDefault;
|
_sutHwnd = w.Properties.NativeWindowHandle.ValueOrDefault;
|
||||||
try { w.SetForeground(); } catch { /* best-effort */ }
|
try { w.SetForeground(); } catch { /* best-effort */ }
|
||||||
try { w.Focus(); } catch { /* best-effort */ }
|
try { w.Focus(); } catch { /* best-effort */ }
|
||||||
|
|
||||||
// Issue #14 follow-up: active wait instead of fixed 600ms sleep.
|
// 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);
|
var deadline = DateTime.UtcNow.AddSeconds(2);
|
||||||
while (DateTime.UtcNow < deadline)
|
while (DateTime.UtcNow < deadline)
|
||||||
{
|
{
|
||||||
if (targetHwnd != IntPtr.Zero && GetForegroundWindow() == targetHwnd)
|
if (_sutHwnd != IntPtr.Zero && GetForegroundWindow() == _sutHwnd)
|
||||||
break;
|
break;
|
||||||
System.Threading.Thread.Sleep(25);
|
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);
|
System.Threading.Thread.Sleep(100);
|
||||||
}
|
}
|
||||||
catch
|
catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private void EnsureSutForegroundQuick()
|
||||||
{
|
{
|
||||||
// best-effort; if this fails the user will see the BOX text land
|
if (_sutHwnd == IntPtr.Zero) return;
|
||||||
// in the wrong window and can re-run with the SUT focused manually.
|
if (GetForegroundWindow() == _sutHwnd) return;
|
||||||
|
|
||||||
|
SetForegroundWindow(_sutHwnd);
|
||||||
|
var deadline = DateTime.UtcNow.AddMilliseconds(300);
|
||||||
|
while (DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
if (GetForegroundWindow() == _sutHwnd) break;
|
||||||
|
System.Threading.Thread.Sleep(20);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user