Fix smoke 2차 gaps: hotkey tests, focus filter, viewport picking (#13)

Gap E — Hotkey named key
- UiaPlayerHost: extract ParsedHotkey record + ParseHotkey static
- HotkeyParseTests: 8 tests (enter/tab/a/ctrl+c/ctrl+shift+s/f5/alt+f4/empty)

Gap F — recorder focus_change SUT filter
- FocusEventFilter.ShouldAccept static rule (same/zero/unknown/unknown-sut)
- Program.cs wires it inside RegisterFocusChangedEvent callback
- FocusEventFilterTests: 4 tests

Gap G — viewport picking foreign-process fallback
- IWindowPointSource + WindowPointResolver pure resolver
- FlaUiPointSource wired in Program.cs (best-effort hit test, honest partial for live SUT)
- WindowPointResolverTests: 5 tests

Tests: 77 → 94, build 0/0 (TreatWarningsAsErrors preserved).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
minsung
2026-04-08 18:21:36 +09:00
parent 7db9cd08e1
commit b139f2b169
10 changed files with 377 additions and 11 deletions

View File

@@ -0,0 +1,16 @@
namespace Recordingtest.Recorder;
/// <summary>
/// Pure decision for UIA focus_change events: keep only if the element belongs
/// to the attached SUT process. Used to avoid flooding scenarios with focus
/// events from VS Code / PowerShell / other foreground apps (issue #13 Gap F).
/// </summary>
public static class FocusEventFilter
{
public static bool ShouldAccept(int candidatePid, int sutPid)
{
if (sutPid <= 0) return true; // unknown SUT: permissive
if (candidatePid <= 0) return false; // unknown element pid: drop
return candidatePid == sutPid;
}
}

View File

@@ -72,10 +72,12 @@ public static class Program
Application? app = null;
UIA3Automation? automation = null;
AutomationElement? mainWindow = null;
int sutPid = 0;
try
{
(app, automation, mainWindow) = TryAttach(args.Attach);
if (app is not null) sutPid = app.ProcessId;
}
catch (Exception ex)
{
@@ -86,7 +88,6 @@ public static class Program
// any window not owned by the SUT process.
if (app is not null)
{
int sutPid = app.ProcessId;
hook.Filter = new SutProcessWindowFilter(
sutPid,
processFromPoint: (x, y) =>
@@ -131,6 +132,11 @@ public static class Program
try
{
if (el is null) return;
// Issue #13 Gap F — drop focus events from non-SUT processes.
int elPid = 0;
try { elPid = el.Properties.ProcessId.ValueOrDefault; }
catch { elPid = 0; }
if (!FocusEventFilter.ShouldAccept(elPid, sutPid)) return;
var snap = new FlaUiSnapshot(el);
var path = ElementPathBuilder.Build(snap);
channel.Writer.TryWrite(new RawEvent(
@@ -186,7 +192,8 @@ public static class Program
}
try
{
var snap = ResolveAt(automation, ev.X, ev.Y);
var source = new FlaUiPointSource(automation, mainWindow);
var snap = WindowPointResolver.Resolve(source, ev.X, ev.Y, sutPid);
if (snap is null)
{
unresolvedPaths++;
@@ -274,11 +281,59 @@ public static class Program
}
}
private static IElementSnapshot? ResolveAt(UIA3Automation automation, int x, int y)
/// <summary>
/// FlaUI/Win32-backed <see cref="IWindowPointSource"/>. The SUT-scope
/// fallback is a best-effort stub (returns null) pending live verification
/// in smoke 3 — the load-bearing piece of Gap G is the pure
/// <see cref="WindowPointResolver"/> rule which falls back to the primary
/// result when the fallback returns null.
/// </summary>
private sealed class FlaUiPointSource : IWindowPointSource
{
var raw = automation.FromPoint(new System.Drawing.Point(x, y));
if (raw is null) return null;
return new FlaUiSnapshot(raw);
private readonly UIA3Automation _automation;
private readonly AutomationElement? _mainWindow;
public FlaUiPointSource(UIA3Automation automation, AutomationElement? mainWindow)
{
_automation = automation;
_mainWindow = mainWindow;
}
public int? GetProcessIdAt(int x, int y)
{
try
{
var hwnd = NativeMethods.WindowFromPoint(new NativeMethods.POINT { x = x, y = y });
if (hwnd == IntPtr.Zero) return null;
NativeMethods.GetWindowThreadProcessId(hwnd, out var pid);
return (int)pid;
}
catch
{
return null;
}
}
public IElementSnapshot? GetElementAt(int x, int y)
{
try
{
var raw = _automation.FromPoint(new System.Drawing.Point(x, y));
return raw is null ? null : new FlaUiSnapshot(raw);
}
catch
{
return null;
}
}
public IElementSnapshot? GetElementFromSutScope(int x, int y)
{
// Partial Gap G: honest stub. Returning null lets WindowPointResolver
// fall back to the primary element as a last resort. Full hit-test
// walker to be implemented once smoke 3 validates the surface.
return null;
}
}
}

View File

@@ -0,0 +1,38 @@
namespace Recordingtest.Recorder;
/// <summary>
/// Pluggable point-to-element lookup so the SUT-scoped fallback rule (issue
/// #13 Gap G) can be unit tested without live UIA or Win32.
/// </summary>
public interface IWindowPointSource
{
/// <summary>Owning process id of the top-level window at (x,y), or null if unknown.</summary>
int? GetProcessIdAt(int x, int y);
/// <summary>Primary UIA lookup — may return an element belonging to any process.</summary>
IElementSnapshot? GetElementAt(int x, int y);
/// <summary>SUT-scoped fallback — hit-test inside the attached SUT main window only.</summary>
IElementSnapshot? GetElementFromSutScope(int x, int y);
}
/// <summary>
/// Pure decision for viewport picking. If the primary lookup lands in a
/// foreign process, try an SUT-scoped descendant hit-test and prefer that.
/// If the foreign-process fallback returns null, fall back to the primary as
/// a last resort (documented semantic, covered by tests).
/// </summary>
public static class WindowPointResolver
{
public static IElementSnapshot? Resolve(IWindowPointSource source, int x, int y, int sutPid)
{
var primary = source.GetElementAt(x, y);
var pid = source.GetProcessIdAt(x, y);
if (pid is null || pid.Value == 0 || pid.Value == sutPid)
{
return primary;
}
var fallback = source.GetElementFromSutScope(x, y);
return fallback ?? primary;
}
}