diff --git a/docs/guides/smoke-test.md b/docs/guides/smoke-test.md
index b496b42..b4d2a12 100644
--- a/docs/guides/smoke-test.md
+++ b/docs/guides/smoke-test.md
@@ -224,6 +224,24 @@ Smoke test 완료 후:
3. 10회 재생 reliability 수치 → player DoD #7 update
4. 이슈 #2 또는 새 이슈에 리포트 코멘트
+## 한글 yaml 확인 팁 (issue #12 Gap D)
+
+`ScenarioWriter`는 UTF-8 (BOM 없음)으로 저장한다. PowerShell `Get-Content`는
+시스템 코드페이지로 디코딩해서 한글이 깨져 보일 수 있다. 파일 자체의 정합성을
+확인하려면 다음 중 하나를 사용한다:
+
+```powershell
+# 권장: 명시적 UTF-8 디코딩
+Get-Content -Encoding UTF8 scenarios/box-v4.yaml
+
+# 또는 출력 시 BOM 없이 다시 저장해 비교
+Get-Content -Encoding UTF8 scenarios/box-v4.yaml | Out-File -Encoding UTF8 tmp.yaml
+```
+
+`UiaPathResolverTests.UiaPathParser_ParsesNameAttribute` 와
+`ScenarioWriter_RoundTrip_PreservesKorean` 가 한글 path/속성 round-trip을
+회귀로 잡는다.
+
## v3 이후 과제
- recorder IME 조합 키 처리
diff --git a/docs/history/2026-04-07_이슈12-smoke2-fix-generator.md b/docs/history/2026-04-07_이슈12-smoke2-fix-generator.md
new file mode 100644
index 0000000..1ea8135
--- /dev/null
+++ b/docs/history/2026-04-07_이슈12-smoke2-fix-generator.md
@@ -0,0 +1,60 @@
+# 2026-04-07 이슈 #12 Smoke 2회차 fix — Generator
+
+- **이슈**: #12
+- **롤**: Generator (Planner/Generator/Evaluator 사이클)
+- **소요 시간**: ~50분
+- **Context 사용량**: ~75k tokens
+
+## 작업 요약
+
+Smoke 1회차에서 발견된 4개 구조적 gap을 unit test 가능한 형태로 수정.
+
+| Gap | 위치 | 수정 |
+|-----|------|------|
+| A | Player full-path resolver | `UiaPathParser` + `IUiaTreeNode`/`UiaPathResolver` 신규. `UiaPlayerHost.ResolveElement`가 FlaUI tree adapter로 segment chain descend |
+| B | Recorder type step target inheritance | `DragCollapser`에 `lastFocusPath`/`lastMousePath` 추적, `FlushType()`이 typeRes 없을 때 fallback |
+| C | SUT 외 창 필터 | `IWindowFilter` + `SutProcessWindowFilter` 도입, `LowLevelHook.Filter`가 mouse/key 모두 필터링. `Program.cs`에서 `WindowFromPoint`/`GetForegroundWindow` 와이어업 |
+| D | UTF-8 BOM 없는 yaml 명시 | `ScenarioWriter.WriteToFile`이 `new UTF8Encoding(false)`로 저장 |
+
+## 새 테스트 (10건)
+
+Player.Tests:
+- `UiaPathParser_ParsesMultiSegment_WithClassAndId`
+- `UiaPathParser_ParsesNameAttribute`
+- `UiaPathResolver_Descend_FindsNestedElement`
+- `UiaPathResolver_LastSegmentWithoutId_UsesClassName`
+- `UiaPathResolver_NotFound_ReturnsNull`
+- `SmokeRegression_BoxV4CleanLike_ParsesAndResolves`
+
+Recorder.Tests:
+- `DragCollapser_TypeAfterFocusChange_InheritsTarget`
+- `DragCollapser_TypeAfterMouseDown_FallbackToMouseTarget`
+- `WindowFilter_ExternalCoord_DropsEvent`
+- `WindowFilter_SutCoord_KeepsEvent`
+- `ScenarioWriter_RoundTrip_PreservesKorean`
+
+## 빌드/테스트 결과
+
+- `dotnet build recordingtest.sln` — 0 warning, 0 error (TreatWarningsAsErrors)
+- `dotnet test` — 60 → 71 통과, 0 실패
+
+## 변경 파일
+
+- src/Recordingtest.Player/UiaPathParser.cs (new)
+- src/Recordingtest.Player/IUiaPathResolver.cs (new — `IUiaTreeNode`+`UiaPathResolver`)
+- src/Recordingtest.Player/UiaPlayerHost.cs
+- src/Recordingtest.Recorder/DragCollapser.cs
+- src/Recordingtest.Recorder/ScenarioWriter.cs
+- src/Recordingtest.Recorder/WindowFilter.cs (new)
+- src/Recordingtest.Recorder/NativeMethods.cs (WindowFromPoint/GetForegroundWindow/GetWindowThreadProcessId)
+- src/Recordingtest.Recorder/LowLevelHook.cs (Filter property)
+- src/Recordingtest.Recorder/Program.cs (filter wiring)
+- tests/Recordingtest.Player.Tests/UiaPathResolverTests.cs (new)
+- tests/Recordingtest.Recorder.Tests/RecorderTests.cs (5 새 fact)
+- docs/guides/smoke-test.md (PowerShell UTF-8 팁)
+
+## 다음 단계
+
+- `/evaluate issue-12` (Evaluator)
+- 통과 시 PROGRESS.md/PLAN.md orchestrator가 갱신
+- 실제 SUT 위 smoke 2회차로 box-v4-clean.yaml 재생 검증
diff --git a/src/Recordingtest.Player/IUiaPathResolver.cs b/src/Recordingtest.Player/IUiaPathResolver.cs
new file mode 100644
index 0000000..4c6cb55
--- /dev/null
+++ b/src/Recordingtest.Player/IUiaPathResolver.cs
@@ -0,0 +1,131 @@
+using System.Collections.Generic;
+
+namespace Recordingtest.Player;
+
+///
+/// Pure adapter over an UIA element tree. Lets
+/// be unit tested with a fake in-memory tree.
+///
+public interface IUiaTreeNode
+{
+ string ClassName { get; }
+ string? AutomationId { get; }
+ string? Name { get; }
+ ElementBounds Bounds { get; }
+ IEnumerable Children { get; }
+}
+
+///
+/// Resolves a UIA path string against a tree rooted at a window node.
+/// Pure logic, fully unit testable.
+///
+public static class UiaPathResolver
+{
+ ///
+ /// Descend a tree following the segments of .
+ /// The first segment matches the root (or its first descendant whose
+ /// ClassName matches). Subsequent segments search direct children of
+ /// the previously matched node.
+ ///
+ /// The resolved node or null when any segment fails to match.
+ public static IUiaTreeNode? Resolve(IUiaTreeNode root, string uiaPath)
+ {
+ var segments = UiaPathParser.Parse(uiaPath);
+ if (segments.Count == 0) return null;
+
+ IUiaTreeNode? current = MatchRoot(root, segments[0]);
+ if (current is null) return null;
+
+ for (int i = 1; i < segments.Count; i++)
+ {
+ current = FindChild(current, segments[i]);
+ if (current is null) return null;
+ }
+ return current;
+ }
+
+ private static IUiaTreeNode? MatchRoot(IUiaTreeNode root, UiaPathParser.Segment seg)
+ {
+ if (Matches(root, seg)) return root;
+ // Allow matching a descendant in case the caller passed an absolute
+ // path that starts at a parent we don't see (e.g. desktop chrome).
+ foreach (var d in Descendants(root))
+ {
+ if (Matches(d, seg)) return d;
+ }
+ return null;
+ }
+
+ private static IUiaTreeNode? FindChild(IUiaTreeNode parent, UiaPathParser.Segment seg)
+ {
+ foreach (var c in parent.Children)
+ {
+ if (Matches(c, seg)) return c;
+ }
+ // Fallback: search descendants up to a small depth so element-tree
+ // wrappers (e.g. ContentPresenter chains) don't break the resolve.
+ foreach (var d in DescendantsBounded(parent, maxDepth: 4))
+ {
+ if (Matches(d, seg)) return d;
+ }
+ return null;
+ }
+
+ private static bool Matches(IUiaTreeNode node, UiaPathParser.Segment seg)
+ {
+ // Priority: AutomationId > Name > ClassName
+ if (!string.IsNullOrEmpty(seg.AutomationId))
+ {
+ if (!string.Equals(node.AutomationId, seg.AutomationId, System.StringComparison.Ordinal))
+ {
+ return false;
+ }
+ // ClassName, when both sides have one, must also agree (loose).
+ if (!string.IsNullOrEmpty(seg.ClassName) &&
+ !string.IsNullOrEmpty(node.ClassName) &&
+ !string.Equals(node.ClassName, seg.ClassName, System.StringComparison.Ordinal))
+ {
+ // Allow class mismatch — AutomationId is the strong key.
+ }
+ return true;
+ }
+ if (!string.IsNullOrEmpty(seg.Name))
+ {
+ if (!string.Equals(node.Name, seg.Name, System.StringComparison.Ordinal))
+ {
+ return false;
+ }
+ return true;
+ }
+ // Only ClassName.
+ if (string.IsNullOrEmpty(seg.ClassName)) return false;
+ return string.Equals(node.ClassName, seg.ClassName, System.StringComparison.Ordinal);
+ }
+
+ private static IEnumerable Descendants(IUiaTreeNode root)
+ {
+ var stack = new Stack();
+ foreach (var c in root.Children) stack.Push(c);
+ while (stack.Count > 0)
+ {
+ var n = stack.Pop();
+ yield return n;
+ foreach (var c in n.Children) stack.Push(c);
+ }
+ }
+
+ private static IEnumerable DescendantsBounded(IUiaTreeNode root, int maxDepth)
+ {
+ var stack = new Stack<(IUiaTreeNode Node, int Depth)>();
+ foreach (var c in root.Children) stack.Push((c, 1));
+ while (stack.Count > 0)
+ {
+ var (n, d) = stack.Pop();
+ yield return n;
+ if (d < maxDepth)
+ {
+ foreach (var c in n.Children) stack.Push((c, d + 1));
+ }
+ }
+ }
+}
diff --git a/src/Recordingtest.Player/UiaPathParser.cs b/src/Recordingtest.Player/UiaPathParser.cs
new file mode 100644
index 0000000..9a4a3b5
--- /dev/null
+++ b/src/Recordingtest.Player/UiaPathParser.cs
@@ -0,0 +1,115 @@
+using System;
+using System.Collections.Generic;
+
+namespace Recordingtest.Player;
+
+///
+/// Pure parser for UIA path strings of the form
+/// ClassName[@AutomationId='...'][@Name='...']/ClassName[@AutomationId='...'].
+///
+/// Segments are separated by / at depth zero (slashes inside '...'
+/// quoted attribute values are preserved). Attributes supported: AutomationId, Name.
+/// Both attributes are optional; ClassName must always be present.
+///
+public static class UiaPathParser
+{
+ public sealed record Segment(string ClassName, string? AutomationId, string? Name);
+
+ public static IReadOnlyList Parse(string path)
+ {
+ if (string.IsNullOrEmpty(path))
+ {
+ return Array.Empty();
+ }
+
+ var raw = SplitTopLevel(path);
+ var result = new List(raw.Count);
+ foreach (var s in raw)
+ {
+ if (string.IsNullOrWhiteSpace(s)) continue;
+ result.Add(ParseSegment(s));
+ }
+ return result;
+ }
+
+ public static Segment ParseSegment(string segment)
+ {
+ // ClassName[@AutomationId='...'][@Name='...']
+ // ClassName may contain '#' (e.g. "#32769") and dots.
+ int bracket = segment.IndexOf('[');
+ string className = bracket < 0 ? segment : segment.Substring(0, bracket);
+ className = className.Trim();
+
+ string? automationId = null;
+ string? name = null;
+
+ int i = bracket;
+ while (i >= 0 && i < segment.Length && segment[i] == '[')
+ {
+ int end = segment.IndexOf(']', i + 1);
+ if (end < 0) break;
+ // Skip ] inside quoted strings
+ int qStart = segment.IndexOf('\'', i + 1);
+ if (qStart >= 0 && qStart < end)
+ {
+ int qEnd = segment.IndexOf('\'', qStart + 1);
+ if (qEnd > end)
+ {
+ end = segment.IndexOf(']', qEnd + 1);
+ if (end < 0) break;
+ }
+ }
+ var inside = segment.Substring(i + 1, end - i - 1);
+ ParseAttribute(inside, ref automationId, ref name);
+ i = end + 1;
+ if (i >= segment.Length) break;
+ }
+
+ return new Segment(className, automationId, name);
+ }
+
+ private static void ParseAttribute(string inside, ref string? automationId, ref string? name)
+ {
+ // form: @AutomationId='value' or @Name='value'
+ inside = inside.Trim();
+ if (!inside.StartsWith("@", StringComparison.Ordinal)) return;
+ int eq = inside.IndexOf('=');
+ if (eq < 0) return;
+ var key = inside.Substring(1, eq - 1).Trim();
+ var rawVal = inside.Substring(eq + 1).Trim();
+ if (rawVal.Length >= 2 && rawVal[0] == '\'' && rawVal[^1] == '\'')
+ {
+ rawVal = rawVal.Substring(1, rawVal.Length - 2);
+ }
+ if (string.Equals(key, "AutomationId", StringComparison.OrdinalIgnoreCase))
+ {
+ automationId = rawVal;
+ }
+ else if (string.Equals(key, "Name", StringComparison.OrdinalIgnoreCase))
+ {
+ name = rawVal;
+ }
+ }
+
+ private static List SplitTopLevel(string path)
+ {
+ var parts = new List();
+ var sb = new System.Text.StringBuilder();
+ bool inQuote = false;
+ foreach (var c in path)
+ {
+ if (c == '\'') inQuote = !inQuote;
+ if (c == '/' && !inQuote)
+ {
+ parts.Add(sb.ToString());
+ sb.Clear();
+ }
+ else
+ {
+ sb.Append(c);
+ }
+ }
+ if (sb.Length > 0) parts.Add(sb.ToString());
+ return parts;
+ }
+}
diff --git a/src/Recordingtest.Player/UiaPlayerHost.cs b/src/Recordingtest.Player/UiaPlayerHost.cs
index d958067..b4c36fe 100644
--- a/src/Recordingtest.Player/UiaPlayerHost.cs
+++ b/src/Recordingtest.Player/UiaPlayerHost.cs
@@ -29,37 +29,28 @@ public sealed class UiaPlayerHost : IPlayerHost, IDisposable
public ResolvedElement? ResolveElement(string uiaPath, TimeSpan timeout)
{
- // Best-effort: search by AutomationId fragment in the last segment.
- // A full UIA-path resolver is out of PoC scope; recorder produces
- // simple AutomationId-based paths in the bootstrap scenarios.
- var automationId = ExtractAutomationId(uiaPath);
+ // Issue #12 — full UIA path resolver. Walk the segment chain via the
+ // pure UiaPathResolver against a FlaUI-backed tree adapter so the
+ // logic is unit testable independent of the live SUT.
var window = _app?.GetMainWindow(_automation, timeout);
if (window is null)
{
return null;
}
- var element = Retry.WhileNull(
- () =>
- {
- if (!string.IsNullOrEmpty(automationId))
- {
- return window.FindFirstDescendant(cf => cf.ByAutomationId(automationId));
- }
- return window.FindFirstDescendant();
- },
+ var resolved = Retry.WhileNull(
+ () => UiaPathResolver.Resolve(new FlaUiTreeNode(window), uiaPath),
timeout: timeout,
ignoreException: true).Result;
- if (element is null)
+ if (resolved is null)
{
return null;
}
- var r = element.BoundingRectangle;
- return new ResolvedElement(
- new ElementBounds(r.X, r.Y, r.Width, r.Height),
- element);
+ var b = resolved.Bounds;
+ var native = resolved is FlaUiTreeNode fn ? (object?)fn.Element : null;
+ return new ResolvedElement(b, native);
}
public bool WaitFor(string waitForHint, TimeSpan timeout)
@@ -132,16 +123,58 @@ public sealed class UiaPlayerHost : IPlayerHost, IDisposable
$"[{DateTime.UtcNow:o}] step={stepIndex} reason={reason}{Environment.NewLine}");
}
- private static string ExtractAutomationId(string uiaPath)
+ ///
+ /// FlaUI-backed adapter for the pure interface.
+ ///
+ private sealed class FlaUiTreeNode : IUiaTreeNode
{
- // Look for [@AutomationId='...'] in the last segment.
- var marker = "@AutomationId='";
- var idx = uiaPath.LastIndexOf(marker, StringComparison.Ordinal);
- if (idx < 0) return string.Empty;
- var start = idx + marker.Length;
- var end = uiaPath.IndexOf('\'', start);
- if (end < 0) return string.Empty;
- return uiaPath.Substring(start, end - start);
+ public AutomationElement Element { get; }
+
+ public FlaUiTreeNode(AutomationElement el)
+ {
+ Element = el;
+ }
+
+ public string ClassName
+ {
+ get { try { return Element.ClassName ?? string.Empty; } catch { return string.Empty; } }
+ }
+ public string? AutomationId
+ {
+ get { try { return Element.AutomationId; } catch { return null; } }
+ }
+ public string? Name
+ {
+ get { try { return Element.Name; } catch { return null; } }
+ }
+ public ElementBounds Bounds
+ {
+ get
+ {
+ try
+ {
+ var r = Element.BoundingRectangle;
+ return new ElementBounds(r.X, r.Y, r.Width, r.Height);
+ }
+ catch
+ {
+ return new ElementBounds(0, 0, 0, 0);
+ }
+ }
+ }
+ public IEnumerable Children
+ {
+ get
+ {
+ AutomationElement[] arr;
+ try { arr = Element.FindAllChildren(); }
+ catch { yield break; }
+ foreach (var c in arr)
+ {
+ yield return new FlaUiTreeNode(c);
+ }
+ }
+ }
}
public void Dispose()
diff --git a/src/Recordingtest.Recorder/DragCollapser.cs b/src/Recordingtest.Recorder/DragCollapser.cs
index 32a4c6e..ddc6dee 100644
--- a/src/Recordingtest.Recorder/DragCollapser.cs
+++ b/src/Recordingtest.Recorder/DragCollapser.cs
@@ -38,6 +38,14 @@ public sealed class DragCollapser
// Active modifiers (ctrl/shift/alt/win) held down.
var modsDown = new HashSet(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;
diff --git a/src/Recordingtest.Recorder/LowLevelHook.cs b/src/Recordingtest.Recorder/LowLevelHook.cs
index e4fd833..3e2b3c1 100644
--- a/src/Recordingtest.Recorder/LowLevelHook.cs
+++ b/src/Recordingtest.Recorder/LowLevelHook.cs
@@ -21,6 +21,12 @@ public sealed class LowLevelHook : IDisposable
private uint _threadId;
private volatile bool _running;
+ ///
+ /// Mutable filter — set after attach so the recorder can drop events
+ /// from non-SUT windows (issue #12 Gap C). Default keeps everything.
+ ///
+ public IWindowFilter Filter { get; set; } = new PassThroughWindowFilter();
+
public LowLevelHook(Channel 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);
}
diff --git a/src/Recordingtest.Recorder/NativeMethods.cs b/src/Recordingtest.Recorder/NativeMethods.cs
index 30c8f3e..df55c44 100644
--- a/src/Recordingtest.Recorder/NativeMethods.cs
+++ b/src/Recordingtest.Recorder/NativeMethods.cs
@@ -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
{
diff --git a/src/Recordingtest.Recorder/Program.cs b/src/Recordingtest.Recorder/Program.cs
index 35034f9..ce69258 100644
--- a/src/Recordingtest.Recorder/Program.cs
+++ b/src/Recordingtest.Recorder/Program.cs
@@ -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),
diff --git a/src/Recordingtest.Recorder/ScenarioWriter.cs b/src/Recordingtest.Recorder/ScenarioWriter.cs
index 41e4989..55ee365 100644
--- a/src/Recordingtest.Recorder/ScenarioWriter.cs
+++ b/src/Recordingtest.Recorder/ScenarioWriter.cs
@@ -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);
}
}
diff --git a/src/Recordingtest.Recorder/WindowFilter.cs b/src/Recordingtest.Recorder/WindowFilter.cs
new file mode 100644
index 0000000..b72cca6
--- /dev/null
+++ b/src/Recordingtest.Recorder/WindowFilter.cs
@@ -0,0 +1,69 @@
+using System;
+
+namespace Recordingtest.Recorder;
+
+///
+/// Decides whether a raw event belongs to the attached SUT process.
+/// Pure interface so the rule is unit testable without P/Invoke.
+///
+public interface IWindowFilter
+{
+ /// Return true to keep the event, false to drop it.
+ bool ShouldKeep(RawEvent ev);
+}
+
+/// Always keeps every event. Used when no SUT is attached yet.
+public sealed class PassThroughWindowFilter : IWindowFilter
+{
+ public bool ShouldKeep(RawEvent ev) => true;
+}
+
+///
+/// Pure SUT-process filter. Uses two pluggable lookups so the rule can be
+/// exercised in unit tests with fake delegates instead of real Win32.
+///
+/// processFromPoint : (x,y) → owning process id, or 0 if unknown
+/// processFromForeground: () → foreground process id (used for keys)
+///
+public sealed class SutProcessWindowFilter : IWindowFilter
+{
+ private readonly int _sutPid;
+ private readonly Func _processFromPoint;
+ private readonly Func _processFromForeground;
+
+ public SutProcessWindowFilter(
+ int sutPid,
+ Func processFromPoint,
+ Func 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;
+ }
+ }
+ }
+}
diff --git a/tests/Recordingtest.Player.Tests/UiaPathResolverTests.cs b/tests/Recordingtest.Player.Tests/UiaPathResolverTests.cs
new file mode 100644
index 0000000..37422a6
--- /dev/null
+++ b/tests/Recordingtest.Player.Tests/UiaPathResolverTests.cs
@@ -0,0 +1,135 @@
+using System.Collections.Generic;
+using Xunit;
+
+namespace Recordingtest.Player.Tests;
+
+public class UiaPathResolverTests
+{
+ private sealed class FakeNode : IUiaTreeNode
+ {
+ public string ClassName { get; set; } = "";
+ public string? AutomationId { get; set; }
+ public string? Name { get; set; }
+ public ElementBounds Bounds { get; set; }
+ public List ChildList { get; } = new();
+ public IEnumerable Children => ChildList;
+ }
+
+ [Fact]
+ public void UiaPathParser_ParsesMultiSegment_WithClassAndId()
+ {
+ var segs = UiaPathParser.Parse(
+ "MetroWindow[@AutomationId='root']/CommandPanel/TextBox[@AutomationId='CommandBox']/TextBox[@AutomationId='CB']");
+ Assert.Equal(4, segs.Count);
+ Assert.Equal("MetroWindow", segs[0].ClassName);
+ Assert.Equal("root", segs[0].AutomationId);
+ Assert.Equal("CommandPanel", segs[1].ClassName);
+ Assert.Null(segs[1].AutomationId);
+ Assert.Equal("TextBox", segs[2].ClassName);
+ Assert.Equal("CommandBox", segs[2].AutomationId);
+ Assert.Equal("CB", segs[3].AutomationId);
+ }
+
+ [Fact]
+ public void UiaPathParser_ParsesNameAttribute()
+ {
+ var seg = UiaPathParser.ParseSegment("Button[@Name='새 파일']");
+ Assert.Equal("Button", seg.ClassName);
+ Assert.Equal("새 파일", seg.Name);
+ Assert.Null(seg.AutomationId);
+ }
+
+ [Fact]
+ public void UiaPathResolver_Descend_FindsNestedElement()
+ {
+ var leaf = new FakeNode
+ {
+ ClassName = "TextBox",
+ AutomationId = "CB",
+ Bounds = new ElementBounds(10, 20, 30, 40),
+ };
+ var commandBox = new FakeNode
+ {
+ ClassName = "TextBox",
+ AutomationId = "CommandBox",
+ };
+ commandBox.ChildList.Add(leaf);
+ var panel = new FakeNode { ClassName = "CommandPanel" };
+ panel.ChildList.Add(commandBox);
+ var root = new FakeNode { ClassName = "MetroWindow", AutomationId = "root" };
+ root.ChildList.Add(panel);
+
+ var found = UiaPathResolver.Resolve(
+ root,
+ "MetroWindow[@AutomationId='root']/CommandPanel/TextBox[@AutomationId='CommandBox']/TextBox[@AutomationId='CB']");
+
+ Assert.NotNull(found);
+ Assert.Equal("CB", found!.AutomationId);
+ Assert.Equal(10, found.Bounds.X);
+ }
+
+ [Fact]
+ public void UiaPathResolver_LastSegmentWithoutId_UsesClassName()
+ {
+ var items = new FakeNode
+ {
+ ClassName = "ItemsControl",
+ Bounds = new ElementBounds(0, 0, 1920, 1040),
+ };
+ var root = new FakeNode { ClassName = "MetroWindow", AutomationId = "root" };
+ root.ChildList.Add(items);
+
+ var found = UiaPathResolver.Resolve(
+ root,
+ "MetroWindow[@AutomationId='root']/ItemsControl");
+
+ Assert.NotNull(found);
+ Assert.Equal("ItemsControl", found!.ClassName);
+ Assert.Equal(1920, found.Bounds.Width);
+ }
+
+ [Fact]
+ public void UiaPathResolver_NotFound_ReturnsNull()
+ {
+ var root = new FakeNode { ClassName = "MetroWindow", AutomationId = "root" };
+ var found = UiaPathResolver.Resolve(
+ root,
+ "MetroWindow[@AutomationId='root']/Missing");
+ Assert.Null(found);
+ }
+
+ [Fact]
+ public void SmokeRegression_BoxV4CleanLike_ParsesAndResolves()
+ {
+ // Build a minimal fake tree resembling EG-BIM Modeler.
+ var cb = new FakeNode { ClassName = "TextBox", AutomationId = "CB", Bounds = new ElementBounds(400, 1020, 200, 30) };
+ var commandBox = new FakeNode { ClassName = "TextBox", AutomationId = "CommandBox" };
+ commandBox.ChildList.Add(cb);
+ var cmdPanel = new FakeNode { ClassName = "CommandPanel" };
+ cmdPanel.ChildList.Add(commandBox);
+ var items = new FakeNode { ClassName = "ItemsControl", Bounds = new ElementBounds(0, 0, 1920, 1040) };
+ var root = new FakeNode { ClassName = "MetroWindow", AutomationId = "root" };
+ root.ChildList.Add(cmdPanel);
+ root.ChildList.Add(items);
+
+ var paths = new[]
+ {
+ "MetroWindow[@AutomationId='root']/ItemsControl",
+ "MetroWindow[@AutomationId='root']/CommandPanel/TextBox[@AutomationId='CommandBox']/TextBox[@AutomationId='CB']",
+ };
+
+ foreach (var p in paths)
+ {
+ var n = UiaPathResolver.Resolve(root, p);
+ Assert.NotNull(n);
+ }
+
+ // The two paths should resolve to *different* nodes — proving the
+ // resolver no longer collapses to "first descendant".
+ var n1 = UiaPathResolver.Resolve(root, paths[0])!;
+ var n2 = UiaPathResolver.Resolve(root, paths[1])!;
+ Assert.NotSame(n1, n2);
+ Assert.Equal("ItemsControl", n1.ClassName);
+ Assert.Equal("CB", n2.AutomationId);
+ }
+}
diff --git a/tests/Recordingtest.Recorder.Tests/RecorderTests.cs b/tests/Recordingtest.Recorder.Tests/RecorderTests.cs
index dbb8421..7ab54ec 100644
--- a/tests/Recordingtest.Recorder.Tests/RecorderTests.cs
+++ b/tests/Recordingtest.Recorder.Tests/RecorderTests.cs
@@ -299,6 +299,138 @@ public class RecorderTests
Assert.Equal(0x43u, steps[0].RawVk);
}
+ [Fact]
+ public void DragCollapser_TypeAfterFocusChange_InheritsTarget()
+ {
+ // focus_change → key_down B,O,X (no resolver result for keys).
+ UiaResolution? Resolver(RawEvent ev) => null;
+ const string focusPath = "MetroWindow[@AutomationId='root']/CommandPanel/TextBox[@AutomationId='CommandBox']/TextBox[@AutomationId='CB']";
+ var events = new[]
+ {
+ new RawEvent(100, "focus_change", 0, 0, 0, 0, focusPath),
+ new RawEvent(110, "key_down", 0, 0, 0x42, 0), // B
+ new RawEvent(120, "key_down", 0, 0, 0x4F, 0), // O
+ new RawEvent(130, "key_down", 0, 0, 0x58, 0), // X
+ };
+
+ var steps = new DragCollapser().Collapse(events, Resolver);
+
+ // focus + type
+ Assert.Equal(2, steps.Count);
+ Assert.Equal("type", steps[1].Kind);
+ Assert.Equal("BOX", steps[1].Value);
+ Assert.NotNull(steps[1].Target);
+ Assert.Equal(focusPath, steps[1].Target!.UiaPath);
+ }
+
+ [Fact]
+ public void DragCollapser_TypeAfterMouseDown_FallbackToMouseTarget()
+ {
+ var el = MakeRectElement("Canvas", 0, 0, 800, 600);
+ const string mousePath = "Window[@Name='Canvas']";
+ UiaResolution? Resolver(RawEvent ev) =>
+ ev.Kind == "key_down" || ev.Kind == "key_up"
+ ? null
+ : new UiaResolution(el, mousePath);
+
+ var events = new[]
+ {
+ new RawEvent(100, "mouse_down_l", 100, 100, 0, 0),
+ new RawEvent(105, "mouse_up_l", 101, 101, 0, 0),
+ new RawEvent(110, "key_down", 0, 0, 0x31, 0), // 1
+ new RawEvent(120, "key_down", 0, 0, 0x30, 0), // 0
+ };
+
+ var steps = new DragCollapser().Collapse(events, Resolver);
+
+ Assert.Equal(2, steps.Count);
+ Assert.Equal("click", steps[0].Kind);
+ Assert.Equal("type", steps[1].Kind);
+ Assert.Equal("10", steps[1].Value);
+ Assert.NotNull(steps[1].Target);
+ Assert.Equal(mousePath, steps[1].Target!.UiaPath);
+ }
+
+ [Fact]
+ public void WindowFilter_ExternalCoord_DropsEvent()
+ {
+ // SUT pid is 42. Mouse event at (10,10) belongs to pid 99 → drop.
+ var filter = new SutProcessWindowFilter(
+ sutPid: 42,
+ processFromPoint: (_, _) => 99,
+ processFromForeground: () => 99);
+
+ var ev = new RawEvent(0, "mouse_down_l", 10, 10, 0, 0);
+ Assert.False(filter.ShouldKeep(ev));
+
+ var key = new RawEvent(0, "key_down", 0, 0, 0x42, 0);
+ Assert.False(filter.ShouldKeep(key));
+ }
+
+ [Fact]
+ public void WindowFilter_SutCoord_KeepsEvent()
+ {
+ var filter = new SutProcessWindowFilter(
+ sutPid: 42,
+ processFromPoint: (_, _) => 42,
+ processFromForeground: () => 42);
+
+ Assert.True(filter.ShouldKeep(new RawEvent(0, "mouse_down_l", 10, 10, 0, 0)));
+ Assert.True(filter.ShouldKeep(new RawEvent(0, "key_down", 0, 0, 0x42, 0)));
+ // Unknown pid (0) is permissively kept to avoid false drops.
+ var permissive = new SutProcessWindowFilter(
+ sutPid: 42,
+ processFromPoint: (_, _) => 0,
+ processFromForeground: () => 0);
+ Assert.True(permissive.ShouldKeep(new RawEvent(0, "mouse_down_l", 1, 1, 0, 0)));
+ }
+
+ [Fact]
+ public void ScenarioWriter_RoundTrip_PreservesKorean()
+ {
+ var s = new Scenario
+ {
+ Name = "한글-시나리오",
+ Description = "회귀 테스트 — 새 파일",
+ Steps =
+ {
+ new ScenarioStep
+ {
+ Kind = "click",
+ Target = new ScenarioTarget
+ {
+ UiaPath = "MetroWindow[@AutomationId='root']/Button[@Name='새 파일']",
+ Offset = new[] { 0.5, 0.5 },
+ },
+ },
+ },
+ };
+
+ var tmp = Path.Combine(Path.GetTempPath(), $"recordingtest-utf8-{System.Guid.NewGuid():N}.yaml");
+ try
+ {
+ ScenarioWriter.WriteToFile(s, tmp);
+
+ // No BOM at byte 0.
+ var bytes = File.ReadAllBytes(tmp);
+ Assert.True(bytes.Length >= 3);
+ Assert.False(bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF,
+ "ScenarioWriter must emit UTF-8 without BOM");
+
+ var text = File.ReadAllText(tmp, System.Text.Encoding.UTF8);
+ var parsed = ScenarioWriter.Deserialize(text);
+
+ Assert.Equal(s.Name, parsed.Name);
+ Assert.Equal(s.Description, parsed.Description);
+ Assert.Equal("MetroWindow[@AutomationId='root']/Button[@Name='새 파일']",
+ parsed.Steps[0].Target!.UiaPath);
+ }
+ finally
+ {
+ if (File.Exists(tmp)) File.Delete(tmp);
+ }
+ }
+
[Fact]
public void Cli_MissingAttach_ExitTwo()
{