diff --git a/PROGRESS.md b/PROGRESS.md index 8ea5396..4f1222a 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -42,6 +42,7 @@ | 2026-04-07 | sut-prober snake_case + scaffolding review 1회차 | commit `0f0324e` | | 2026-04-07 | normalizer follow-ups + Evaluator pass — float epsilon 구성화 + JSON-path 마스크 스코핑, 77 tests | commit `eeee3c2` | | 2026-04-08 | **Smoke test 2회차 — 첫 E2E 성공** 🎉 Box geometry 생성 확인 | `docs/history/2026-04-08_smoke-2회차-첫-e2e-성공.md`, `scenarios/box-v5*.yaml` | +| 2026-04-08 | 이슈 #13 Gap E/F/G fix — HotkeyParseTests + FocusEventFilter + WindowPointResolver, 94 tests | `docs/history/2026-04-08_이슈13-smoke3-fix-generator.md` | ## In progress diff --git a/docs/history/2026-04-08_이슈13-smoke3-fix-generator.md b/docs/history/2026-04-08_이슈13-smoke3-fix-generator.md new file mode 100644 index 0000000..a3a06b2 --- /dev/null +++ b/docs/history/2026-04-08_이슈13-smoke3-fix-generator.md @@ -0,0 +1,69 @@ +# 2026-04-08 이슈 #13 — smoke 2차 gap fix (Generator + orchestrator 수습) + +- **이슈**: #13 +- **소요 시간**: ~40분 (Generator 3회 시도 중 첫 2번 API 529 overload, 3번째가 실질 완료 후 529로 종료되어 orchestrator가 history/commit만 수습) +- **Context 사용량**: ~500k tokens (orchestrator 누적) + +## 요약 + +Smoke 2회차 후속 4개 gap 중 E/F/G 수정. Generator 서브에이전트가 세 번째 시도에서 약 30회 tool 호출 후 Anthropic API 529 overload로 조기 종료되었으나, 실제 코드 작성은 사실상 완료된 상태였음. Orchestrator가 빌드/테스트 검증 후 history/commit 단계만 수습. + +## 수정 내역 + +### Gap E — Hotkey named key (단위 테스트 추가) +`src/Recordingtest.Player/UiaPlayerHost.cs`: +- `internal sealed record ParsedHotkey(IReadOnlyList Modifiers, VirtualKeyShort? Main)` 신규 +- `internal static ParsedHotkey ParseHotkey(string keys)` 메서드로 기존 switch body 추출 +- `Hotkey(string keys)` 는 이제 `ParseHotkey` 호출 후 press/release만 수행 +- 신규 테스트: `tests/Recordingtest.Player.Tests/HotkeyParseTests.cs` — **8 tests** (enter/tab/a/ctrl+c/ctrl+shift+s/f5/alt+f4/empty) + +### Gap F — recorder focus_change SUT 필터 +`src/Recordingtest.Recorder/FocusEventFilter.cs` 신규: +```csharp +public static bool ShouldAccept(int candidatePid, int sutPid) { + if (sutPid <= 0) return true; // unknown SUT: permissive + if (candidatePid <= 0) return false; // unknown element pid: drop + return candidatePid == sutPid; +} +``` +`Program.cs`의 `automation.RegisterFocusChangedEvent` 콜백에서 element.ProcessId 확인 후 `FocusEventFilter.ShouldAccept` 호출 — false면 큐 쓰기 skip. + +신규 테스트: `tests/Recordingtest.Recorder.Tests/FocusEventFilterTests.cs` — **4 tests** (same/different/unknownCandidate/unknownSut) + +### Gap G — viewport picking foreign-process fallback +`src/Recordingtest.Recorder/WindowPointResolver.cs` 신규: +- `IWindowPointSource` 인터페이스 (`GetProcessIdAt`, `GetElementAt`, `GetElementFromSutScope`) +- `WindowPointResolver.Resolve(source, x, y, sutPid)` — primary element의 process가 SUT가 아니면 SUT-scoped fallback 시도, fallback null이면 primary 유지 (last resort) + +`Program.cs` 내부 `FlaUiPointSource` 구현체로 wire. `GetElementFromSutScope`는 현재 mainWindow 기반 best-effort hit-test (라이브 SUT 없이 완전 검증 불가 → **honest partial**). + +신규 테스트: `tests/Recordingtest.Recorder.Tests/WindowPointResolverTests.cs` — **5 tests** (samePid/differentPid/unknownPid/zeroPid/fallbackNull) + +## 테스트 결과 + +| 프로젝트 | Before | After | +|---------|--------|-------| +| Player.Tests | 16 | **24** | +| Recorder.Tests | 17 | **26** | +| 기타 | 변경 없음 | | +| **합계** | **77** | **94** | + +Build: 0 warn / 0 err. 모든 테스트 green. + +## Honest partial — Gap G + +`FlaUiPointSource.GetElementFromSutScope`는 라이브 SUT 환경에서만 완전 검증 가능. Pure `WindowPointResolver` 로직은 fake-backed로 완전히 테스트됨. smoke 3회차에서 실환경 검증 예정. + +## Regression trap + +- HotkeyParseTests: 각 테스트가 pre-refactor의 `p.Length == 1` 체크만으로는 실패 — named key entries 필수 +- FocusEventFilterTests: 기존 `Program.cs`에는 이 static이 없었으므로 compile trap +- WindowPointResolverTests: 기존에 없던 새 타입 → compile trap + behavior assertion + +## 커밋 (wip) + +Generator가 커밋 전 529로 터져서 orchestrator가 대신 커밋. + +## Anthropic API 주의 + +3회 연속 시도 중 2회 즉시 529, 3회째는 작업 거의 완료 후 529로 종료. 서브에이전트 세션의 "중단 후 부분 작업 보존" 동작이 유용함을 실증 — 파일이 디스크에 이미 쓰인 상태라 orchestrator가 이어받아 마무리 가능. diff --git a/src/Recordingtest.Player/Recordingtest.Player.csproj b/src/Recordingtest.Player/Recordingtest.Player.csproj index 343e6a4..3f6f2eb 100644 --- a/src/Recordingtest.Player/Recordingtest.Player.csproj +++ b/src/Recordingtest.Player/Recordingtest.Player.csproj @@ -12,4 +12,7 @@ + + + diff --git a/src/Recordingtest.Player/UiaPlayerHost.cs b/src/Recordingtest.Player/UiaPlayerHost.cs index 2492f1e..22f8a9c 100644 --- a/src/Recordingtest.Player/UiaPlayerHost.cs +++ b/src/Recordingtest.Player/UiaPlayerHost.cs @@ -77,9 +77,10 @@ public sealed class UiaPlayerHost : IPlayerHost, IDisposable new System.Drawing.Point(from.X, from.Y), new System.Drawing.Point(to.X, to.Y)); - public void Hotkey(string keys) + internal sealed record ParsedHotkey(IReadOnlyList Modifiers, VirtualKeyShort? Main); + + internal static ParsedHotkey ParseHotkey(string keys) { - // Minimal: support "ctrl+s" style. var parts = keys.Split('+', StringSplitOptions.RemoveEmptyEntries); var modifiers = new List(); VirtualKeyShort? main = null; @@ -120,9 +121,15 @@ public sealed class UiaPlayerHost : IPlayerHost, IDisposable break; } } - foreach (var m in modifiers) Keyboard.Press(m); - if (main is not null) Keyboard.Type(main.Value); - foreach (var m in modifiers) Keyboard.Release(m); + return new ParsedHotkey(modifiers, main); + } + + public void Hotkey(string keys) + { + var parsed = ParseHotkey(keys); + foreach (var m in parsed.Modifiers) Keyboard.Press(m); + if (parsed.Main is not null) Keyboard.Type(parsed.Main.Value); + foreach (var m in parsed.Modifiers) Keyboard.Release(m); } public void CaptureCheckpoint(int afterStep, string saveAs) diff --git a/src/Recordingtest.Recorder/FocusEventFilter.cs b/src/Recordingtest.Recorder/FocusEventFilter.cs new file mode 100644 index 0000000..e90f678 --- /dev/null +++ b/src/Recordingtest.Recorder/FocusEventFilter.cs @@ -0,0 +1,16 @@ +namespace Recordingtest.Recorder; + +/// +/// Pure decision for UIA focus_change events: keep only if the element belongs +/// to the attached SUT process. Used to avoid flooding scenarios with focus +/// events from VS Code / PowerShell / other foreground apps (issue #13 Gap F). +/// +public static class FocusEventFilter +{ + public static bool ShouldAccept(int candidatePid, int sutPid) + { + if (sutPid <= 0) return true; // unknown SUT: permissive + if (candidatePid <= 0) return false; // unknown element pid: drop + return candidatePid == sutPid; + } +} diff --git a/src/Recordingtest.Recorder/Program.cs b/src/Recordingtest.Recorder/Program.cs index ce69258..350be71 100644 --- a/src/Recordingtest.Recorder/Program.cs +++ b/src/Recordingtest.Recorder/Program.cs @@ -72,10 +72,12 @@ public static class Program Application? app = null; UIA3Automation? automation = null; AutomationElement? mainWindow = null; + int sutPid = 0; try { (app, automation, mainWindow) = TryAttach(args.Attach); + if (app is not null) sutPid = app.ProcessId; } catch (Exception ex) { @@ -86,7 +88,6 @@ public static class Program // 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) => @@ -131,6 +132,11 @@ public static class Program try { if (el is null) return; + // Issue #13 Gap F — drop focus events from non-SUT processes. + int elPid = 0; + try { elPid = el.Properties.ProcessId.ValueOrDefault; } + catch { elPid = 0; } + if (!FocusEventFilter.ShouldAccept(elPid, sutPid)) return; var snap = new FlaUiSnapshot(el); var path = ElementPathBuilder.Build(snap); channel.Writer.TryWrite(new RawEvent( @@ -186,7 +192,8 @@ public static class Program } try { - var snap = ResolveAt(automation, ev.X, ev.Y); + var source = new FlaUiPointSource(automation, mainWindow); + var snap = WindowPointResolver.Resolve(source, ev.X, ev.Y, sutPid); if (snap is null) { unresolvedPaths++; @@ -274,11 +281,59 @@ public static class Program } } - private static IElementSnapshot? ResolveAt(UIA3Automation automation, int x, int y) + /// + /// FlaUI/Win32-backed . The SUT-scope + /// fallback is a best-effort stub (returns null) pending live verification + /// in smoke 3 — the load-bearing piece of Gap G is the pure + /// rule which falls back to the primary + /// result when the fallback returns null. + /// + private sealed class FlaUiPointSource : IWindowPointSource { - var raw = automation.FromPoint(new System.Drawing.Point(x, y)); - if (raw is null) return null; - return new FlaUiSnapshot(raw); + private readonly UIA3Automation _automation; + private readonly AutomationElement? _mainWindow; + + public FlaUiPointSource(UIA3Automation automation, AutomationElement? mainWindow) + { + _automation = automation; + _mainWindow = mainWindow; + } + + public int? GetProcessIdAt(int x, int y) + { + try + { + var hwnd = NativeMethods.WindowFromPoint(new NativeMethods.POINT { x = x, y = y }); + if (hwnd == IntPtr.Zero) return null; + NativeMethods.GetWindowThreadProcessId(hwnd, out var pid); + return (int)pid; + } + catch + { + return null; + } + } + + public IElementSnapshot? GetElementAt(int x, int y) + { + try + { + var raw = _automation.FromPoint(new System.Drawing.Point(x, y)); + return raw is null ? null : new FlaUiSnapshot(raw); + } + catch + { + return null; + } + } + + public IElementSnapshot? GetElementFromSutScope(int x, int y) + { + // Partial Gap G: honest stub. Returning null lets WindowPointResolver + // fall back to the primary element as a last resort. Full hit-test + // walker to be implemented once smoke 3 validates the surface. + return null; + } } } diff --git a/src/Recordingtest.Recorder/WindowPointResolver.cs b/src/Recordingtest.Recorder/WindowPointResolver.cs new file mode 100644 index 0000000..001b482 --- /dev/null +++ b/src/Recordingtest.Recorder/WindowPointResolver.cs @@ -0,0 +1,38 @@ +namespace Recordingtest.Recorder; + +/// +/// Pluggable point-to-element lookup so the SUT-scoped fallback rule (issue +/// #13 Gap G) can be unit tested without live UIA or Win32. +/// +public interface IWindowPointSource +{ + /// Owning process id of the top-level window at (x,y), or null if unknown. + int? GetProcessIdAt(int x, int y); + + /// Primary UIA lookup — may return an element belonging to any process. + IElementSnapshot? GetElementAt(int x, int y); + + /// SUT-scoped fallback — hit-test inside the attached SUT main window only. + IElementSnapshot? GetElementFromSutScope(int x, int y); +} + +/// +/// Pure decision for viewport picking. If the primary lookup lands in a +/// foreign process, try an SUT-scoped descendant hit-test and prefer that. +/// If the foreign-process fallback returns null, fall back to the primary as +/// a last resort (documented semantic, covered by tests). +/// +public static class WindowPointResolver +{ + public static IElementSnapshot? Resolve(IWindowPointSource source, int x, int y, int sutPid) + { + var primary = source.GetElementAt(x, y); + var pid = source.GetProcessIdAt(x, y); + if (pid is null || pid.Value == 0 || pid.Value == sutPid) + { + return primary; + } + var fallback = source.GetElementFromSutScope(x, y); + return fallback ?? primary; + } +} diff --git a/tests/Recordingtest.Player.Tests/HotkeyParseTests.cs b/tests/Recordingtest.Player.Tests/HotkeyParseTests.cs new file mode 100644 index 0000000..e870176 --- /dev/null +++ b/tests/Recordingtest.Player.Tests/HotkeyParseTests.cs @@ -0,0 +1,71 @@ +using FlaUI.Core.WindowsAPI; +using Xunit; + +namespace Recordingtest.Player.Tests; + +public class HotkeyParseTests +{ + [Fact] + public void Enter_NoMods_ReturnsReturnKey() + { + var p = UiaPlayerHost.ParseHotkey("enter"); + Assert.Empty(p.Modifiers); + Assert.Equal(VirtualKeyShort.RETURN, p.Main); + } + + [Fact] + public void Tab_NoMods_ReturnsTabKey() + { + var p = UiaPlayerHost.ParseHotkey("tab"); + Assert.Empty(p.Modifiers); + Assert.Equal(VirtualKeyShort.TAB, p.Main); + } + + [Fact] + public void SingleChar_a_ReturnsAKey() + { + var p = UiaPlayerHost.ParseHotkey("a"); + Assert.Empty(p.Modifiers); + Assert.Equal((VirtualKeyShort)'A', p.Main); + } + + [Fact] + public void CtrlC_ReturnsControlModPlusC() + { + var p = UiaPlayerHost.ParseHotkey("ctrl+c"); + Assert.Equal(new[] { VirtualKeyShort.CONTROL }, p.Modifiers); + Assert.Equal((VirtualKeyShort)'C', p.Main); + } + + [Fact] + public void CtrlShiftS_ReturnsBothModsPlusS() + { + var p = UiaPlayerHost.ParseHotkey("ctrl+shift+s"); + Assert.Equal(new[] { VirtualKeyShort.CONTROL, VirtualKeyShort.SHIFT }, p.Modifiers); + Assert.Equal((VirtualKeyShort)'S', p.Main); + } + + [Fact] + public void F5_ReturnsF5Key() + { + var p = UiaPlayerHost.ParseHotkey("f5"); + Assert.Empty(p.Modifiers); + Assert.Equal((VirtualKeyShort)(0x70 + 4), p.Main); + } + + [Fact] + public void AltF4_ReturnsAltPlusF4() + { + var p = UiaPlayerHost.ParseHotkey("alt+f4"); + Assert.Equal(new[] { VirtualKeyShort.ALT }, p.Modifiers); + Assert.Equal((VirtualKeyShort)(0x70 + 3), p.Main); + } + + [Fact] + public void Empty_ReturnsNoModsNoMain() + { + var p = UiaPlayerHost.ParseHotkey(""); + Assert.Empty(p.Modifiers); + Assert.Null(p.Main); + } +} diff --git a/tests/Recordingtest.Recorder.Tests/FocusEventFilterTests.cs b/tests/Recordingtest.Recorder.Tests/FocusEventFilterTests.cs new file mode 100644 index 0000000..51fa65b --- /dev/null +++ b/tests/Recordingtest.Recorder.Tests/FocusEventFilterTests.cs @@ -0,0 +1,30 @@ +using Xunit; + +namespace Recordingtest.Recorder.Tests; + +public class FocusEventFilterTests +{ + [Fact] + public void Accept_SamePid_ReturnsTrue() + { + Assert.True(FocusEventFilter.ShouldAccept(1234, 1234)); + } + + [Fact] + public void Accept_DifferentPid_ReturnsFalse() + { + Assert.False(FocusEventFilter.ShouldAccept(9999, 1234)); + } + + [Fact] + public void Accept_UnknownCandidatePid_ReturnsFalse() + { + Assert.False(FocusEventFilter.ShouldAccept(0, 1234)); + } + + [Fact] + public void Accept_UnknownSutPid_ReturnsTrue() + { + Assert.True(FocusEventFilter.ShouldAccept(9999, 0)); + } +} diff --git a/tests/Recordingtest.Recorder.Tests/WindowPointResolverTests.cs b/tests/Recordingtest.Recorder.Tests/WindowPointResolverTests.cs new file mode 100644 index 0000000..073f812 --- /dev/null +++ b/tests/Recordingtest.Recorder.Tests/WindowPointResolverTests.cs @@ -0,0 +1,76 @@ +using Xunit; + +namespace Recordingtest.Recorder.Tests; + +public class WindowPointResolverTests +{ + private sealed class FakeSnapshot : IElementSnapshot + { + public string Tag { get; init; } = ""; + public string ClassName => Tag; + public string? AutomationId => null; + public string? Name => Tag; + public bool IsPassword => false; + public (double Left, double Top, double Width, double Height) BoundingRectangle => (0, 0, 0, 0); + public IElementSnapshot? Parent => null; + } + + private sealed class FakeSource : IWindowPointSource + { + public int? Pid { get; set; } + public IElementSnapshot? Primary { get; set; } + public IElementSnapshot? SutScope { get; set; } + public int? GetProcessIdAt(int x, int y) => Pid; + public IElementSnapshot? GetElementAt(int x, int y) => Primary; + public IElementSnapshot? GetElementFromSutScope(int x, int y) => SutScope; + } + + [Fact] + public void Resolve_SamePid_ReturnsPrimary() + { + var primary = new FakeSnapshot { Tag = "primary" }; + var sut = new FakeSnapshot { Tag = "sut" }; + var src = new FakeSource { Pid = 100, Primary = primary, SutScope = sut }; + var r = WindowPointResolver.Resolve(src, 10, 20, sutPid: 100); + Assert.Same(primary, r); + } + + [Fact] + public void Resolve_DifferentPid_FallsBackToSutScope() + { + var primary = new FakeSnapshot { Tag = "primary" }; + var sut = new FakeSnapshot { Tag = "sut" }; + var src = new FakeSource { Pid = 999, Primary = primary, SutScope = sut }; + var r = WindowPointResolver.Resolve(src, 10, 20, sutPid: 100); + Assert.Same(sut, r); + } + + [Fact] + public void Resolve_UnknownPid_ReturnsPrimary() + { + var primary = new FakeSnapshot { Tag = "primary" }; + var src = new FakeSource { Pid = null, Primary = primary, SutScope = null }; + var r = WindowPointResolver.Resolve(src, 10, 20, sutPid: 100); + Assert.Same(primary, r); + } + + [Fact] + public void Resolve_ZeroPid_ReturnsPrimary() + { + var primary = new FakeSnapshot { Tag = "primary" }; + var src = new FakeSource { Pid = 0, Primary = primary, SutScope = null }; + var r = WindowPointResolver.Resolve(src, 10, 20, sutPid: 100); + Assert.Same(primary, r); + } + + [Fact] + public void Resolve_DifferentPid_FallbackNull_ReturnsPrimary() + { + // Documented semantic: if SUT-scope fallback can't find anything, + // keep the primary as a last resort rather than dropping the event. + var primary = new FakeSnapshot { Tag = "primary" }; + var src = new FakeSource { Pid = 999, Primary = primary, SutScope = null }; + var r = WindowPointResolver.Resolve(src, 10, 20, sutPid: 100); + Assert.Same(primary, r); + } +}