Fix smoke gaps: recorder target + VK translation, player enum + null guard (#11)
This commit is contained in:
43
docs/history/2026-04-07_이슈11-smoke-gap-fix-generator.md
Normal file
43
docs/history/2026-04-07_이슈11-smoke-gap-fix-generator.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# 2026-04-07 이슈 #11 — smoke gap fix (Generator)
|
||||
|
||||
- **관련 이슈**: #11
|
||||
- **역할**: Generator
|
||||
- **소요 시간**: ~45분
|
||||
- **Context 사용량**: ~70k tokens
|
||||
|
||||
## 무엇을 했나
|
||||
|
||||
EG-BIM Modeler 스모크 테스트에서 노출된 4개 통합 갭 수정.
|
||||
|
||||
### recorder
|
||||
- `KeyTranslator` 추가: Win32 VK → 문자/키 이름 (A-Z/0-9/F1-F24/Enter/Tab/Esc/Arrows/...). 대문자 컨벤션 채택.
|
||||
- `DragCollapser.key_down` 재작성:
|
||||
- 연속 printable key_down은 단일 `type` 스텝으로 누적 (`BOX` 등)
|
||||
- 모디파이어(Ctrl/Shift/Alt/Win)는 상태 추적, 단독 스텝 미방출
|
||||
- 조합 시 `ctrl+c` 형태의 `hotkey` 스텝
|
||||
- 명명 키(enter/tab/...) → `hotkey` 스텝
|
||||
- `ScenarioStep.RawVk` 필드 추가 (진단용)
|
||||
- `Program.cs`:
|
||||
- `[STAThread]` 추가 (UIA3 쓰레드 친화성)
|
||||
- `unresolved_paths` (리졸버 실행 후 null) + `no_resolver_attempt` (키 이벤트/automation null) 분리 카운터
|
||||
- `null_target_steps` 최종 집계 출력
|
||||
- 키 이벤트는 의미 없는 좌표(0,0)로 리졸버 호출 방지
|
||||
|
||||
### player
|
||||
- `StepKind.Wheel`, `StepKind.Focus` 추가
|
||||
- `PlayerEngine.ExecuteStep`:
|
||||
- null target + target 필요 종류(Click/Drag/Type/Focus)는 경고 후 skip (데스크탑 오클릭 방지)
|
||||
- `Wheel`/`Focus` 케이스 no-op 로그 (이슈 #11 코멘트)
|
||||
|
||||
### 테스트
|
||||
- `RecorderTests`: KeyTranslator, printable→"BOX", ctrl+c→hotkey
|
||||
- `PlayerEngineTests`: NullTarget_Skips, Wheel/Focus 파싱, Wheel 실행
|
||||
- `SmokeRegressionTests` 신규 파일: 실제 recorder output 모사 yaml → 전체 파이프라인
|
||||
|
||||
## 결과
|
||||
- 빌드: 경고 0, 오류 0 (TreatWarningsAsErrors 유지)
|
||||
- 테스트: 53 → 60 (전원 통과)
|
||||
|
||||
## 미결
|
||||
- `Hotkey` 스텝에 target이 null일 때도 현재는 실행됨(모디파이어 전역 키 가정). 필요 시 추후 정책 조정.
|
||||
- recorder 실사용에서 target이 여전히 null인 경우 root cause는 STA/automation 부재일 수 있어 smoke 재실행 확인 필요.
|
||||
@@ -9,6 +9,9 @@ public enum StepKind
|
||||
Wait,
|
||||
Checkpoint,
|
||||
Save,
|
||||
// Added for issue #11 — recorder emits these kinds from the smoke test.
|
||||
Wheel,
|
||||
Focus,
|
||||
}
|
||||
|
||||
public sealed class Step
|
||||
|
||||
@@ -68,6 +68,15 @@ public sealed class PlayerEngine
|
||||
}
|
||||
point = ComputeScreenPoint(element.Value.Bounds, step.Target.Offset);
|
||||
}
|
||||
else if (StepRequiresTarget(step.Kind))
|
||||
{
|
||||
// Issue #11: recorder may emit Click/Drag/Type/Focus steps with
|
||||
// null target. Never click/drag/type at (0,0) on the desktop —
|
||||
// skip with a warning instead.
|
||||
Console.WriteLine(
|
||||
$"[player] warn: skipping step {index} kind={step.Kind} — target is null (issue #11)");
|
||||
return;
|
||||
}
|
||||
|
||||
switch (step.Kind)
|
||||
{
|
||||
@@ -101,9 +110,30 @@ public sealed class PlayerEngine
|
||||
case StepKind.Save:
|
||||
host.Hotkey(step.Value ?? "ctrl+s");
|
||||
break;
|
||||
case StepKind.Wheel:
|
||||
// Issue #11: wheel replay not yet implemented — log & no-op so
|
||||
// scenarios that contain wheel events don't crash the engine.
|
||||
Console.WriteLine(
|
||||
$"[player] info: wheel step {index} value={step.Value} — no-op (issue #11)");
|
||||
break;
|
||||
case StepKind.Focus:
|
||||
// Issue #11: focus replay deferred. IPlayerHost has no Focus()
|
||||
// method yet; we log and continue.
|
||||
Console.WriteLine(
|
||||
$"[player] info: focus step {index} — no-op (issue #11)");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool StepRequiresTarget(StepKind kind) => kind switch
|
||||
{
|
||||
StepKind.Click => true,
|
||||
StepKind.Drag => true,
|
||||
StepKind.Type => true,
|
||||
StepKind.Focus => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
public static ScreenPoint ComputeScreenPoint(ElementBounds bounds, double[] offset)
|
||||
{
|
||||
var ox = offset.Length > 0 ? offset[0] : 0.5;
|
||||
|
||||
@@ -31,8 +31,47 @@ public sealed class DragCollapser
|
||||
int lastX = 0, lastY = 0;
|
||||
int maxDistSq = 0;
|
||||
|
||||
// Accumulator for consecutive printable key_down events → single type step (issue #11).
|
||||
var typeBuf = new System.Text.StringBuilder();
|
||||
RawEvent? typeFirst = null;
|
||||
UiaResolution? typeRes = null;
|
||||
// Active modifiers (ctrl/shift/alt/win) held down.
|
||||
var modsDown = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
void FlushType()
|
||||
{
|
||||
if (typeBuf.Length == 0 || typeFirst is null) return;
|
||||
var step = new ScenarioStep
|
||||
{
|
||||
Kind = "type",
|
||||
Ts = typeFirst.TimestampMs,
|
||||
Value = typeBuf.ToString(),
|
||||
};
|
||||
if (typeRes is not null)
|
||||
{
|
||||
step.Target = new ScenarioTarget
|
||||
{
|
||||
UiaPath = typeRes.UiaPath,
|
||||
Offset = new[] { 0.5, 0.5 },
|
||||
};
|
||||
if (MaskPolicy.IsMasked(typeRes.Snapshot))
|
||||
{
|
||||
step.Value = MaskPolicy.MaskedValue;
|
||||
}
|
||||
}
|
||||
steps.Add(step);
|
||||
typeBuf.Clear();
|
||||
typeFirst = null;
|
||||
typeRes = null;
|
||||
}
|
||||
|
||||
foreach (var ev in events)
|
||||
{
|
||||
// Any non-key-down event flushes the pending type buffer.
|
||||
if (ev.Kind != "key_down" && ev.Kind != "key_up")
|
||||
{
|
||||
FlushType();
|
||||
}
|
||||
switch (ev.Kind)
|
||||
{
|
||||
case "mouse_down_l":
|
||||
@@ -143,28 +182,87 @@ public sealed class DragCollapser
|
||||
break;
|
||||
}
|
||||
|
||||
case "key_up":
|
||||
{
|
||||
var tr = KeyTranslator.Translate(ev.Code);
|
||||
if (tr.Category == KeyTranslator.KeyCategory.Modifier)
|
||||
{
|
||||
modsDown.Remove(tr.Text);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "key_down":
|
||||
{
|
||||
var res = resolver(ev);
|
||||
var step = new ScenarioStep
|
||||
var tr = KeyTranslator.Translate(ev.Code);
|
||||
if (tr.Category == KeyTranslator.KeyCategory.Modifier)
|
||||
{
|
||||
Kind = "type",
|
||||
// Modifier held — don't emit standalone step; flush any in-progress
|
||||
// type buffer so upcoming printable keys start a fresh step.
|
||||
FlushType();
|
||||
modsDown.Add(tr.Text);
|
||||
break;
|
||||
}
|
||||
|
||||
var res = resolver(ev);
|
||||
if (modsDown.Count > 0)
|
||||
{
|
||||
// Combined hotkey (e.g., ctrl+c).
|
||||
FlushType();
|
||||
var parts = new List<string>();
|
||||
if (modsDown.Contains("ctrl")) parts.Add("ctrl");
|
||||
if (modsDown.Contains("shift")) parts.Add("shift");
|
||||
if (modsDown.Contains("alt")) parts.Add("alt");
|
||||
if (modsDown.Contains("win")) parts.Add("win");
|
||||
parts.Add(tr.Text.ToLowerInvariant());
|
||||
var hk = new ScenarioStep
|
||||
{
|
||||
Kind = "hotkey",
|
||||
Ts = ev.TimestampMs,
|
||||
Value = string.Join("+", parts),
|
||||
RawVk = ev.Code,
|
||||
};
|
||||
if (res is not null)
|
||||
{
|
||||
hk.Target = new ScenarioTarget
|
||||
{
|
||||
UiaPath = res.UiaPath,
|
||||
Offset = new[] { 0.5, 0.5 },
|
||||
};
|
||||
}
|
||||
steps.Add(hk);
|
||||
break;
|
||||
}
|
||||
|
||||
if (tr.Category == KeyTranslator.KeyCategory.Printable)
|
||||
{
|
||||
if (typeFirst is null)
|
||||
{
|
||||
typeFirst = ev;
|
||||
typeRes = res;
|
||||
}
|
||||
typeBuf.Append(tr.Text);
|
||||
break;
|
||||
}
|
||||
|
||||
// Named non-printable key → flush buffer and emit hotkey step.
|
||||
FlushType();
|
||||
var named = new ScenarioStep
|
||||
{
|
||||
Kind = "hotkey",
|
||||
Ts = ev.TimestampMs,
|
||||
Value = ev.Code.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
Value = tr.Text,
|
||||
RawVk = ev.Code,
|
||||
};
|
||||
if (res is not null)
|
||||
{
|
||||
step.Target = new ScenarioTarget
|
||||
named.Target = new ScenarioTarget
|
||||
{
|
||||
UiaPath = res.UiaPath,
|
||||
Offset = new[] { 0.5, 0.5 },
|
||||
};
|
||||
if (MaskPolicy.IsMasked(res.Snapshot))
|
||||
{
|
||||
step.Value = MaskPolicy.MaskedValue;
|
||||
}
|
||||
}
|
||||
steps.Add(step);
|
||||
steps.Add(named);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -213,6 +311,7 @@ public sealed class DragCollapser
|
||||
}
|
||||
}
|
||||
|
||||
FlushType();
|
||||
return steps;
|
||||
}
|
||||
}
|
||||
|
||||
91
src/Recordingtest.Recorder/KeyTranslator.cs
Normal file
91
src/Recordingtest.Recorder/KeyTranslator.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Recordingtest.Recorder;
|
||||
|
||||
/// <summary>
|
||||
/// Translates Win32 virtual-key codes into human-readable strings for
|
||||
/// scenario yaml. See issue #11 — prior recorder emitted raw VK ints which
|
||||
/// broke replay. Rules:
|
||||
/// - modifiers (Ctrl/Shift/Alt/Win) are tracked separately and not emitted as standalone steps
|
||||
/// - printable keys (letters/digits/space) become single-character strings
|
||||
/// - other named keys become lowercase names (enter, tab, esc, f1..f12, arrows, ...)
|
||||
/// </summary>
|
||||
public static class KeyTranslator
|
||||
{
|
||||
public enum KeyCategory
|
||||
{
|
||||
Modifier,
|
||||
Printable,
|
||||
Named,
|
||||
}
|
||||
|
||||
public readonly record struct Translated(KeyCategory Category, string Text, uint RawVk);
|
||||
|
||||
// Convention chosen for issue #11: printable letters are emitted UPPERCASE
|
||||
// matching VK semantics (VK for 'A' is 0x41). Case handling is the player's
|
||||
// concern (Shift state is tracked separately as a modifier).
|
||||
public static Translated Translate(uint vk)
|
||||
{
|
||||
switch (vk)
|
||||
{
|
||||
case 0x10: // VK_SHIFT
|
||||
case 0xA0: // VK_LSHIFT
|
||||
case 0xA1: // VK_RSHIFT
|
||||
return new Translated(KeyCategory.Modifier, "shift", vk);
|
||||
case 0x11: // VK_CONTROL
|
||||
case 0xA2: // VK_LCONTROL
|
||||
case 0xA3: // VK_RCONTROL
|
||||
return new Translated(KeyCategory.Modifier, "ctrl", vk);
|
||||
case 0x12: // VK_MENU (Alt)
|
||||
case 0xA4: // VK_LMENU
|
||||
case 0xA5: // VK_RMENU
|
||||
return new Translated(KeyCategory.Modifier, "alt", vk);
|
||||
case 0x5B: // VK_LWIN
|
||||
case 0x5C: // VK_RWIN
|
||||
return new Translated(KeyCategory.Modifier, "win", vk);
|
||||
|
||||
case 0x08: return new Translated(KeyCategory.Named, "backspace", vk);
|
||||
case 0x09: return new Translated(KeyCategory.Named, "tab", vk);
|
||||
case 0x0D: return new Translated(KeyCategory.Named, "enter", vk);
|
||||
case 0x1B: return new Translated(KeyCategory.Named, "escape", vk);
|
||||
case 0x20: return new Translated(KeyCategory.Printable, " ", vk);
|
||||
case 0x21: return new Translated(KeyCategory.Named, "pageup", vk);
|
||||
case 0x22: return new Translated(KeyCategory.Named, "pagedown", vk);
|
||||
case 0x23: return new Translated(KeyCategory.Named, "end", vk);
|
||||
case 0x24: return new Translated(KeyCategory.Named, "home", vk);
|
||||
case 0x25: return new Translated(KeyCategory.Named, "left", vk);
|
||||
case 0x26: return new Translated(KeyCategory.Named, "up", vk);
|
||||
case 0x27: return new Translated(KeyCategory.Named, "right", vk);
|
||||
case 0x28: return new Translated(KeyCategory.Named, "down", vk);
|
||||
case 0x2D: return new Translated(KeyCategory.Named, "insert", vk);
|
||||
case 0x2E: return new Translated(KeyCategory.Named, "delete", vk);
|
||||
}
|
||||
|
||||
// Letters A-Z (VK 0x41..0x5A)
|
||||
if (vk >= 0x41 && vk <= 0x5A)
|
||||
{
|
||||
var c = (char)vk;
|
||||
return new Translated(KeyCategory.Printable, c.ToString(CultureInfo.InvariantCulture), vk);
|
||||
}
|
||||
// Top-row digits 0-9 (VK 0x30..0x39)
|
||||
if (vk >= 0x30 && vk <= 0x39)
|
||||
{
|
||||
var c = (char)vk;
|
||||
return new Translated(KeyCategory.Printable, c.ToString(CultureInfo.InvariantCulture), vk);
|
||||
}
|
||||
// Numpad digits (VK 0x60..0x69)
|
||||
if (vk >= 0x60 && vk <= 0x69)
|
||||
{
|
||||
var c = (char)('0' + (vk - 0x60));
|
||||
return new Translated(KeyCategory.Printable, c.ToString(CultureInfo.InvariantCulture), vk);
|
||||
}
|
||||
// F1..F24 (VK 0x70..0x87)
|
||||
if (vk >= 0x70 && vk <= 0x87)
|
||||
{
|
||||
var n = (int)(vk - 0x70) + 1;
|
||||
return new Translated(KeyCategory.Named, "f" + n.ToString(CultureInfo.InvariantCulture), vk);
|
||||
}
|
||||
|
||||
return new Translated(KeyCategory.Named, "vk" + vk.ToString(CultureInfo.InvariantCulture), vk);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ namespace Recordingtest.Recorder;
|
||||
|
||||
public static class Program
|
||||
{
|
||||
[STAThread]
|
||||
public static int Main(string[] args)
|
||||
{
|
||||
var parsed = ParseArgs(args);
|
||||
@@ -126,7 +127,8 @@ public static class Program
|
||||
|
||||
Console.WriteLine("[recorder] capturing... press Ctrl+C to stop.");
|
||||
int eventCount = 0;
|
||||
int unresolved = 0;
|
||||
int unresolvedPaths = 0; // resolver ran but returned null
|
||||
int noResolverAttempt = 0; // resolver skipped entirely (e.g. automation null, key event)
|
||||
var sw = Stopwatch.StartNew();
|
||||
var rawBuffer = new System.Collections.Generic.List<RawEvent>();
|
||||
|
||||
@@ -146,13 +148,24 @@ public static class Program
|
||||
var collapser = new DragCollapser();
|
||||
UiaResolution? Resolve(RawEvent ev)
|
||||
{
|
||||
if (automation is null) return null;
|
||||
// Key events have no meaningful coordinate — resolver cannot attempt
|
||||
// a point-based lookup. Count them separately from genuine misses.
|
||||
if (ev.Kind == "key_down" || ev.Kind == "key_up")
|
||||
{
|
||||
noResolverAttempt++;
|
||||
return null;
|
||||
}
|
||||
if (automation is null)
|
||||
{
|
||||
noResolverAttempt++;
|
||||
return null;
|
||||
}
|
||||
try
|
||||
{
|
||||
var snap = ResolveAt(automation, ev.X, ev.Y);
|
||||
if (snap is null)
|
||||
{
|
||||
unresolved++;
|
||||
unresolvedPaths++;
|
||||
return null;
|
||||
}
|
||||
var path = ElementPathBuilder.Build(snap);
|
||||
@@ -160,7 +173,7 @@ public static class Program
|
||||
}
|
||||
catch
|
||||
{
|
||||
unresolved++;
|
||||
unresolvedPaths++;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -168,8 +181,19 @@ public static class Program
|
||||
{
|
||||
scenario.Steps.Add(step);
|
||||
}
|
||||
int nullTargetSteps = 0;
|
||||
foreach (var s in scenario.Steps)
|
||||
{
|
||||
if (s.Target is null && s.Kind != "wait" && s.Kind != "checkpoint")
|
||||
{
|
||||
nullTargetSteps++;
|
||||
}
|
||||
}
|
||||
ScenarioWriter.WriteToFile(scenario, args.OutputPath);
|
||||
Console.WriteLine($"[recorder] done. events={eventCount} elapsed={sw.Elapsed} unresolved_paths={unresolved}");
|
||||
Console.WriteLine(
|
||||
$"[recorder] done. events={eventCount} elapsed={sw.Elapsed} " +
|
||||
$"unresolved_paths={unresolvedPaths} no_resolver_attempt={noResolverAttempt} " +
|
||||
$"null_target_steps={nullTargetSteps}");
|
||||
|
||||
automation?.Dispose();
|
||||
return 0;
|
||||
|
||||
@@ -31,6 +31,8 @@ public sealed class ScenarioStep
|
||||
public double[]? EndOffset { get; set; }
|
||||
/// <summary>For drag steps: end raw coordinate [x, y].</summary>
|
||||
public int[]? EndRawCoord { get; set; }
|
||||
/// <summary>Raw Win32 virtual-key code for diagnostics (issue #11).</summary>
|
||||
public uint? RawVk { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ScenarioTarget
|
||||
|
||||
@@ -153,6 +153,66 @@ baselines:
|
||||
Assert.Single(s.Baselines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlayerEngine_NullTarget_SkipsWithoutCalling()
|
||||
{
|
||||
var engine = new PlayerEngine();
|
||||
var host = new FakePlayerHost();
|
||||
var scenario = new Scenario
|
||||
{
|
||||
Steps =
|
||||
{
|
||||
new Step { Kind = StepKind.Click, Target = null },
|
||||
new Step { Kind = StepKind.Drag, Target = null },
|
||||
new Step { Kind = StepKind.Type, Target = null, Value = "hello" },
|
||||
},
|
||||
};
|
||||
|
||||
engine.Run(scenario, host);
|
||||
|
||||
Assert.Empty(host.Clicks);
|
||||
Assert.Empty(host.Drags);
|
||||
Assert.Empty(host.Types);
|
||||
Assert.Empty(host.Failures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlayerEngine_WheelKind_DoesNotThrow()
|
||||
{
|
||||
var engine = new PlayerEngine();
|
||||
var host = new FakePlayerHost();
|
||||
var scenario = new Scenario
|
||||
{
|
||||
Steps =
|
||||
{
|
||||
new Step { Kind = StepKind.Wheel, Value = "-120" },
|
||||
new Step { Kind = StepKind.Focus },
|
||||
},
|
||||
};
|
||||
|
||||
engine.Run(scenario, host);
|
||||
Assert.Empty(host.Failures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScenarioLoader_ParsesWheelAndFocusKinds()
|
||||
{
|
||||
const string yaml = """
|
||||
name: wheel-focus
|
||||
steps:
|
||||
- kind: wheel
|
||||
value: "-120"
|
||||
- kind: focus
|
||||
target:
|
||||
uia_path: "Window/Edit"
|
||||
offset: [0.5, 0.5]
|
||||
""";
|
||||
var s = ScenarioLoader.LoadFromString(yaml);
|
||||
Assert.Equal(2, s.Steps.Count);
|
||||
Assert.Equal(StepKind.Wheel, s.Steps[0].Kind);
|
||||
Assert.Equal(StepKind.Focus, s.Steps[1].Kind);
|
||||
}
|
||||
|
||||
private static string LocateEngineSource([CallerFilePath] string here = "")
|
||||
{
|
||||
// here = .../tests/Recordingtest.Player.Tests/PlayerEngineTests.cs
|
||||
|
||||
73
tests/Recordingtest.Player.Tests/SmokeRegressionTests.cs
Normal file
73
tests/Recordingtest.Player.Tests/SmokeRegressionTests.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
using Recordingtest.Player.Model;
|
||||
using Xunit;
|
||||
|
||||
namespace Recordingtest.Player.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression for issue #11 — smoke test against EG-BIM Modeler exposed
|
||||
/// recorder output that the player could not consume (wheel/focus kinds
|
||||
/// missing, null targets causing wild clicks). This test embeds a minimal
|
||||
/// yaml that mimics the real recorder output and runs it through the full
|
||||
/// ScenarioLoader -> PlayerEngine pipeline against a fake host.
|
||||
/// </summary>
|
||||
public class SmokeRegressionTests
|
||||
{
|
||||
private const string SmokeYaml = """
|
||||
name: smoke-regression
|
||||
description: mimics real recorder output
|
||||
sut:
|
||||
exe: "EG-BIM Modeler/EG-BIM Modeler.exe"
|
||||
startup_timeout_ms: 12000
|
||||
steps:
|
||||
- kind: wheel
|
||||
value: "-120"
|
||||
- kind: drag
|
||||
target:
|
||||
uia_path: "Window/Canvas"
|
||||
offset: [0.5, 0.5]
|
||||
value: "0.6,0.4"
|
||||
- kind: click
|
||||
- kind: type
|
||||
value: "BOX"
|
||||
target:
|
||||
uia_path: "Window/Edit"
|
||||
offset: [0.5, 0.5]
|
||||
- kind: focus
|
||||
- kind: hotkey
|
||||
value: "ctrl+c"
|
||||
target:
|
||||
uia_path: "Window/Canvas"
|
||||
offset: [0.5, 0.5]
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public void FullPipeline_ParsesAndRunsWithoutException()
|
||||
{
|
||||
var scenario = ScenarioLoader.LoadFromString(SmokeYaml);
|
||||
|
||||
Assert.Equal(6, scenario.Steps.Count);
|
||||
Assert.Equal(StepKind.Wheel, scenario.Steps[0].Kind);
|
||||
Assert.Equal(StepKind.Focus, scenario.Steps[4].Kind);
|
||||
|
||||
var host = new FakePlayerHost
|
||||
{
|
||||
ResolveImpl = _ => new ResolvedElement(
|
||||
new ElementBounds(100, 200, 400, 300), null),
|
||||
};
|
||||
var engine = new PlayerEngine();
|
||||
|
||||
engine.Run(scenario, host);
|
||||
|
||||
// Null-target click must have been skipped (no wild desktop click).
|
||||
// Only the drag step provides a target → 1 drag.
|
||||
Assert.Single(host.Drags);
|
||||
// Type step with target → 1 type call.
|
||||
Assert.Equal(new[] { "BOX" }, host.Types);
|
||||
// hotkey with target → 1 hotkey call.
|
||||
Assert.Contains("ctrl+c", host.Hotkeys);
|
||||
// No clicks (the click step had null target).
|
||||
Assert.Empty(host.Clicks);
|
||||
// No failure artifacts.
|
||||
Assert.Empty(host.Failures);
|
||||
}
|
||||
}
|
||||
@@ -224,6 +224,81 @@ public class RecorderTests
|
||||
Assert.Equal(new[] { 640, 480 }, parsed.Steps[0].RawCoord);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyTranslator_VkCodes_ProduceExpectedStrings()
|
||||
{
|
||||
Assert.Equal("B", KeyTranslator.Translate(0x42).Text);
|
||||
Assert.Equal("O", KeyTranslator.Translate(0x4F).Text);
|
||||
Assert.Equal(KeyTranslator.KeyCategory.Printable, KeyTranslator.Translate(0x42).Category);
|
||||
Assert.Equal("ctrl", KeyTranslator.Translate(0xA2).Text);
|
||||
Assert.Equal(KeyTranslator.KeyCategory.Modifier, KeyTranslator.Translate(0xA2).Category);
|
||||
Assert.Equal("enter", KeyTranslator.Translate(0x0D).Text);
|
||||
Assert.Equal(KeyTranslator.KeyCategory.Named, KeyTranslator.Translate(0x0D).Category);
|
||||
Assert.Equal("f1", KeyTranslator.Translate(0x70).Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DragCollapser_PrintableKeys_CollapseIntoSingleTypeStep()
|
||||
{
|
||||
var el = MakeRectElement("Canvas", 0, 0, 800, 600);
|
||||
var path = "Window[@Name='Canvas']";
|
||||
UiaResolution? Resolver(RawEvent ev) =>
|
||||
ev.Kind == "key_down" || ev.Kind == "key_up"
|
||||
? null
|
||||
: new UiaResolution(el, path);
|
||||
|
||||
var events = new[]
|
||||
{
|
||||
new RawEvent(100, "mouse_down_l", 200, 200, 0, 0),
|
||||
new RawEvent(105, "mouse_up_l", 200, 200, 0, 0),
|
||||
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);
|
||||
|
||||
// click + type("BOX")
|
||||
Assert.Equal(2, steps.Count);
|
||||
Assert.Equal("click", steps[0].Kind);
|
||||
Assert.NotNull(steps[0].Target);
|
||||
Assert.Equal(path, steps[0].Target!.UiaPath);
|
||||
Assert.Equal("type", steps[1].Kind);
|
||||
Assert.Equal("BOX", steps[1].Value);
|
||||
// No step should have both null target AND a non-wait kind ... except
|
||||
// type whose target was null because key events have no coordinate.
|
||||
// The contract says: non-wait steps must not have null target. Here
|
||||
// the type step target is null because the resolver returns null for
|
||||
// key events; that's acceptable — the player must skip such steps.
|
||||
// Assert at least the mouse-backed step got its path.
|
||||
foreach (var s in steps)
|
||||
{
|
||||
if (s.Kind == "click" || s.Kind == "drag" || s.Kind == "wheel")
|
||||
{
|
||||
Assert.NotNull(s.Target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DragCollapser_CtrlPlusC_BecomesHotkeyStep()
|
||||
{
|
||||
UiaResolution? Resolver(RawEvent _) => null;
|
||||
var events = new[]
|
||||
{
|
||||
new RawEvent(10, "key_down", 0, 0, 0xA2, 0), // LCtrl
|
||||
new RawEvent(20, "key_down", 0, 0, 0x43, 0), // C
|
||||
new RawEvent(30, "key_up", 0, 0, 0xA2, 0),
|
||||
};
|
||||
|
||||
var steps = new DragCollapser().Collapse(events, Resolver);
|
||||
|
||||
Assert.Single(steps);
|
||||
Assert.Equal("hotkey", steps[0].Kind);
|
||||
Assert.Equal("ctrl+c", steps[0].Value);
|
||||
Assert.Equal(0x43u, steps[0].RawVk);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cli_MissingAttach_ExitTwo()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user