box-v7 패턴: alt+tab → click(PowerShell) → ctrl+c ctrl+c 가 재생 시 SUT 외부 창(브라우저 등)에 입력을 보내는 버그. - PlayerEngine.Run: trailing (alt+tab → optional click → ctrl+c+) 감지 시 제거 - leading alt+tab 제거와 대칭적으로 동작 - ctrl+c 단독으로는 제거하지 않음 (SUT 내 복사 액션과 구분) - 테스트 2건 추가 (138 total) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
284 lines
9.0 KiB
C#
284 lines
9.0 KiB
C#
using System.Runtime.CompilerServices;
|
|
using System.Text.RegularExpressions;
|
|
using Recordingtest.Player.Model;
|
|
using Xunit;
|
|
|
|
namespace Recordingtest.Player.Tests;
|
|
|
|
public class PlayerEngineTests
|
|
{
|
|
[Fact]
|
|
public void Player_EmptyScenario_ExitsZero()
|
|
{
|
|
var engine = new PlayerEngine();
|
|
var host = new FakePlayerHost();
|
|
var scenario = new Scenario { Name = "empty" };
|
|
|
|
engine.Run(scenario, host);
|
|
|
|
Assert.Empty(host.Clicks);
|
|
Assert.Empty(host.Failures);
|
|
}
|
|
|
|
[Fact]
|
|
public void Player_ClickStep_InvokesHostClickAtExpectedScreenPoint()
|
|
{
|
|
var engine = new PlayerEngine();
|
|
var host = new FakePlayerHost
|
|
{
|
|
ResolveImpl = _ => new ResolvedElement(
|
|
new ElementBounds(100, 200, 50, 40), null),
|
|
};
|
|
var scenario = new Scenario
|
|
{
|
|
Steps =
|
|
{
|
|
new Step
|
|
{
|
|
Kind = StepKind.Click,
|
|
Target = new Target
|
|
{
|
|
UiaPath = "Window/Button[@AutomationId='ok']",
|
|
Offset = new[] { 0.5, 0.25 },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
engine.Run(scenario, host);
|
|
|
|
// 100 + 50*0.5 = 125 ; 200 + 40*0.25 = 210
|
|
Assert.Single(host.Clicks);
|
|
Assert.Equal(new ScreenPoint(125, 210), host.Clicks[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Player_ResolveFailure_CapturesArtifacts()
|
|
{
|
|
var engine = new PlayerEngine();
|
|
var host = new FakePlayerHost { ResolveImpl = _ => null };
|
|
var scenario = new Scenario
|
|
{
|
|
Steps =
|
|
{
|
|
new Step
|
|
{
|
|
Kind = StepKind.Click,
|
|
Target = new Target { UiaPath = "Bogus" },
|
|
},
|
|
},
|
|
};
|
|
|
|
var ex = Assert.Throws<InvalidOperationException>(
|
|
() => engine.Run(scenario, host));
|
|
|
|
Assert.Single(host.Failures);
|
|
Assert.Equal(0, host.Failures[0].StepIndex);
|
|
Assert.False(string.IsNullOrEmpty(host.Failures[0].Reason));
|
|
Assert.Contains("Bogus", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void Player_CheckpointStep_InvokesCapture()
|
|
{
|
|
var engine = new PlayerEngine();
|
|
var host = new FakePlayerHost();
|
|
var scenario = new Scenario
|
|
{
|
|
Steps =
|
|
{
|
|
new Step
|
|
{
|
|
Kind = StepKind.Checkpoint,
|
|
AfterStep = 3,
|
|
SaveAs = "out/cp.hmeg",
|
|
},
|
|
},
|
|
};
|
|
|
|
engine.Run(scenario, host);
|
|
|
|
Assert.Single(host.Checkpoints);
|
|
Assert.Equal(3, host.Checkpoints[0].AfterStep);
|
|
Assert.Equal("out/cp.hmeg", host.Checkpoints[0].SaveAs);
|
|
}
|
|
|
|
[Fact]
|
|
public void Player_NoFixedSleep()
|
|
{
|
|
var path = LocateEngineSource();
|
|
var src = File.ReadAllText(path);
|
|
Assert.DoesNotMatch(new Regex(@"Thread\.Sleep\("), src);
|
|
Assert.DoesNotMatch(new Regex(@"Task\.Delay\(TimeSpan\.FromSeconds"), src);
|
|
}
|
|
|
|
[Fact]
|
|
public void Player_ScenarioLoader_ParsesSampleYaml()
|
|
{
|
|
const string yaml = """
|
|
name: sample
|
|
description: tiny
|
|
sut:
|
|
exe: "EG-BIM Modeler/EG-BIM Modeler.exe"
|
|
startup_timeout_ms: 12000
|
|
steps:
|
|
- kind: click
|
|
target:
|
|
uia_path: "Window/Button[@AutomationId='ok']"
|
|
offset: [0.5, 0.5]
|
|
- kind: type
|
|
value: "hello"
|
|
- kind: checkpoint
|
|
after_step: 1
|
|
save_as: "out/cp1.hmeg"
|
|
checkpoints:
|
|
- after_step: 1
|
|
save_as: "out/cp1.hmeg"
|
|
baselines:
|
|
- path: "baselines/cp1.approved.hmeg"
|
|
""";
|
|
var s = ScenarioLoader.LoadFromString(yaml);
|
|
|
|
Assert.Equal("sample", s.Name);
|
|
Assert.Equal(12000, s.Sut.StartupTimeoutMs);
|
|
Assert.Equal(3, s.Steps.Count);
|
|
Assert.Equal(StepKind.Click, s.Steps[0].Kind);
|
|
Assert.Equal("Window/Button[@AutomationId='ok']", s.Steps[0].Target!.UiaPath);
|
|
Assert.Equal(0.5, s.Steps[0].Target!.Offset[0]);
|
|
Assert.Equal(StepKind.Type, s.Steps[1].Kind);
|
|
Assert.Equal("hello", s.Steps[1].Value);
|
|
Assert.Equal(StepKind.Checkpoint, s.Steps[2].Kind);
|
|
Assert.Equal(1, s.Steps[2].AfterStep);
|
|
Assert.Single(s.Checkpoints);
|
|
Assert.Single(s.Baselines);
|
|
}
|
|
|
|
[Fact]
|
|
public void PlayerEngine_NullTarget_Fallback_Issue14()
|
|
{
|
|
// Issue #14: null-target fallbacks.
|
|
// - Type → send keystrokes to current focus (no target required)
|
|
// - Click w/ raw_coord → click at raw_coord screen-absolute
|
|
// - Click w/o raw_coord → still skipped (no way to click safely)
|
|
// - Drag → still skipped (no raw_coord pair handling yet)
|
|
var engine = new PlayerEngine();
|
|
var host = new FakePlayerHost();
|
|
var scenario = new Scenario
|
|
{
|
|
Steps =
|
|
{
|
|
new Step { Kind = StepKind.Click, Target = null }, // skip
|
|
new Step { Kind = StepKind.Click, Target = null, RawCoord = new[] { 123, 456 } },
|
|
new Step { Kind = StepKind.Drag, Target = null }, // skip
|
|
new Step { Kind = StepKind.Type, Target = null, Value = "hello" },
|
|
},
|
|
};
|
|
|
|
engine.Run(scenario, host);
|
|
|
|
Assert.Single(host.Clicks);
|
|
Assert.Equal(new ScreenPoint(123, 456), host.Clicks[0]);
|
|
Assert.Empty(host.Drags);
|
|
Assert.Single(host.Types);
|
|
Assert.Equal("hello", host.Types[0]);
|
|
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);
|
|
}
|
|
|
|
// ---- trailing recorder-stop noise stripping --------------------------------
|
|
|
|
[Fact]
|
|
public void TrailingRecorderStop_AltTabClickCtrlC_Stripped()
|
|
{
|
|
// box-v7 pattern: real work → alt+tab → click → ctrl+c ctrl+c
|
|
var host = new FakePlayerHost();
|
|
var engine = new PlayerEngine(new PlayerEngineOptions { PreserveTiming = false });
|
|
var scenario = new Scenario
|
|
{
|
|
Steps =
|
|
{
|
|
new Step { Kind = StepKind.Type, Value = "BOX" }, // real work
|
|
new Step { Kind = StepKind.Hotkey, Value = "enter" }, // real work
|
|
new Step { Kind = StepKind.Hotkey, Value = "alt+tab" }, // recorder stop →
|
|
new Step { Kind = StepKind.Click, RawCoord = [771, 833] }, // click terminal
|
|
new Step { Kind = StepKind.Hotkey, Value = "ctrl+c" }, // stop recorder
|
|
new Step { Kind = StepKind.Hotkey, Value = "ctrl+c" }, // stop recorder
|
|
},
|
|
};
|
|
|
|
engine.Run(scenario, host);
|
|
|
|
// Only the two real-work steps should have fired — no Clicks, hotkey only "enter"
|
|
Assert.Empty(host.Clicks); // click (771,833) stripped
|
|
Assert.Single(host.Types); // only "BOX"
|
|
Assert.Equal("BOX", host.Types[0]);
|
|
Assert.Single(host.Hotkeys); // only "enter", not ctrl+c
|
|
Assert.Equal("enter", host.Hotkeys[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public void TrailingCtrlCAlone_NotStripped_RequiresAltTab()
|
|
{
|
|
// ctrl+c without preceding alt+tab is a legitimate SUT action (copy)
|
|
var host = new FakePlayerHost();
|
|
var engine = new PlayerEngine(new PlayerEngineOptions { PreserveTiming = false });
|
|
var scenario = new Scenario
|
|
{
|
|
Steps =
|
|
{
|
|
new Step { Kind = StepKind.Type, Value = "BOX" },
|
|
new Step { Kind = StepKind.Hotkey, Value = "ctrl+c" },
|
|
},
|
|
};
|
|
|
|
engine.Run(scenario, host);
|
|
// ctrl+c alone at the end must NOT be stripped (it's a copy action)
|
|
Assert.Single(host.Hotkeys);
|
|
Assert.Equal("ctrl+c", host.Hotkeys[0]);
|
|
}
|
|
|
|
private static string LocateEngineSource([CallerFilePath] string here = "")
|
|
{
|
|
// here = .../tests/Recordingtest.Player.Tests/PlayerEngineTests.cs
|
|
var dir = Path.GetDirectoryName(here)!;
|
|
var repo = Path.GetFullPath(Path.Combine(dir, "..", ".."));
|
|
return Path.Combine(repo, "src", "Recordingtest.Player", "PlayerEngine.cs");
|
|
}
|
|
}
|