Fix smoke 1차 follow-up gaps: player resolver, type target, filter, utf8 (#12)
This commit is contained in:
@@ -38,6 +38,14 @@ public sealed class DragCollapser
|
||||
// Active modifiers (ctrl/shift/alt/win) held down.
|
||||
var modsDown = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
// Issue #12 — fallback target for type steps when no resolver result
|
||||
// is available for the key event itself. Priority order:
|
||||
// 1. typeRes (resolver result captured at first key_down)
|
||||
// 2. _lastFocusPath (most recent focus_change)
|
||||
// 3. _lastMousePath (most recent successful mouse resolution)
|
||||
string? lastFocusPath = null;
|
||||
string? lastMousePath = null;
|
||||
|
||||
void FlushType()
|
||||
{
|
||||
if (typeBuf.Length == 0 || typeFirst is null) return;
|
||||
@@ -59,6 +67,18 @@ public sealed class DragCollapser
|
||||
step.Value = MaskPolicy.MaskedValue;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var fallback = lastFocusPath ?? lastMousePath;
|
||||
if (!string.IsNullOrEmpty(fallback))
|
||||
{
|
||||
step.Target = new ScenarioTarget
|
||||
{
|
||||
UiaPath = fallback!,
|
||||
Offset = new[] { 0.5, 0.5 },
|
||||
};
|
||||
}
|
||||
}
|
||||
steps.Add(step);
|
||||
typeBuf.Clear();
|
||||
typeFirst = null;
|
||||
@@ -103,6 +123,10 @@ public sealed class DragCollapser
|
||||
var threshSq = DragThresholdPx * DragThresholdPx;
|
||||
|
||||
var downRes = resolver(down);
|
||||
if (downRes is not null)
|
||||
{
|
||||
lastMousePath = downRes.UiaPath;
|
||||
}
|
||||
if (useSq >= threshSq)
|
||||
{
|
||||
// drag step
|
||||
@@ -161,6 +185,7 @@ public sealed class DragCollapser
|
||||
case "mouse_down_r":
|
||||
{
|
||||
var res = resolver(ev);
|
||||
if (res is not null) lastMousePath = res.UiaPath;
|
||||
var step = new ScenarioStep
|
||||
{
|
||||
Kind = "click",
|
||||
@@ -304,6 +329,7 @@ public sealed class DragCollapser
|
||||
UiaPath = ev.FocusedElementPath!,
|
||||
Offset = new[] { 0.5, 0.5 },
|
||||
};
|
||||
lastFocusPath = ev.FocusedElementPath;
|
||||
}
|
||||
steps.Add(step);
|
||||
break;
|
||||
|
||||
@@ -21,6 +21,12 @@ public sealed class LowLevelHook : IDisposable
|
||||
private uint _threadId;
|
||||
private volatile bool _running;
|
||||
|
||||
/// <summary>
|
||||
/// Mutable filter — set after attach so the recorder can drop events
|
||||
/// from non-SUT windows (issue #12 Gap C). Default keeps everything.
|
||||
/// </summary>
|
||||
public IWindowFilter Filter { get; set; } = new PassThroughWindowFilter();
|
||||
|
||||
public LowLevelHook(Channel<RawEvent> channel)
|
||||
{
|
||||
_channel = channel;
|
||||
@@ -77,7 +83,11 @@ public sealed class LowLevelHook : IDisposable
|
||||
NativeMethods.WM_KEYUP or NativeMethods.WM_SYSKEYUP => "key_up",
|
||||
_ => "key",
|
||||
};
|
||||
_channel.Writer.TryWrite(new RawEvent(NowMs(), kind, 0, 0, data.vkCode, 0));
|
||||
var ev = new RawEvent(NowMs(), kind, 0, 0, data.vkCode, 0);
|
||||
if (Filter.ShouldKeep(ev))
|
||||
{
|
||||
_channel.Writer.TryWrite(ev);
|
||||
}
|
||||
}
|
||||
return NativeMethods.CallNextHookEx(IntPtr.Zero, nCode, wParam, lParam);
|
||||
}
|
||||
@@ -105,7 +115,11 @@ public sealed class LowLevelHook : IDisposable
|
||||
{
|
||||
wheel = (short)((data.mouseData >> 16) & 0xFFFF);
|
||||
}
|
||||
_channel.Writer.TryWrite(new RawEvent(NowMs(), kind, data.pt.x, data.pt.y, 0, wheel));
|
||||
var ev = new RawEvent(NowMs(), kind, data.pt.x, data.pt.y, 0, wheel);
|
||||
if (Filter.ShouldKeep(ev))
|
||||
{
|
||||
_channel.Writer.TryWrite(ev);
|
||||
}
|
||||
}
|
||||
return NativeMethods.CallNextHookEx(IntPtr.Zero, nCode, wParam, lParam);
|
||||
}
|
||||
|
||||
@@ -76,6 +76,15 @@ internal static class NativeMethods
|
||||
[DllImport("user32.dll")]
|
||||
public static extern void PostQuitMessage(int nExitCode);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern IntPtr WindowFromPoint(POINT pt);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern IntPtr GetForegroundWindow();
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct MSG
|
||||
{
|
||||
|
||||
@@ -82,6 +82,30 @@ public static class Program
|
||||
Console.Error.WriteLine($"[recorder] attach failed: {ex.Message}");
|
||||
}
|
||||
|
||||
// Issue #12 Gap C — once attached, drop events that originate from
|
||||
// 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) =>
|
||||
{
|
||||
var hwnd = NativeMethods.WindowFromPoint(new NativeMethods.POINT { x = x, y = y });
|
||||
if (hwnd == IntPtr.Zero) return 0;
|
||||
NativeMethods.GetWindowThreadProcessId(hwnd, out var pid);
|
||||
return (int)pid;
|
||||
},
|
||||
processFromForeground: () =>
|
||||
{
|
||||
var hwnd = NativeMethods.GetForegroundWindow();
|
||||
if (hwnd == IntPtr.Zero) return 0;
|
||||
NativeMethods.GetWindowThreadProcessId(hwnd, out var pid);
|
||||
return (int)pid;
|
||||
});
|
||||
Console.WriteLine($"[recorder] window filter active for pid={sutPid}");
|
||||
}
|
||||
|
||||
var scenario = new Scenario
|
||||
{
|
||||
Name = System.IO.Path.GetFileNameWithoutExtension(args.OutputPath),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
@@ -35,6 +36,11 @@ public static class ScenarioWriter
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
File.WriteAllText(path, Serialize(scenario));
|
||||
// Issue #12 — write UTF-8 without BOM so the file round-trips through
|
||||
// PowerShell / cross-tool readers without garbling Korean text. The
|
||||
// default `File.WriteAllText` overload also produces UTF-8 without BOM
|
||||
// on .NET Core+, but we make it explicit to lock the contract.
|
||||
var enc = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
|
||||
File.WriteAllText(path, Serialize(scenario), enc);
|
||||
}
|
||||
}
|
||||
|
||||
69
src/Recordingtest.Recorder/WindowFilter.cs
Normal file
69
src/Recordingtest.Recorder/WindowFilter.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using System;
|
||||
|
||||
namespace Recordingtest.Recorder;
|
||||
|
||||
/// <summary>
|
||||
/// Decides whether a raw event belongs to the attached SUT process.
|
||||
/// Pure interface so the rule is unit testable without P/Invoke.
|
||||
/// </summary>
|
||||
public interface IWindowFilter
|
||||
{
|
||||
/// <summary>Return true to keep the event, false to drop it.</summary>
|
||||
bool ShouldKeep(RawEvent ev);
|
||||
}
|
||||
|
||||
/// <summary>Always keeps every event. Used when no SUT is attached yet.</summary>
|
||||
public sealed class PassThroughWindowFilter : IWindowFilter
|
||||
{
|
||||
public bool ShouldKeep(RawEvent ev) => true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure SUT-process filter. Uses two pluggable lookups so the rule can be
|
||||
/// exercised in unit tests with fake delegates instead of real Win32.
|
||||
///
|
||||
/// <c>processFromPoint</c> : (x,y) → owning process id, or 0 if unknown
|
||||
/// <c>processFromForeground</c>: () → foreground process id (used for keys)
|
||||
/// </summary>
|
||||
public sealed class SutProcessWindowFilter : IWindowFilter
|
||||
{
|
||||
private readonly int _sutPid;
|
||||
private readonly Func<int, int, int> _processFromPoint;
|
||||
private readonly Func<int> _processFromForeground;
|
||||
|
||||
public SutProcessWindowFilter(
|
||||
int sutPid,
|
||||
Func<int, int, int> processFromPoint,
|
||||
Func<int> processFromForeground)
|
||||
{
|
||||
_sutPid = sutPid;
|
||||
_processFromPoint = processFromPoint;
|
||||
_processFromForeground = processFromForeground;
|
||||
}
|
||||
|
||||
public bool ShouldKeep(RawEvent ev)
|
||||
{
|
||||
if (_sutPid <= 0) return true;
|
||||
|
||||
switch (ev.Kind)
|
||||
{
|
||||
case "key_down":
|
||||
case "key_up":
|
||||
{
|
||||
var pid = _processFromForeground();
|
||||
return pid == 0 || pid == _sutPid;
|
||||
}
|
||||
case "focus_change":
|
||||
// focus_change comes from the UIA callback already scoped to
|
||||
// the SUT process, keep unconditionally.
|
||||
return true;
|
||||
default:
|
||||
{
|
||||
var pid = _processFromPoint(ev.X, ev.Y);
|
||||
// pid==0 means lookup failed; be permissive (don't drop legit
|
||||
// events when WindowFromPoint races with the cursor).
|
||||
return pid == 0 || pid == _sutPid;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user