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:
@@ -37,4 +37,14 @@ internal sealed class FakePlayerHost : IPlayerHost
|
||||
Failures.Add((stepIndex, reason));
|
||||
public List<TimeSpan> Delays { get; } = new();
|
||||
public void Delay(TimeSpan duration) => Delays.Add(duration);
|
||||
|
||||
// Camera restore tracking
|
||||
public record CameraRestoreCall(double[] Eye, double[] Target, double[] Up, double Fov);
|
||||
public List<CameraRestoreCall> CameraRestoreCalls { get; } = new();
|
||||
public bool CameraRestoreResult { get; set; } = true;
|
||||
public bool TryRestoreCamera(double[] eye, double[] target, double[] up, double fov)
|
||||
{
|
||||
CameraRestoreCalls.Add(new CameraRestoreCall(eye, target, up, fov));
|
||||
return CameraRestoreResult;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ public class ChainedEngineStateProviderTests
|
||||
public CameraSnapshot GetCamera() => Camera;
|
||||
public SceneSnapshot GetScene() => Scene;
|
||||
public bool GetRenderComplete() => Render;
|
||||
public void SetCamera(CameraSnapshot snapshot) { /* tracked if needed */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -13,6 +13,7 @@ public class StateRouterTests
|
||||
public CameraSnapshot GetCamera() => new(new double[] { 1, 2, 3 }, new double[] { 0, 0, 0 }, new double[] { 0, 0, 1 }, 45);
|
||||
public SceneSnapshot GetScene() => new(7, "doc.hmeg");
|
||||
public bool GetRenderComplete() => true;
|
||||
public void SetCamera(CameraSnapshot snapshot) { /* no-op in tests */ }
|
||||
}
|
||||
|
||||
private sealed class FaultyProvider : IEngineStateProvider
|
||||
@@ -21,6 +22,7 @@ public class StateRouterTests
|
||||
public CameraSnapshot GetCamera() => throw new InvalidOperationException();
|
||||
public SceneSnapshot GetScene() => throw new InvalidOperationException();
|
||||
public bool GetRenderComplete() => throw new InvalidOperationException();
|
||||
public void SetCamera(CameraSnapshot snapshot) => throw new InvalidOperationException();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user