using System.Numerics; using AcDream.App.Input; using AcDream.App.Rendering; using AcDream.UI.Abstractions.Input; using Silk.NET.Input; namespace AcDream.App.Tests.Input; public sealed class CameraPointerInputControllerTests { [Theory] [InlineData(0)] [InlineData(1)] [InlineData(2)] public void AttachFailureRollsBackRawPrefixAndLeavesCameraUnsubscribed(int failureIndex) { var surfaces = new[] { new RawSurface(), new RawSurface(), new RawSurface() }; surfaces[failureIndex].FailAdd = true; CameraPointerInputController owner = Create(surfaces).Owner; Assert.ThrowsAny(owner.AttachRaw); Assert.True(owner.IsDisposalComplete); Assert.All(surfaces, surface => Assert.Equal(0, surface.LiveEdges)); Assert.Equal(failureIndex + 1, surfaces.Sum(surface => surface.AddCalls)); Assert.Equal(failureIndex + 1, surfaces.Sum(surface => surface.RemoveCalls)); } [Fact] public void FlyPointerUsesAbsoluteDeltaAndCaptureAdvancesPointerWithoutLooking() { var surface = new RawSurface(); var fixture = Create([surface]); fixture.Camera.ToggleFly(); fixture.Owner.AttachRaw(); float originalYaw = fixture.Camera.Fly.Yaw; float originalPitch = fixture.Camera.Fly.Pitch; surface.Raise(new Vector2(10f, 5f)); Assert.Equal(originalYaw - 10f * 0.003f, fixture.Camera.Fly.Yaw, 5); Assert.Equal(originalPitch - 5f * 0.003f, fixture.Camera.Fly.Pitch, 5); fixture.Capture.Mouse = true; surface.Raise(new Vector2(100f, 100f)); float capturedYaw = fixture.Camera.Fly.Yaw; float capturedPitch = fixture.Camera.Fly.Pitch; fixture.Capture.Mouse = false; surface.Raise(new Vector2(102f, 97f)); Assert.Equal(capturedYaw - 2f * 0.003f, fixture.Camera.Fly.Yaw, 5); Assert.Equal(capturedPitch + 3f * 0.003f, fixture.Camera.Fly.Pitch, 5); } [Fact] public void RawMoveRaisedReentrantlyDuringAttachCannotEnterPartialOwner() { var surface = new RawSurface { RaiseDuringAdd = true }; var fixture = Create([surface]); fixture.Camera.ToggleFly(); float yaw = fixture.Camera.Fly.Yaw; fixture.Owner.AttachRaw(); Assert.Equal(yaw, fixture.Camera.Fly.Yaw); surface.Raise(new Vector2(3, 0)); Assert.NotEqual(yaw, fixture.Camera.Fly.Yaw); } [Fact] public void LateGameplayBindDoesNotResubscribeAndFocusLossEndsMouseLook() { var surface = new RawSurface(); var fixture = Create([surface]); fixture.Owner.AttachRaw(); int adds = surface.AddCalls; var mouseLook = new MouseLook(active: true); var frame = new GameplayInputFrameController( dispatcher: null, new DispatcherMovementInputSource(), mouseLook, new Combat()); fixture.Owner.BindGameplayFrame(frame); fixture.Owner.HandleFocusChanged(focused: false); Assert.Equal(adds, surface.AddCalls); Assert.Equal(1, mouseLook.LifecycleEndCalls); } [Fact] public void DeactivateSilencesCopiedRawAndCameraDelegatesBeforeDetach() { var surface = new RawSurface(); var fixture = Create([surface]); fixture.Camera.ToggleFly(); fixture.Owner.AttachRaw(); Action copied = surface.Callback!; float yaw = fixture.Camera.Fly.Yaw; fixture.Owner.Deactivate(); copied(new Vector2(100, 0)); fixture.Camera.ToggleFly(); fixture.Owner.Dispose(); Assert.Equal(yaw, fixture.Camera.Fly.Yaw); Assert.True(fixture.Owner.IsDisposalComplete); } [Fact] public void ProcessCloseCutoffDoesNotEndMouseLookUntilSessionRetirement() { var fixture = Create([new RawSurface()]); var mouseLook = new MouseLook(active: true); var frame = new GameplayInputFrameController( dispatcher: null, new DispatcherMovementInputSource(), mouseLook, new Combat()); fixture.Owner.BindGameplayFrame(frame); fixture.Owner.AttachRaw(); fixture.Owner.Deactivate(); fixture.Owner.Dispose(); Assert.Equal(0, mouseLook.LifecycleEndCalls); fixture.Owner.ReleaseMouseLookAfterSessionRetirement(); Assert.Equal(1, mouseLook.LifecycleEndCalls); } [Fact] public void FailedRawDetachRetriesOnlyPendingEdge() { var surface = new RawSurface(); var fixture = Create([surface]); fixture.Owner.AttachRaw(); surface.FailRemoveOnce = true; fixture.Owner.Dispose(); int removes = surface.RemoveCalls; fixture.Owner.Dispose(); Assert.Equal(2, surface.RemoveCalls); Assert.Equal(removes, surface.RemoveCalls); Assert.True(fixture.Owner.IsDisposalComplete); } [Fact] public void DisposeBeforeAttachMakesPointerOwnerTerminal() { var surface = new RawSurface(); var fixture = Create([surface]); fixture.Owner.Dispose(); Assert.Throws(fixture.Owner.AttachRaw); fixture.Owner.Dispose(); Assert.Equal(0, surface.LiveEdges); } [Fact] public void ScrollAndSensitivityPreserveModeSpecificPolicy() { var fixture = Create([new RawSurface()]); float orbitDistance = fixture.Camera.Orbit.Distance; fixture.Owner.HandleScroll(InputAction.ScrollUp); string orbit = fixture.Owner.AdjustSensitivity(1.2f); fixture.Camera.ToggleFly(); float flyBefore = fixture.Owner.ActiveSensitivity; string fly = fixture.Owner.AdjustSensitivity(1.2f); Assert.Equal(Math.Clamp(orbitDistance - 20f, 50f, 2000f), fixture.Camera.Orbit.Distance); Assert.Equal("Orbit sens 1.200x", orbit); Assert.Equal("Fly sens 1.200x", fly); Assert.Equal(flyBefore * 1.2f, fixture.Owner.ActiveSensitivity, 5); } private static Fixture Create(IReadOnlyList surfaces) { var camera = new CameraController(new OrbitCamera(), new FlyCamera()); var capture = new Capture(); var mouse = new Mouse(); var cursor = new Cursor(); var owner = new CameraPointerInputController( surfaces, cursor, new HostQuiescenceGate(), capture, new LocalPlayerModeState(), camera, new ChaseCameraInputState(), mouse, new PointerPositionState(), new Clock()); return new Fixture(owner, camera, capture, cursor); } private sealed record Fixture( CameraPointerInputController Owner, CameraController Camera, Capture Capture, Cursor Cursor); private sealed class RawSurface : IRawPointerSurface { public bool FailAdd { get; set; } public bool FailRemoveOnce { get; set; } public bool RaiseDuringAdd { get; set; } public int AddCalls { get; private set; } public int RemoveCalls { get; private set; } public int LiveEdges { get; private set; } public Action? Callback { get; private set; } public void AddMouseMove(Action callback) { AddCalls++; Callback = callback; LiveEdges++; if (RaiseDuringAdd) callback(new Vector2(50, 25)); if (FailAdd) throw new InvalidOperationException("add"); } public void RemoveMouseMove(Action callback) { RemoveCalls++; if (FailRemoveOnce) { FailRemoveOnce = false; throw new InvalidOperationException("remove"); } if (ReferenceEquals(Callback, callback)) { Callback = null; LiveEdges--; } } public void Raise(Vector2 position) => Callback?.Invoke(position); } private sealed class Cursor : IPointerCursorModeTarget { public CursorMode CursorMode { get; set; } } private sealed class Capture : IInputCaptureSource { public bool Mouse { get; set; } public bool WantCaptureMouse => Mouse; public bool WantCaptureKeyboard => false; public bool DevToolsWantCaptureKeyboard => false; } private sealed class Mouse : IMouseSource { #pragma warning disable CS0067 public event Action? MouseDown; public event Action? MouseUp; public event Action? MouseMove; public event Action? Scroll; #pragma warning restore CS0067 public bool IsHeld(MouseButton button) => false; public bool WantCaptureMouse => false; public bool WantCaptureKeyboard => false; } private sealed class Clock : IInputMonotonicClock { public float NowSeconds => 1f; } private sealed class MouseLook(bool active) : IMouseLookInputFrameController { public bool Active { get; } = active; public int LifecycleEndCalls { get; private set; } public bool HandlePointerAction(InputAction action, ActivationType activation) => false; public void QueueRawDelta(float dx, float dy) { } public void Tick() { } public void EndAndRestoreCursor() { } public void EndForLifecycle() => LifecycleEndCalls++; public void ResetSession() { } } private sealed class Combat : ICombatInputFrameController { public void Tick() { } public void HandleMovementInput(InputAction action, ActivationType activation) { } public bool HandleInputAction(InputAction action, ActivationType activation) => false; } }