diff --git a/docs/history/2026-04-07_이슈6-recorder-iteration2.md b/docs/history/2026-04-07_이슈6-recorder-iteration2.md new file mode 100644 index 0000000..ae90804 --- /dev/null +++ b/docs/history/2026-04-07_이슈6-recorder-iteration2.md @@ -0,0 +1,16 @@ +# 이슈 #6 — Recorder Iteration 2 + +- 소요 시간: 약 20분 +- Context 사용량: 약 35K tokens +- 이슈: #6 (recorder) + +## 변경 요약 +1. `DragCollapser.cs` 신규: down/move/up 상태 머신으로 click 또는 drag 스텝 생성. focus_change/wheel/key_down/right-click도 처리. +2. `Scenario.ScenarioStep`에 `Ts`, `RawCoord`, `EndOffset`, `EndRawCoord` 필드 추가 (snake_case 직렬화). +3. `RawEvent`에 `FocusedElementPath` 필드 추가. +4. `Program.cs`: ConsumeAsync는 raw 이벤트 버퍼링만, 종료 시 DragCollapser로 일괄 변환. `UIA3Automation.RegisterFocusChangedEvent` 호출 추가 (callback에서는 element 캡처 + path build만 수행하고 큐로 push). +5. 테스트 4개 추가 (drag/click/focus/yaml roundtrip), 총 9개. + +## 결과 +- `dotnet build recordingtest.sln`: 0 warning / 0 error +- `dotnet test tests/Recordingtest.Recorder.Tests`: 9 passed diff --git a/src/Recordingtest.Recorder/DragCollapser.cs b/src/Recordingtest.Recorder/DragCollapser.cs new file mode 100644 index 0000000..4621e0f --- /dev/null +++ b/src/Recordingtest.Recorder/DragCollapser.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Generic; + +namespace Recordingtest.Recorder; + +/// +/// Resolution of a raw event into an UIA element snapshot + path. +/// Provided by the caller (usually backed by FlaUI from-point lookup). +/// +public sealed record UiaResolution(IElementSnapshot Snapshot, string UiaPath); + +/// +/// Pure state machine that collapses a raw event stream into ScenarioSteps. +/// Recognizes click vs drag based on movement between mouse_down/mouse_up. +/// +public sealed class DragCollapser +{ + public int DragThresholdPx { get; } + + public DragCollapser(int dragThresholdPx = 4) + { + DragThresholdPx = dragThresholdPx; + } + + public IReadOnlyList Collapse( + IEnumerable events, + Func resolver) + { + var steps = new List(); + RawEvent? down = null; + int lastX = 0, lastY = 0; + int maxDistSq = 0; + + foreach (var ev in events) + { + switch (ev.Kind) + { + case "mouse_down_l": + down = ev; + lastX = ev.X; + lastY = ev.Y; + maxDistSq = 0; + break; + + case "move": + if (down is not null) + { + int dx = ev.X - down.X; + int dy = ev.Y - down.Y; + int d2 = dx * dx + dy * dy; + if (d2 > maxDistSq) maxDistSq = d2; + lastX = ev.X; + lastY = ev.Y; + } + break; + + case "mouse_up_l": + if (down is not null) + { + int fdx = ev.X - down.X; + int fdy = ev.Y - down.Y; + int finalDistSq = fdx * fdx + fdy * fdy; + int useSq = Math.Max(maxDistSq, finalDistSq); + var threshSq = DragThresholdPx * DragThresholdPx; + + var downRes = resolver(down); + if (useSq >= threshSq) + { + // drag step + var step = new ScenarioStep + { + Kind = "drag", + Ts = down.TimestampMs, + RawCoord = new[] { down.X, down.Y }, + EndRawCoord = new[] { ev.X, ev.Y }, + }; + if (downRes is not null) + { + var (sx, sy) = OffsetNormalizer.Normalize( + downRes.Snapshot.BoundingRectangle, down.X, down.Y); + var (ex, ey) = OffsetNormalizer.Normalize( + downRes.Snapshot.BoundingRectangle, ev.X, ev.Y); + step.Target = new ScenarioTarget + { + UiaPath = downRes.UiaPath, + Offset = new[] { sx, sy }, + }; + step.EndOffset = new[] { ex, ey }; + } + steps.Add(step); + } + else + { + // click step at down point + var step = new ScenarioStep + { + Kind = "click", + Ts = down.TimestampMs, + RawCoord = new[] { down.X, down.Y }, + }; + if (downRes is not null) + { + var (ox, oy) = OffsetNormalizer.Normalize( + downRes.Snapshot.BoundingRectangle, down.X, down.Y); + step.Target = new ScenarioTarget + { + UiaPath = downRes.UiaPath, + Offset = new[] { ox, oy }, + }; + if (MaskPolicy.IsMasked(downRes.Snapshot)) + { + step.Value = MaskPolicy.MaskedValue; + } + } + steps.Add(step); + } + down = null; + maxDistSq = 0; + } + break; + + case "mouse_down_r": + { + var res = resolver(ev); + var step = new ScenarioStep + { + Kind = "click", + Ts = ev.TimestampMs, + RawCoord = new[] { ev.X, ev.Y }, + Value = "right", + }; + if (res is not null) + { + var (ox, oy) = OffsetNormalizer.Normalize( + res.Snapshot.BoundingRectangle, ev.X, ev.Y); + step.Target = new ScenarioTarget + { + UiaPath = res.UiaPath, + Offset = new[] { ox, oy }, + }; + } + steps.Add(step); + break; + } + + case "key_down": + { + var res = resolver(ev); + var step = new ScenarioStep + { + Kind = "type", + Ts = ev.TimestampMs, + Value = ev.Code.ToString(System.Globalization.CultureInfo.InvariantCulture), + }; + if (res is not null) + { + step.Target = new ScenarioTarget + { + UiaPath = res.UiaPath, + Offset = new[] { 0.5, 0.5 }, + }; + if (MaskPolicy.IsMasked(res.Snapshot)) + { + step.Value = MaskPolicy.MaskedValue; + } + } + steps.Add(step); + break; + } + + case "wheel": + { + var res = resolver(ev); + var step = new ScenarioStep + { + Kind = "wheel", + Ts = ev.TimestampMs, + RawCoord = new[] { ev.X, ev.Y }, + Value = ev.WheelDelta.ToString(System.Globalization.CultureInfo.InvariantCulture), + }; + if (res is not null) + { + var (ox, oy) = OffsetNormalizer.Normalize( + res.Snapshot.BoundingRectangle, ev.X, ev.Y); + step.Target = new ScenarioTarget + { + UiaPath = res.UiaPath, + Offset = new[] { ox, oy }, + }; + } + steps.Add(step); + break; + } + + case "focus_change": + { + var step = new ScenarioStep + { + Kind = "focus", + Ts = ev.TimestampMs, + }; + if (!string.IsNullOrEmpty(ev.FocusedElementPath)) + { + step.Target = new ScenarioTarget + { + UiaPath = ev.FocusedElementPath!, + Offset = new[] { 0.5, 0.5 }, + }; + } + steps.Add(step); + break; + } + } + } + + return steps; + } +} diff --git a/src/Recordingtest.Recorder/LowLevelHook.cs b/src/Recordingtest.Recorder/LowLevelHook.cs index 58c2b25..e4fd833 100644 --- a/src/Recordingtest.Recorder/LowLevelHook.cs +++ b/src/Recordingtest.Recorder/LowLevelHook.cs @@ -4,7 +4,7 @@ using System.Threading.Channels; namespace Recordingtest.Recorder; -public sealed record RawEvent(long TimestampMs, string Kind, int X, int Y, uint Code, int WheelDelta); +public sealed record RawEvent(long TimestampMs, string Kind, int X, int Y, uint Code, int WheelDelta, string? FocusedElementPath = null); /// /// Installs WH_KEYBOARD_LL and WH_MOUSE_LL hooks on a dedicated thread with its own message loop. diff --git a/src/Recordingtest.Recorder/Program.cs b/src/Recordingtest.Recorder/Program.cs index 5f182c2..ea235c4 100644 --- a/src/Recordingtest.Recorder/Program.cs +++ b/src/Recordingtest.Recorder/Program.cs @@ -94,16 +94,46 @@ public static class Program cts.Cancel(); }; + // Register UIA focus changed event. The callback only captures the + // element path and pushes a synthetic RawEvent into the same queue; + // it does NOT compute anything else inside the UIA callback. + try + { + if (automation is not null) + { + automation.RegisterFocusChangedEvent(el => + { + try + { + if (el is null) return; + var snap = new FlaUiSnapshot(el); + var path = ElementPathBuilder.Build(snap); + channel.Writer.TryWrite(new RawEvent( + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + "focus_change", 0, 0, 0, 0, path)); + } + catch + { + // never throw from UIA callback + } + }); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"[recorder] focus subscribe failed: {ex.Message}"); + } + Console.WriteLine("[recorder] capturing... press Ctrl+C to stop."); int eventCount = 0; int unresolved = 0; var sw = Stopwatch.StartNew(); + var rawBuffer = new System.Collections.Generic.List(); try { - ConsumeAsync(channel.Reader, scenario, mainWindow, automation, cts.Token, - onEvent: () => eventCount++, - onUnresolved: () => unresolved++).GetAwaiter().GetResult(); + ConsumeAsync(channel.Reader, rawBuffer, cts.Token, + onEvent: () => eventCount++).GetAwaiter().GetResult(); } catch (OperationCanceledException) { @@ -111,6 +141,33 @@ public static class Program } sw.Stop(); + + // Collapse buffered raw events into scenario steps via DragCollapser. + var collapser = new DragCollapser(); + UiaResolution? Resolve(RawEvent ev) + { + if (automation is null) return null; + try + { + var snap = ResolveAt(automation, ev.X, ev.Y); + if (snap is null) + { + unresolved++; + return null; + } + var path = ElementPathBuilder.Build(snap); + return new UiaResolution(snap, path); + } + catch + { + unresolved++; + return null; + } + } + foreach (var step in collapser.Collapse(rawBuffer, Resolve)) + { + scenario.Steps.Add(step); + } ScenarioWriter.WriteToFile(scenario, args.OutputPath); Console.WriteLine($"[recorder] done. events={eventCount} elapsed={sw.Elapsed} unresolved_paths={unresolved}"); @@ -155,59 +212,20 @@ public static class Program private static async Task ConsumeAsync( ChannelReader reader, - Scenario scenario, - AutomationElement? mainWindow, - UIA3Automation? automation, + System.Collections.Generic.List buffer, CancellationToken ct, - Action onEvent, - Action onUnresolved) + Action onEvent) { while (await reader.WaitToReadAsync(ct).ConfigureAwait(false)) { while (reader.TryRead(out var ev)) { onEvent(); - if (!IsInterestingForStep(ev.Kind)) continue; - - IElementSnapshot? snap = null; - if (automation is not null) - { - try - { - snap = ResolveAt(automation, ev.X, ev.Y); - } - catch - { - snap = null; - } - } - - if (snap is null) - { - onUnresolved(); - continue; - } - - var path = ElementPathBuilder.Build(snap); - var (dx, dy) = OffsetNormalizer.Normalize(snap.BoundingRectangle, ev.X, ev.Y); - var step = new ScenarioStep - { - Kind = ev.Kind.StartsWith("key", StringComparison.Ordinal) ? "type" : "click", - Target = new ScenarioTarget - { - UiaPath = path, - Offset = new[] { dx, dy }, - }, - Value = MaskPolicy.IsMasked(snap) ? MaskPolicy.MaskedValue : null, - }; - scenario.Steps.Add(step); + buffer.Add(ev); } } } - private static bool IsInterestingForStep(string kind) => - kind == "mouse_down_l" || kind == "mouse_down_r" || kind == "key_down"; - private static IElementSnapshot? ResolveAt(UIA3Automation automation, int x, int y) { var raw = automation.FromPoint(new System.Drawing.Point(x, y)); diff --git a/src/Recordingtest.Recorder/Scenario.cs b/src/Recordingtest.Recorder/Scenario.cs index dac34bb..ab25a50 100644 --- a/src/Recordingtest.Recorder/Scenario.cs +++ b/src/Recordingtest.Recorder/Scenario.cs @@ -18,11 +18,19 @@ public sealed class ScenarioSut public sealed class ScenarioStep { - /// click | type | drag | hotkey | wait + /// click | type | drag | hotkey | wait | focus public string Kind { get; set; } = "click"; public ScenarioTarget? Target { get; set; } public string? Value { get; set; } public string? WaitFor { get; set; } + /// ms since recording start (or epoch ms from RawEvent). + public long Ts { get; set; } + /// Raw screen coordinate [x, y] when applicable. + public int[]? RawCoord { get; set; } + /// For drag steps: end offset within target element. + public double[]? EndOffset { get; set; } + /// For drag steps: end raw coordinate [x, y]. + public int[]? EndRawCoord { get; set; } } public sealed class ScenarioTarget diff --git a/tests/Recordingtest.Recorder.Tests/RecorderTests.cs b/tests/Recordingtest.Recorder.Tests/RecorderTests.cs index 862d1ec..bf24cb9 100644 --- a/tests/Recordingtest.Recorder.Tests/RecorderTests.cs +++ b/tests/Recordingtest.Recorder.Tests/RecorderTests.cs @@ -123,6 +123,107 @@ public class RecorderTests Assert.Equal(s.Steps[1].Value, parsed.Steps[1].Value); } + private static FakeElement MakeRectElement(string path, double l, double t, double w, double h) => + new FakeElement + { + ClassName = "Window", + Name = path, + BoundingRectangle = (l, t, w, h), + }; + + [Fact] + public void DragCollapser_DownMoveUp_BeyondThreshold_EmitsDrag() + { + var el = MakeRectElement("Canvas", 0, 0, 1000, 1000); + var path = "Window[@Name='Canvas']"; + UiaResolution? Resolver(RawEvent _) => new UiaResolution(el, path); + + var events = new[] + { + new RawEvent(100, "mouse_down_l", 100, 100, 0, 0), + new RawEvent(110, "move", 150, 120, 0, 0), + new RawEvent(120, "move", 300, 400, 0, 0), + new RawEvent(130, "mouse_up_l", 300, 400, 0, 0), + }; + + var steps = new DragCollapser().Collapse(events, Resolver); + + Assert.Single(steps); + Assert.Equal("drag", steps[0].Kind); + Assert.Equal(path, steps[0].Target!.UiaPath); + Assert.Equal(new[] { 100, 100 }, steps[0].RawCoord); + Assert.Equal(new[] { 300, 400 }, steps[0].EndRawCoord); + Assert.NotNull(steps[0].EndOffset); + Assert.Equal(100L, steps[0].Ts); + } + + [Fact] + public void DragCollapser_DownUp_BelowThreshold_EmitsClick() + { + var el = MakeRectElement("Btn", 0, 0, 100, 100); + UiaResolution? Resolver(RawEvent _) => new UiaResolution(el, "Window[@Name='Btn']"); + + var events = new[] + { + new RawEvent(50, "mouse_down_l", 10, 10, 0, 0), + new RawEvent(55, "mouse_up_l", 11, 11, 0, 0), + }; + + var steps = new DragCollapser().Collapse(events, Resolver); + + Assert.Single(steps); + Assert.Equal("click", steps[0].Kind); + Assert.Equal(new[] { 10, 10 }, steps[0].RawCoord); + Assert.Equal(50L, steps[0].Ts); + } + + [Fact] + public void DragCollapser_FocusChangeEvent_EmitsFocusStep() + { + var events = new[] + { + new RawEvent(200, "focus_change", 0, 0, 0, 0, "Window[@Name='Main']/Edit[@AutomationId='Pwd']"), + }; + + var steps = new DragCollapser().Collapse(events, _ => null); + + Assert.Single(steps); + Assert.Equal("focus", steps[0].Kind); + Assert.Equal("Window[@Name='Main']/Edit[@AutomationId='Pwd']", steps[0].Target!.UiaPath); + Assert.Equal(200L, steps[0].Ts); + } + + [Fact] + public void ScenarioStep_YamlRoundtrip_PreservesTsAndRawCoord() + { + var s = new Scenario + { + Name = "ts-test", + Steps = + { + new ScenarioStep + { + Kind = "click", + Ts = 12345, + RawCoord = new[] { 640, 480 }, + Target = new ScenarioTarget + { + UiaPath = "Window[@Name='X']", + Offset = new[] { 0.5, 0.5 }, + }, + }, + }, + }; + + var yaml = ScenarioWriter.Serialize(s); + Assert.Contains("ts:", yaml); + Assert.Contains("raw_coord", yaml); + + var parsed = ScenarioWriter.Deserialize(yaml); + Assert.Equal(12345L, parsed.Steps[0].Ts); + Assert.Equal(new[] { 640, 480 }, parsed.Steps[0].RawCoord); + } + [Fact] public void Cli_MissingAttach_ExitTwo() {