feat: camera-restore + LauncherUI UX 개선 + player fallback 강화 (#15)

camera-restore:
- IEngineStateProvider.SetCamera 반사 쓰기 (HmegDirectStateProvider)
- POST /camera/restore (BridgeHttpServer, StateRouter)
- Recorder --sidecar-url + camera_snapshot 캡처
- UiaPlayerHost.TryRestoreCamera, PlayerEngine 재생 전 복원
- 149 tests

LauncherUI (#15):
- Sidecar URL 체크박스 + 입력란 (녹화/재생 모두 연동)
- 재생 속도 슬라이더 (0.25x~4.0x, 기본 1.0x)
- 빌드 타임스탬프 타이틀바 표시
- 녹화 완료 후 RecordNameBox 초기화
- UiAnalysisWindow 추가

PlayerEngine (#15):
- CancellationToken 지원 (중단 버튼 동작)
- Focus 스텝 early return (no-op, issue #11)
- Type/Drag unresolvable UIA path fallback
- SpeedMultiplier 옵션

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
minsung
2026-04-13 18:37:13 +09:00
parent 6bc71afd32
commit 11eb92b2b2
32 changed files with 1658 additions and 46 deletions

View File

@@ -273,6 +273,107 @@ steps:
Assert.Equal("ctrl+c", host.Hotkeys[0]);
}
// ---- camera restore -------------------------------------------------------
[Fact]
public void CameraRestore_NoCameraSnapshot_HostNotCalled()
{
var engine = new PlayerEngine(new PlayerEngineOptions { PreserveTiming = false });
var host = new FakePlayerHost();
var scenario = new Scenario
{
Steps = { new Step { Kind = StepKind.Type, Value = "x" } },
CameraSnapshot = null,
};
engine.Run(scenario, host);
Assert.Empty(host.CameraRestoreCalls);
}
[Fact]
public void CameraRestore_HasCameraSnapshot_HostCalledWithCorrectValues()
{
var engine = new PlayerEngine(new PlayerEngineOptions { PreserveTiming = false });
var host = new FakePlayerHost { CameraRestoreResult = true };
var scenario = new Scenario
{
Steps = { new Step { Kind = StepKind.Type, Value = "x" } },
CameraSnapshot = new ScenarioCameraSnapshot
{
Eye = new[] { 1.0, 2.0, 3.0 },
Target = new[] { 4.0, 5.0, 6.0 },
Up = new[] { 0.0, 1.0, 0.0 },
Fov = 60.0,
},
};
engine.Run(scenario, host);
Assert.Single(host.CameraRestoreCalls);
var call = host.CameraRestoreCalls[0];
Assert.Equal(new[] { 1.0, 2.0, 3.0 }, call.Eye);
Assert.Equal(new[] { 4.0, 5.0, 6.0 }, call.Target);
Assert.Equal(new[] { 0.0, 1.0, 0.0 }, call.Up);
Assert.Equal(60.0, call.Fov);
}
[Fact]
public void CameraRestore_HostReturnsFalse_PlaybackContinues()
{
var engine = new PlayerEngine(new PlayerEngineOptions { PreserveTiming = false });
var host = new FakePlayerHost { CameraRestoreResult = false };
var scenario = new Scenario
{
Steps =
{
new Step { Kind = StepKind.Type, Value = "BOX" },
new Step { Kind = StepKind.Hotkey, Value = "enter" },
},
CameraSnapshot = new ScenarioCameraSnapshot
{
Eye = new[] { 0.0, 0.0, 10.0 },
Target = new double[3],
Up = new[] { 0.0, 1.0, 0.0 },
Fov = 45.0,
},
};
engine.Run(scenario, host);
// Even though restore returned false, all steps should still run
Assert.Single(host.Types);
Assert.Equal("BOX", host.Types[0]);
Assert.Single(host.Hotkeys);
Assert.Empty(host.Failures);
}
[Fact]
public void ScenarioLoader_ParsesCameraSnapshot()
{
const string yaml = """
name: with-camera
camera_snapshot:
eye: [1.0, 2.0, 3.0]
target: [4.0, 5.0, 6.0]
up: [0.0, 1.0, 0.0]
fov: 60.0
steps: []
""";
var s = ScenarioLoader.LoadFromString(yaml);
Assert.NotNull(s.CameraSnapshot);
Assert.Equal(new[] { 1.0, 2.0, 3.0 }, s.CameraSnapshot!.Eye);
Assert.Equal(60.0, s.CameraSnapshot.Fov);
}
[Fact]
public void ScenarioLoader_NoCameraSnapshot_ReturnsNull()
{
const string yaml = "name: no-camera\nsteps: []\n";
var s = ScenarioLoader.LoadFromString(yaml);
Assert.Null(s.CameraSnapshot);
}
private static string LocateEngineSource([CallerFilePath] string here = "")
{
// here = .../tests/Recordingtest.Player.Tests/PlayerEngineTests.cs