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

@@ -446,4 +446,126 @@ public class RecorderTests
Console.SetError(stderr);
}
}
// ── BuildAnchored tests ───────────────────────────────────────────────────
[Fact]
public void BuildAnchored_ElementHasAutomationId_PathToSelf_OffsetRelativeToSelf()
{
// Element has AutomationId → anchor is the element itself.
var btn = new FakeElement
{
ClassName = "Button",
AutomationId = "BoxCmd",
BoundingRectangle = (100, 200, 200, 50),
};
var (path, ox, oy) = ElementPathBuilder.BuildAnchored(btn, 150, 220);
Assert.Equal("Button[@AutomationId='BoxCmd']", path);
// (150-100)/200 = 0.25, (220-200)/50 = 0.4
Assert.Equal(0.25, ox, 6);
Assert.Equal(0.40, oy, 6);
}
[Fact]
public void BuildAnchored_CanvasInsideViewport_AnchorToViewport()
{
// Canvas (generic) inside HmEGViewport (distinctive custom class, no AutomationId)
// inside Window (has AutomationId).
// Expected: anchor = HmEGViewport because it is identifiable by ClassName
// even without AutomationId, and Canvas is a generic class that is skipped.
var window = new FakeElement
{
ClassName = "Window",
AutomationId = "MainWnd",
BoundingRectangle = (0, 0, 1920, 1080),
};
var viewport = new FakeElement
{
ClassName = "HmEGViewport",
BoundingRectangle = (0, 40, 1920, 1040),
Parent = window,
};
var canvas = new FakeElement
{
ClassName = "Canvas",
BoundingRectangle = (0, 40, 1920, 1040),
Parent = viewport,
};
// Click at (960, 560)
var (path, ox, oy) = ElementPathBuilder.BuildAnchored(canvas, 960, 560);
// Anchor is HmEGViewport (distinctive ClassName, skips Canvas which is generic).
// Full path includes Window ancestor for resolution.
Assert.Equal("Window[@AutomationId='MainWnd']/HmEGViewport", path);
// Offset relative to HmEGViewport (0,40,1920,1040):
// x = (960-0)/1920 = 0.5, y = (560-40)/1040 = 0.5
Assert.Equal(0.5, ox, 6);
Assert.Equal(0.5, oy, 6);
}
[Fact]
public void BuildAnchored_NoAncestorHasAutomationId_FallsBackToFullPath()
{
// Orphan element with no AutomationId anywhere in chain.
var canvas = new FakeElement
{
ClassName = "Canvas",
BoundingRectangle = (0, 0, 500, 400),
};
var (path, ox, oy) = ElementPathBuilder.BuildAnchored(canvas, 250, 200);
Assert.Equal("Canvas", path);
Assert.Equal(0.5, ox, 6);
Assert.Equal(0.5, oy, 6);
}
// ── Camera snapshot ───────────────────────────────────────────────────────
[Fact]
public void TryCaptureCamera_UnreachableSidecar_ReturnsNull()
{
// Port 19999 is almost certainly not listening.
var result = Program.TryCaptureCamera("http://localhost:19999");
Assert.Null(result);
}
[Fact]
public void ScenarioWriter_WithCameraSnapshot_RoundTripsCorrectly()
{
var s = new Scenario
{
Name = "cam-test",
CameraSnapshot = new RecordedCameraSnapshot
{
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,
},
};
var yaml = ScenarioWriter.Serialize(s);
Assert.Contains("camera_snapshot:", yaml);
Assert.Contains("eye:", yaml);
Assert.Contains("fov:", yaml);
var parsed = ScenarioWriter.Deserialize(yaml);
Assert.NotNull(parsed.CameraSnapshot);
Assert.Equal(new[] { 1.0, 2.0, 3.0 }, parsed.CameraSnapshot!.Eye);
Assert.Equal(60.0, parsed.CameraSnapshot.Fov);
}
[Fact]
public void ScenarioWriter_NullCameraSnapshot_DoesNotEmitField()
{
var s = new Scenario { Name = "no-cam", CameraSnapshot = null };
var yaml = ScenarioWriter.Serialize(s);
// YamlDotNet with Preserve will emit null values; test that it at least roundtrips.
var parsed = ScenarioWriter.Deserialize(yaml);
Assert.Null(parsed.CameraSnapshot);
}
}