Fix smoke 1차 follow-up gaps: player resolver, type target, filter, utf8 (#12)

This commit is contained in:
minsung
2026-04-07 20:30:59 +09:00
parent 3738a0df5c
commit 8784fec923
13 changed files with 802 additions and 30 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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