Fix smoke 1차 follow-up gaps: player resolver, type target, filter, utf8 (#12)
This commit is contained in:
@@ -224,6 +224,24 @@ Smoke test 완료 후:
|
|||||||
3. 10회 재생 reliability 수치 → player DoD #7 update
|
3. 10회 재생 reliability 수치 → player DoD #7 update
|
||||||
4. 이슈 #2 또는 새 이슈에 리포트 코멘트
|
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 이후 과제
|
## v3 이후 과제
|
||||||
|
|
||||||
- recorder IME 조합 키 처리
|
- recorder IME 조합 키 처리
|
||||||
|
|||||||
60
docs/history/2026-04-07_이슈12-smoke2-fix-generator.md
Normal file
60
docs/history/2026-04-07_이슈12-smoke2-fix-generator.md
Normal file
@@ -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 재생 검증
|
||||||
131
src/Recordingtest.Player/IUiaPathResolver.cs
Normal file
131
src/Recordingtest.Player/IUiaPathResolver.cs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Recordingtest.Player;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure adapter over an UIA element tree. Lets <see cref="UiaPathResolver"/>
|
||||||
|
/// be unit tested with a fake in-memory tree.
|
||||||
|
/// </summary>
|
||||||
|
public interface IUiaTreeNode
|
||||||
|
{
|
||||||
|
string ClassName { get; }
|
||||||
|
string? AutomationId { get; }
|
||||||
|
string? Name { get; }
|
||||||
|
ElementBounds Bounds { get; }
|
||||||
|
IEnumerable<IUiaTreeNode> Children { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves a UIA path string against a tree rooted at a window node.
|
||||||
|
/// Pure logic, fully unit testable.
|
||||||
|
/// </summary>
|
||||||
|
public static class UiaPathResolver
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Descend a tree following the segments of <paramref name="uiaPath"/>.
|
||||||
|
/// The first segment matches the root (or its first descendant whose
|
||||||
|
/// ClassName matches). Subsequent segments search direct children of
|
||||||
|
/// the previously matched node.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The resolved node or null when any segment fails to match.</returns>
|
||||||
|
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<IUiaTreeNode> Descendants(IUiaTreeNode root)
|
||||||
|
{
|
||||||
|
var stack = new Stack<IUiaTreeNode>();
|
||||||
|
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<IUiaTreeNode> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
115
src/Recordingtest.Player/UiaPathParser.cs
Normal file
115
src/Recordingtest.Player/UiaPathParser.cs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Recordingtest.Player;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure parser for UIA path strings of the form
|
||||||
|
/// <c>ClassName[@AutomationId='...'][@Name='...']/ClassName[@AutomationId='...']</c>.
|
||||||
|
///
|
||||||
|
/// Segments are separated by <c>/</c> at depth zero (slashes inside <c>'...'</c>
|
||||||
|
/// quoted attribute values are preserved). Attributes supported: AutomationId, Name.
|
||||||
|
/// Both attributes are optional; ClassName must always be present.
|
||||||
|
/// </summary>
|
||||||
|
public static class UiaPathParser
|
||||||
|
{
|
||||||
|
public sealed record Segment(string ClassName, string? AutomationId, string? Name);
|
||||||
|
|
||||||
|
public static IReadOnlyList<Segment> Parse(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(path))
|
||||||
|
{
|
||||||
|
return Array.Empty<Segment>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw = SplitTopLevel(path);
|
||||||
|
var result = new List<Segment>(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<string> SplitTopLevel(string path)
|
||||||
|
{
|
||||||
|
var parts = new List<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,37 +29,28 @@ public sealed class UiaPlayerHost : IPlayerHost, IDisposable
|
|||||||
|
|
||||||
public ResolvedElement? ResolveElement(string uiaPath, TimeSpan timeout)
|
public ResolvedElement? ResolveElement(string uiaPath, TimeSpan timeout)
|
||||||
{
|
{
|
||||||
// Best-effort: search by AutomationId fragment in the last segment.
|
// Issue #12 — full UIA path resolver. Walk the segment chain via the
|
||||||
// A full UIA-path resolver is out of PoC scope; recorder produces
|
// pure UiaPathResolver against a FlaUI-backed tree adapter so the
|
||||||
// simple AutomationId-based paths in the bootstrap scenarios.
|
// logic is unit testable independent of the live SUT.
|
||||||
var automationId = ExtractAutomationId(uiaPath);
|
|
||||||
var window = _app?.GetMainWindow(_automation, timeout);
|
var window = _app?.GetMainWindow(_automation, timeout);
|
||||||
if (window is null)
|
if (window is null)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var element = Retry.WhileNull(
|
var resolved = Retry.WhileNull<IUiaTreeNode?>(
|
||||||
() =>
|
() => UiaPathResolver.Resolve(new FlaUiTreeNode(window), uiaPath),
|
||||||
{
|
|
||||||
if (!string.IsNullOrEmpty(automationId))
|
|
||||||
{
|
|
||||||
return window.FindFirstDescendant(cf => cf.ByAutomationId(automationId));
|
|
||||||
}
|
|
||||||
return window.FindFirstDescendant();
|
|
||||||
},
|
|
||||||
timeout: timeout,
|
timeout: timeout,
|
||||||
ignoreException: true).Result;
|
ignoreException: true).Result;
|
||||||
|
|
||||||
if (element is null)
|
if (resolved is null)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var r = element.BoundingRectangle;
|
var b = resolved.Bounds;
|
||||||
return new ResolvedElement(
|
var native = resolved is FlaUiTreeNode fn ? (object?)fn.Element : null;
|
||||||
new ElementBounds(r.X, r.Y, r.Width, r.Height),
|
return new ResolvedElement(b, native);
|
||||||
element);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool WaitFor(string waitForHint, TimeSpan timeout)
|
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}");
|
$"[{DateTime.UtcNow:o}] step={stepIndex} reason={reason}{Environment.NewLine}");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ExtractAutomationId(string uiaPath)
|
/// <summary>
|
||||||
|
/// FlaUI-backed adapter for the pure <see cref="IUiaTreeNode"/> interface.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class FlaUiTreeNode : IUiaTreeNode
|
||||||
{
|
{
|
||||||
// Look for [@AutomationId='...'] in the last segment.
|
public AutomationElement Element { get; }
|
||||||
var marker = "@AutomationId='";
|
|
||||||
var idx = uiaPath.LastIndexOf(marker, StringComparison.Ordinal);
|
public FlaUiTreeNode(AutomationElement el)
|
||||||
if (idx < 0) return string.Empty;
|
{
|
||||||
var start = idx + marker.Length;
|
Element = el;
|
||||||
var end = uiaPath.IndexOf('\'', start);
|
}
|
||||||
if (end < 0) return string.Empty;
|
|
||||||
return uiaPath.Substring(start, end - start);
|
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<IUiaTreeNode> Children
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
AutomationElement[] arr;
|
||||||
|
try { arr = Element.FindAllChildren(); }
|
||||||
|
catch { yield break; }
|
||||||
|
foreach (var c in arr)
|
||||||
|
{
|
||||||
|
yield return new FlaUiTreeNode(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ public sealed class DragCollapser
|
|||||||
// Active modifiers (ctrl/shift/alt/win) held down.
|
// Active modifiers (ctrl/shift/alt/win) held down.
|
||||||
var modsDown = new HashSet<string>(StringComparer.Ordinal);
|
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()
|
void FlushType()
|
||||||
{
|
{
|
||||||
if (typeBuf.Length == 0 || typeFirst is null) return;
|
if (typeBuf.Length == 0 || typeFirst is null) return;
|
||||||
@@ -59,6 +67,18 @@ public sealed class DragCollapser
|
|||||||
step.Value = MaskPolicy.MaskedValue;
|
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);
|
steps.Add(step);
|
||||||
typeBuf.Clear();
|
typeBuf.Clear();
|
||||||
typeFirst = null;
|
typeFirst = null;
|
||||||
@@ -103,6 +123,10 @@ public sealed class DragCollapser
|
|||||||
var threshSq = DragThresholdPx * DragThresholdPx;
|
var threshSq = DragThresholdPx * DragThresholdPx;
|
||||||
|
|
||||||
var downRes = resolver(down);
|
var downRes = resolver(down);
|
||||||
|
if (downRes is not null)
|
||||||
|
{
|
||||||
|
lastMousePath = downRes.UiaPath;
|
||||||
|
}
|
||||||
if (useSq >= threshSq)
|
if (useSq >= threshSq)
|
||||||
{
|
{
|
||||||
// drag step
|
// drag step
|
||||||
@@ -161,6 +185,7 @@ public sealed class DragCollapser
|
|||||||
case "mouse_down_r":
|
case "mouse_down_r":
|
||||||
{
|
{
|
||||||
var res = resolver(ev);
|
var res = resolver(ev);
|
||||||
|
if (res is not null) lastMousePath = res.UiaPath;
|
||||||
var step = new ScenarioStep
|
var step = new ScenarioStep
|
||||||
{
|
{
|
||||||
Kind = "click",
|
Kind = "click",
|
||||||
@@ -304,6 +329,7 @@ public sealed class DragCollapser
|
|||||||
UiaPath = ev.FocusedElementPath!,
|
UiaPath = ev.FocusedElementPath!,
|
||||||
Offset = new[] { 0.5, 0.5 },
|
Offset = new[] { 0.5, 0.5 },
|
||||||
};
|
};
|
||||||
|
lastFocusPath = ev.FocusedElementPath;
|
||||||
}
|
}
|
||||||
steps.Add(step);
|
steps.Add(step);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ public sealed class LowLevelHook : IDisposable
|
|||||||
private uint _threadId;
|
private uint _threadId;
|
||||||
private volatile bool _running;
|
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)
|
public LowLevelHook(Channel<RawEvent> channel)
|
||||||
{
|
{
|
||||||
_channel = channel;
|
_channel = channel;
|
||||||
@@ -77,7 +83,11 @@ public sealed class LowLevelHook : IDisposable
|
|||||||
NativeMethods.WM_KEYUP or NativeMethods.WM_SYSKEYUP => "key_up",
|
NativeMethods.WM_KEYUP or NativeMethods.WM_SYSKEYUP => "key_up",
|
||||||
_ => "key",
|
_ => "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);
|
return NativeMethods.CallNextHookEx(IntPtr.Zero, nCode, wParam, lParam);
|
||||||
}
|
}
|
||||||
@@ -105,7 +115,11 @@ public sealed class LowLevelHook : IDisposable
|
|||||||
{
|
{
|
||||||
wheel = (short)((data.mouseData >> 16) & 0xFFFF);
|
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);
|
return NativeMethods.CallNextHookEx(IntPtr.Zero, nCode, wParam, lParam);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,15 @@ internal static class NativeMethods
|
|||||||
[DllImport("user32.dll")]
|
[DllImport("user32.dll")]
|
||||||
public static extern void PostQuitMessage(int nExitCode);
|
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)]
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
public struct MSG
|
public struct MSG
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -82,6 +82,30 @@ public static class Program
|
|||||||
Console.Error.WriteLine($"[recorder] attach failed: {ex.Message}");
|
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
|
var scenario = new Scenario
|
||||||
{
|
{
|
||||||
Name = System.IO.Path.GetFileNameWithoutExtension(args.OutputPath),
|
Name = System.IO.Path.GetFileNameWithoutExtension(args.OutputPath),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
using YamlDotNet.Serialization;
|
using YamlDotNet.Serialization;
|
||||||
using YamlDotNet.Serialization.NamingConventions;
|
using YamlDotNet.Serialization.NamingConventions;
|
||||||
|
|
||||||
@@ -35,6 +36,11 @@ public static class ScenarioWriter
|
|||||||
{
|
{
|
||||||
Directory.CreateDirectory(dir);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
135
tests/Recordingtest.Player.Tests/UiaPathResolverTests.cs
Normal file
135
tests/Recordingtest.Player.Tests/UiaPathResolverTests.cs
Normal file
@@ -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<FakeNode> ChildList { get; } = new();
|
||||||
|
public IEnumerable<IUiaTreeNode> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -299,6 +299,138 @@ public class RecorderTests
|
|||||||
Assert.Equal(0x43u, steps[0].RawVk);
|
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]
|
[Fact]
|
||||||
public void Cli_MissingAttach_ExitTwo()
|
public void Cli_MissingAttach_ExitTwo()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user