From 8784fec923ae284ef998e5bf1a09477b8895a8d2 Mon Sep 17 00:00:00 2001 From: minsung Date: Tue, 7 Apr 2026 20:30:59 +0900 Subject: [PATCH] =?UTF-8?q?Fix=20smoke=201=EC=B0=A8=20follow-up=20gaps:=20?= =?UTF-8?q?player=20resolver,=20type=20target,=20filter,=20utf8=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/guides/smoke-test.md | 18 +++ .../2026-04-07_이슈12-smoke2-fix-generator.md | 60 ++++++++ src/Recordingtest.Player/IUiaPathResolver.cs | 131 +++++++++++++++++ src/Recordingtest.Player/UiaPathParser.cs | 115 +++++++++++++++ src/Recordingtest.Player/UiaPlayerHost.cs | 87 +++++++---- src/Recordingtest.Recorder/DragCollapser.cs | 26 ++++ src/Recordingtest.Recorder/LowLevelHook.cs | 18 ++- src/Recordingtest.Recorder/NativeMethods.cs | 9 ++ src/Recordingtest.Recorder/Program.cs | 24 ++++ src/Recordingtest.Recorder/ScenarioWriter.cs | 8 +- src/Recordingtest.Recorder/WindowFilter.cs | 69 +++++++++ .../UiaPathResolverTests.cs | 135 ++++++++++++++++++ .../RecorderTests.cs | 132 +++++++++++++++++ 13 files changed, 802 insertions(+), 30 deletions(-) create mode 100644 docs/history/2026-04-07_이슈12-smoke2-fix-generator.md create mode 100644 src/Recordingtest.Player/IUiaPathResolver.cs create mode 100644 src/Recordingtest.Player/UiaPathParser.cs create mode 100644 src/Recordingtest.Recorder/WindowFilter.cs create mode 100644 tests/Recordingtest.Player.Tests/UiaPathResolverTests.cs 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() {