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()
{