refactor(input): own pointer and callback lifetime
Move camera pointer, framebuffer resize, and retained/devtools input edges behind focused reversible owners. Preserve input priority while making shutdown deactivate callbacks before live-session retirement and retry physical detach without stranding transport teardown.
This commit is contained in:
parent
d09e246d3a
commit
8b8afeefa3
42 changed files with 4029 additions and 461 deletions
|
|
@ -0,0 +1,287 @@
|
|||
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<Exception>(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<Vector2> 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<ObjectDisposedException>(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<RawSurface> 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<Vector2>? Callback { get; private set; }
|
||||
public void AddMouseMove(Action<Vector2> callback)
|
||||
{
|
||||
AddCalls++;
|
||||
Callback = callback;
|
||||
LiveEdges++;
|
||||
if (RaiseDuringAdd) callback(new Vector2(50, 25));
|
||||
if (FailAdd) throw new InvalidOperationException("add");
|
||||
}
|
||||
public void RemoveMouseMove(Action<Vector2> 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<MouseButton, ModifierMask>? MouseDown;
|
||||
public event Action<MouseButton, ModifierMask>? MouseUp;
|
||||
public event Action<float, float>? MouseMove;
|
||||
public event Action<float>? 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
using AcDream.App.Input;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.App.Tests.Input;
|
||||
|
||||
public sealed class DispatcherCameraInputSourceTests
|
||||
{
|
||||
[Fact]
|
||||
public void ExactUnbindRestoresNeutralCameraSampling()
|
||||
{
|
||||
var keyboard = new Keyboard();
|
||||
var mouse = new Mouse();
|
||||
var dispatcher = InputDispatcher.CreateDetached(keyboard, mouse, new KeyBindings());
|
||||
dispatcher.Attach();
|
||||
var other = InputDispatcher.CreateDetached(new Keyboard(), new Mouse(), new KeyBindings());
|
||||
other.Attach();
|
||||
var source = new DispatcherCameraInputSource();
|
||||
source.Bind(dispatcher);
|
||||
dispatcher.TrySetAutomationActionHeld(InputAction.MovementForward, held: true);
|
||||
|
||||
source.Unbind(other);
|
||||
Assert.True(source.CaptureFly().Forward);
|
||||
source.Unbind(dispatcher);
|
||||
|
||||
Assert.False(source.IsAvailable);
|
||||
Assert.Equal(default, source.CaptureFly());
|
||||
Assert.Equal(default, source.CaptureChaseAdjustment());
|
||||
}
|
||||
|
||||
private sealed class Keyboard : IKeyboardSource
|
||||
{
|
||||
#pragma warning disable CS0067
|
||||
public event Action<Key, ModifierMask>? KeyDown;
|
||||
public event Action<Key, ModifierMask>? KeyUp;
|
||||
#pragma warning restore CS0067
|
||||
public bool IsHeld(Key key) => false;
|
||||
public ModifierMask CurrentModifiers => ModifierMask.None;
|
||||
}
|
||||
|
||||
private sealed class Mouse : IMouseSource
|
||||
{
|
||||
#pragma warning disable CS0067
|
||||
public event Action<MouseButton, ModifierMask>? MouseDown;
|
||||
public event Action<MouseButton, ModifierMask>? MouseUp;
|
||||
public event Action<float, float>? MouseMove;
|
||||
public event Action<float>? Scroll;
|
||||
#pragma warning restore CS0067
|
||||
public bool IsHeld(MouseButton button) => false;
|
||||
public bool WantCaptureMouse => false;
|
||||
public bool WantCaptureKeyboard => false;
|
||||
}
|
||||
}
|
||||
|
|
@ -112,12 +112,34 @@ public sealed class DispatcherMovementInputSourceTests
|
|||
Assert.Throws<InvalidOperationException>(() => source.Bind(second));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnbindRequiresExactDispatcherAndRestoresNeutralCapture()
|
||||
{
|
||||
var source = new DispatcherMovementInputSource();
|
||||
var (first, _, _) = CreateDispatcher();
|
||||
var (other, _, _) = CreateDispatcher();
|
||||
source.Bind(first);
|
||||
first.TrySetAutomationActionHeld(InputAction.MovementForward, held: true);
|
||||
|
||||
source.Unbind(other);
|
||||
Assert.True(source.Capture().Forward);
|
||||
source.Unbind(first);
|
||||
|
||||
Assert.False(source.IsAvailable);
|
||||
Assert.Equal(default, source.Capture());
|
||||
}
|
||||
|
||||
private static (InputDispatcher Dispatcher, FakeKeyboard Keyboard, FakeMouse Mouse)
|
||||
CreateDispatcher()
|
||||
{
|
||||
var keyboard = new FakeKeyboard();
|
||||
var mouse = new FakeMouse();
|
||||
return (new InputDispatcher(keyboard, mouse, new KeyBindings()), keyboard, mouse);
|
||||
var dispatcher = InputDispatcher.CreateDetached(
|
||||
keyboard,
|
||||
mouse,
|
||||
new KeyBindings());
|
||||
dispatcher.Attach();
|
||||
return (dispatcher, keyboard, mouse);
|
||||
}
|
||||
|
||||
private sealed class FakeKeyboard : IKeyboardSource
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ public sealed class GameplayInputFrameControllerTests
|
|||
new KeyChord(Key.W, ModifierMask.None),
|
||||
InputAction.MovementForward,
|
||||
ActivationType.Hold));
|
||||
var dispatcher = new InputDispatcher(keyboard, mouseSource, bindings);
|
||||
var dispatcher = InputDispatcher.CreateDetached(keyboard, mouseSource, bindings);
|
||||
dispatcher.Attach();
|
||||
keyboard.Press(Key.W);
|
||||
dispatcher.Fired += (_, activation) =>
|
||||
{
|
||||
|
|
|
|||
344
tests/AcDream.App.Tests/Input/QuiescentInputContextTests.cs
Normal file
344
tests/AcDream.App.Tests/Input/QuiescentInputContextTests.cs
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
using System.Reflection;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Rendering;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.App.Tests.Input;
|
||||
|
||||
public sealed class QuiescentInputContextTests
|
||||
{
|
||||
[Fact]
|
||||
public void EveryFrontendAddFailureRetainsTheCompletePossiblyAttachedPrefix()
|
||||
{
|
||||
for (int failAdd = 1; failAdd <= 10; failAdd++)
|
||||
{
|
||||
var fixture = new Fixture { FailAdd = failAdd };
|
||||
var context = fixture.CreateContext();
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => SubscribeAll(context, () => { }));
|
||||
context.Dispose();
|
||||
|
||||
Assert.Equal(failAdd, fixture.AddCalls);
|
||||
Assert.Equal(failAdd, fixture.RemoveCalls);
|
||||
Assert.Equal(0, fixture.LiveEdges);
|
||||
Assert.True(context.IsDisposalComplete);
|
||||
Assert.False(fixture.InnerDisposed);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryFrontendRemoveFailureRetriesOnlyThePendingEdge()
|
||||
{
|
||||
for (int failRemoveEdge = 1; failRemoveEdge <= 10; failRemoveEdge++)
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
var context = fixture.CreateContext();
|
||||
SubscribeAll(context, () => { });
|
||||
context.Activate();
|
||||
fixture.FailRemoveEdgeOnce = failRemoveEdge;
|
||||
|
||||
context.Dispose();
|
||||
int[] completedCounts = fixture.RemoveCounts.ToArray();
|
||||
context.Dispose();
|
||||
|
||||
for (int edge = 1; edge <= 10; edge++)
|
||||
{
|
||||
Assert.Equal(
|
||||
edge == failRemoveEdge ? 2 : 1,
|
||||
completedCounts[edge]);
|
||||
Assert.Equal(completedCounts[edge], fixture.RemoveCounts[edge]);
|
||||
}
|
||||
Assert.Equal(0, fixture.LiveEdges);
|
||||
Assert.True(context.IsDisposalComplete);
|
||||
Assert.False(fixture.InnerDisposed);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeactivateSilencesKeyboardMouseAndConnectionRelaysBeforePhysicalDetach()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
var context = fixture.CreateContext();
|
||||
int calls = 0;
|
||||
SubscribeAll(context, () => calls++);
|
||||
context.Activate();
|
||||
|
||||
fixture.RaiseAll();
|
||||
Assert.Equal(10, calls);
|
||||
|
||||
context.Deactivate();
|
||||
fixture.RaiseAll();
|
||||
context.Dispose();
|
||||
|
||||
Assert.Equal(10, calls);
|
||||
Assert.Equal(0, fixture.LiveEdges);
|
||||
Assert.True(context.IsDisposalComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventRaisedFromAddAccessorCannotEnterPartialFrontend()
|
||||
{
|
||||
var fixture = new Fixture { RaiseKeyboardDownDuringAdd = true };
|
||||
var context = fixture.CreateContext();
|
||||
int calls = 0;
|
||||
|
||||
context.Keyboards[0].KeyDown += (_, _, _) => calls++;
|
||||
Assert.Equal(0, calls);
|
||||
context.Activate();
|
||||
fixture.Keyboard.RaiseDown();
|
||||
|
||||
Assert.Equal(1, calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisposeBeforeFrontendConstructionMakesContextAndDeviceViewsTerminal()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
var context = fixture.CreateContext();
|
||||
IKeyboard keyboard = context.Keyboards[0];
|
||||
IMouse mouse = context.Mice[0];
|
||||
context.Dispose();
|
||||
|
||||
Assert.Throws<ObjectDisposedException>(context.Activate);
|
||||
Assert.Throws<ObjectDisposedException>(
|
||||
() => keyboard.KeyDown += static (_, _, _) => { });
|
||||
Assert.Throws<ObjectDisposedException>(
|
||||
() => mouse.MouseDown += static (_, _) => { });
|
||||
Assert.Throws<ObjectDisposedException>(
|
||||
() => context.ConnectionChanged += static (_, _) => { });
|
||||
|
||||
context.Dispose();
|
||||
Assert.Equal(0, fixture.LiveEdges);
|
||||
Assert.True(context.IsDisposalComplete);
|
||||
}
|
||||
|
||||
private static void SubscribeAll(QuiescentInputContext context, Action called)
|
||||
{
|
||||
IKeyboard keyboard = context.Keyboards[0];
|
||||
IMouse mouse = context.Mice[0];
|
||||
keyboard.KeyDown += (_, _, _) => called();
|
||||
keyboard.KeyUp += (_, _, _) => called();
|
||||
keyboard.KeyChar += (_, _) => called();
|
||||
mouse.MouseDown += (_, _) => called();
|
||||
mouse.MouseUp += (_, _) => called();
|
||||
mouse.Click += (_, _, _) => called();
|
||||
mouse.DoubleClick += (_, _, _) => called();
|
||||
mouse.MouseMove += (_, _) => called();
|
||||
mouse.Scroll += (_, _) => called();
|
||||
context.ConnectionChanged += (_, _) => called();
|
||||
}
|
||||
|
||||
private sealed class Fixture
|
||||
{
|
||||
private readonly FaultPlan _faults = new();
|
||||
private Context? _inner;
|
||||
private IMouse? _mouse;
|
||||
private MouseProxy? _mouseProxy;
|
||||
|
||||
public Keyboard Keyboard { get; private set; } = null!;
|
||||
public int FailAdd { set => _faults.FailAdd = value; }
|
||||
public int FailRemoveEdgeOnce { set => _faults.FailRemoveEdgeOnce = value; }
|
||||
public bool RaiseKeyboardDownDuringAdd { get; set; }
|
||||
public int AddCalls => _faults.AddCalls;
|
||||
public int RemoveCalls => _faults.RemoveCalls;
|
||||
public int[] RemoveCounts => _faults.RemoveCounts;
|
||||
public int LiveEdges => _faults.LiveEdges;
|
||||
public bool InnerDisposed => _inner?.Disposed == true;
|
||||
|
||||
public QuiescentInputContext CreateContext()
|
||||
{
|
||||
Keyboard = new Keyboard(_faults)
|
||||
{
|
||||
RaiseDuringAdd = RaiseKeyboardDownDuringAdd,
|
||||
};
|
||||
_mouse = DispatchProxy.Create<IMouse, MouseProxy>();
|
||||
_mouseProxy = (MouseProxy)(object)_mouse;
|
||||
_mouseProxy.Initialize(_faults, _mouse);
|
||||
_inner = new Context(_faults, Keyboard, _mouse);
|
||||
return new QuiescentInputContext(_inner, new HostQuiescenceGate());
|
||||
}
|
||||
|
||||
public void RaiseAll()
|
||||
{
|
||||
Keyboard.RaiseAll();
|
||||
_mouseProxy!.RaiseAll();
|
||||
_inner!.RaiseConnection(Keyboard);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class FaultPlan
|
||||
{
|
||||
public int FailAdd { get; set; }
|
||||
public int FailRemoveEdgeOnce { get; set; }
|
||||
public int AddCalls { get; private set; }
|
||||
public int RemoveCalls { get; private set; }
|
||||
public int LiveEdges { get; private set; }
|
||||
public int[] RemoveCounts { get; } = new int[11];
|
||||
|
||||
public void Add(int edge, Action sideEffect)
|
||||
{
|
||||
AddCalls++;
|
||||
sideEffect();
|
||||
LiveEdges++;
|
||||
if (AddCalls == FailAdd)
|
||||
throw new InvalidOperationException($"add {edge}");
|
||||
}
|
||||
|
||||
public void Remove(int edge, Action sideEffect)
|
||||
{
|
||||
RemoveCalls++;
|
||||
RemoveCounts[edge]++;
|
||||
if (FailRemoveEdgeOnce == edge)
|
||||
{
|
||||
FailRemoveEdgeOnce = 0;
|
||||
throw new InvalidOperationException($"remove {edge}");
|
||||
}
|
||||
|
||||
sideEffect();
|
||||
LiveEdges--;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Context(
|
||||
FaultPlan faults,
|
||||
IKeyboard keyboard,
|
||||
IMouse mouse) : IInputContext
|
||||
{
|
||||
private Action<IInputDevice, bool>? _connectionChanged;
|
||||
public bool Disposed { get; private set; }
|
||||
public nint Handle => 1;
|
||||
public IReadOnlyList<IGamepad> Gamepads { get; } = [];
|
||||
public IReadOnlyList<IJoystick> Joysticks { get; } = [];
|
||||
public IReadOnlyList<IKeyboard> Keyboards { get; } = [keyboard];
|
||||
public IReadOnlyList<IMouse> Mice { get; } = [mouse];
|
||||
public IReadOnlyList<IInputDevice> OtherDevices { get; } = [];
|
||||
|
||||
public event Action<IInputDevice, bool>? ConnectionChanged
|
||||
{
|
||||
add => faults.Add(10, () => _connectionChanged += value);
|
||||
remove => faults.Remove(10, () => _connectionChanged -= value);
|
||||
}
|
||||
|
||||
public void Dispose() => Disposed = true;
|
||||
public void RaiseConnection(IInputDevice device) =>
|
||||
_connectionChanged?.Invoke(device, true);
|
||||
}
|
||||
|
||||
private sealed class Keyboard(FaultPlan faults) : IKeyboard
|
||||
{
|
||||
private Action<IKeyboard, Key, int>? _down;
|
||||
private Action<IKeyboard, Key, int>? _up;
|
||||
private Action<IKeyboard, char>? _char;
|
||||
public bool RaiseDuringAdd { get; init; }
|
||||
public string Name => "keyboard";
|
||||
public int Index => 0;
|
||||
public bool IsConnected => true;
|
||||
public IReadOnlyList<Key> SupportedKeys { get; } = [];
|
||||
public string ClipboardText { get; set; } = string.Empty;
|
||||
|
||||
public event Action<IKeyboard, Key, int>? KeyDown
|
||||
{
|
||||
add => faults.Add(1, () =>
|
||||
{
|
||||
_down += value;
|
||||
if (RaiseDuringAdd)
|
||||
_down?.Invoke(this, Key.A, 0);
|
||||
});
|
||||
remove => faults.Remove(1, () => _down -= value);
|
||||
}
|
||||
public event Action<IKeyboard, Key, int>? KeyUp
|
||||
{
|
||||
add => faults.Add(2, () => _up += value);
|
||||
remove => faults.Remove(2, () => _up -= value);
|
||||
}
|
||||
public event Action<IKeyboard, char>? KeyChar
|
||||
{
|
||||
add => faults.Add(3, () => _char += value);
|
||||
remove => faults.Remove(3, () => _char -= value);
|
||||
}
|
||||
|
||||
public bool IsKeyPressed(Key key) => false;
|
||||
public bool IsScancodePressed(int scancode) => false;
|
||||
public void BeginInput() { }
|
||||
public void EndInput() { }
|
||||
public void RaiseDown() => _down?.Invoke(this, Key.A, 0);
|
||||
public void RaiseAll()
|
||||
{
|
||||
_down?.Invoke(this, Key.A, 0);
|
||||
_up?.Invoke(this, Key.A, 0);
|
||||
_char?.Invoke(this, 'a');
|
||||
}
|
||||
}
|
||||
|
||||
public class MouseProxy : DispatchProxy
|
||||
{
|
||||
private readonly Dictionary<string, Delegate?> _events = new();
|
||||
private FaultPlan _faults = null!;
|
||||
private IMouse _self = null!;
|
||||
|
||||
public void Initialize(FaultPlan faults, IMouse self)
|
||||
{
|
||||
_faults = faults;
|
||||
_self = self;
|
||||
}
|
||||
|
||||
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(targetMethod);
|
||||
args ??= [];
|
||||
if (targetMethod.Name.StartsWith("add_", StringComparison.Ordinal))
|
||||
{
|
||||
string name = targetMethod.Name[4..];
|
||||
int edge = Edge(name);
|
||||
_faults.Add(edge, () =>
|
||||
_events[name] = Delegate.Combine(_events.GetValueOrDefault(name), (Delegate)args[0]!));
|
||||
return null;
|
||||
}
|
||||
if (targetMethod.Name.StartsWith("remove_", StringComparison.Ordinal))
|
||||
{
|
||||
string name = targetMethod.Name[7..];
|
||||
int edge = Edge(name);
|
||||
_faults.Remove(edge, () =>
|
||||
_events[name] = Delegate.Remove(_events.GetValueOrDefault(name), (Delegate)args[0]!));
|
||||
return null;
|
||||
}
|
||||
|
||||
if (targetMethod.Name == nameof(IMouse.IsButtonPressed))
|
||||
return false;
|
||||
Type returnType = targetMethod.ReturnType;
|
||||
if (returnType == typeof(void))
|
||||
return null;
|
||||
if (returnType == typeof(string))
|
||||
return string.Empty;
|
||||
return returnType.IsValueType ? Activator.CreateInstance(returnType) : null;
|
||||
}
|
||||
|
||||
public void RaiseAll()
|
||||
{
|
||||
Get<Action<IMouse, MouseButton>>(nameof(IMouse.MouseDown))?.Invoke(_self, MouseButton.Left);
|
||||
Get<Action<IMouse, MouseButton>>(nameof(IMouse.MouseUp))?.Invoke(_self, MouseButton.Left);
|
||||
Get<Action<IMouse, MouseButton, System.Numerics.Vector2>>(nameof(IMouse.Click))
|
||||
?.Invoke(_self, MouseButton.Left, System.Numerics.Vector2.One);
|
||||
Get<Action<IMouse, MouseButton, System.Numerics.Vector2>>(nameof(IMouse.DoubleClick))
|
||||
?.Invoke(_self, MouseButton.Left, System.Numerics.Vector2.One);
|
||||
Get<Action<IMouse, System.Numerics.Vector2>>(nameof(IMouse.MouseMove))
|
||||
?.Invoke(_self, System.Numerics.Vector2.One);
|
||||
Get<Action<IMouse, ScrollWheel>>(nameof(IMouse.Scroll))
|
||||
?.Invoke(_self, new ScrollWheel(1, 1));
|
||||
}
|
||||
|
||||
private T? Get<T>(string name) where T : Delegate =>
|
||||
_events.GetValueOrDefault(name) as T;
|
||||
|
||||
private static int Edge(string name) => name switch
|
||||
{
|
||||
nameof(IMouse.MouseDown) => 4,
|
||||
nameof(IMouse.MouseUp) => 5,
|
||||
nameof(IMouse.Click) => 6,
|
||||
nameof(IMouse.DoubleClick) => 7,
|
||||
nameof(IMouse.MouseMove) => 8,
|
||||
nameof(IMouse.Scroll) => 9,
|
||||
_ => throw new InvalidOperationException($"Unexpected mouse event {name}."),
|
||||
};
|
||||
}
|
||||
}
|
||||
284
tests/AcDream.App.Tests/Input/SilkInputSourceLifetimeTests.cs
Normal file
284
tests/AcDream.App.Tests/Input/SilkInputSourceLifetimeTests.cs
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
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 SilkInputSourceLifetimeTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
public void KeyboardAttachFailureRollsBackEveryPossiblyAcquiredPrefix(int failAdd)
|
||||
{
|
||||
var surface = new KeyboardSurface { FailAdd = failAdd };
|
||||
var source = SilkKeyboardSource.CreateDetached(surface, new HostQuiescenceGate());
|
||||
|
||||
Assert.ThrowsAny<Exception>(source.Attach);
|
||||
|
||||
Assert.True(source.IsDisposalComplete);
|
||||
Assert.Equal(failAdd, surface.AddCalls);
|
||||
Assert.Equal(failAdd, surface.RemoveCalls);
|
||||
Assert.Equal(0, surface.LiveEdges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyboardDeactivateSilencesCopiedDelegateBeforePhysicalDetach()
|
||||
{
|
||||
var surface = new KeyboardSurface();
|
||||
var source = SilkKeyboardSource.CreateDetached(surface, new HostQuiescenceGate());
|
||||
int calls = 0;
|
||||
source.KeyDown += (_, _) => calls++;
|
||||
source.Attach();
|
||||
Action<Key> copied = surface.Down!;
|
||||
|
||||
surface.RaiseDown(Key.A);
|
||||
source.Deactivate();
|
||||
copied(Key.B);
|
||||
source.Dispose();
|
||||
|
||||
Assert.Equal(1, calls);
|
||||
Assert.True(source.IsDisposalComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyboardEventRaisedReentrantlyDuringAttachCannotEnterPartialSource()
|
||||
{
|
||||
var surface = new KeyboardSurface { RaiseDuringAdd = true };
|
||||
var source = SilkKeyboardSource.CreateDetached(surface, new HostQuiescenceGate());
|
||||
int calls = 0;
|
||||
source.KeyDown += (_, _) => calls++;
|
||||
|
||||
source.Attach();
|
||||
Assert.Equal(0, calls);
|
||||
surface.RaiseDown(Key.A);
|
||||
|
||||
Assert.Equal(1, calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyboardDetachRetriesOnlyPendingEdge()
|
||||
{
|
||||
var surface = new KeyboardSurface();
|
||||
var source = SilkKeyboardSource.CreateDetached(surface, new HostQuiescenceGate());
|
||||
source.Attach();
|
||||
surface.FailRemoveDown = true;
|
||||
|
||||
Assert.Throws<AggregateException>(source.Dispose);
|
||||
int upRemoves = surface.UpRemoveCalls;
|
||||
surface.FailRemoveDown = false;
|
||||
source.Dispose();
|
||||
|
||||
Assert.Equal(upRemoves, surface.UpRemoveCalls);
|
||||
Assert.True(source.IsDisposalComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyboardDisposeBeforeAttachMakesSourceTerminal()
|
||||
{
|
||||
var surface = new KeyboardSurface();
|
||||
var source = SilkKeyboardSource.CreateDetached(surface, new HostQuiescenceGate());
|
||||
|
||||
source.Dispose();
|
||||
|
||||
Assert.Throws<ObjectDisposedException>(source.Attach);
|
||||
source.Dispose();
|
||||
Assert.Equal(0, surface.LiveEdges);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
public void MouseAttachFailureRollsBackEveryPossiblyAcquiredPrefix(int failAdd)
|
||||
{
|
||||
var surface = new MouseSurface { FailAdd = failAdd };
|
||||
var source = SilkMouseSource.CreateDetached(
|
||||
surface,
|
||||
new Capture(),
|
||||
modifierSource: null,
|
||||
new HostQuiescenceGate());
|
||||
|
||||
Assert.ThrowsAny<Exception>(source.Attach);
|
||||
|
||||
Assert.True(source.IsDisposalComplete);
|
||||
Assert.Equal(failAdd, surface.AddCalls);
|
||||
Assert.Equal(failAdd, surface.RemoveCalls);
|
||||
Assert.Equal(0, surface.LiveEdges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MouseDeactivateSilencesAllCopiedDelegatesBeforePhysicalDetach()
|
||||
{
|
||||
var surface = new MouseSurface();
|
||||
var source = SilkMouseSource.CreateDetached(
|
||||
surface,
|
||||
new Capture(),
|
||||
modifierSource: null,
|
||||
new HostQuiescenceGate());
|
||||
int calls = 0;
|
||||
source.MouseDown += (_, _) => calls++;
|
||||
source.MouseUp += (_, _) => calls++;
|
||||
source.MouseMove += (_, _) => calls++;
|
||||
source.Scroll += _ => calls++;
|
||||
source.Attach();
|
||||
var copied = surface.CopyCallbacks();
|
||||
|
||||
surface.RaiseAll();
|
||||
source.Deactivate();
|
||||
copied.Down(MouseButton.Left);
|
||||
copied.Up(MouseButton.Left);
|
||||
copied.Move(new Vector2(5, 6));
|
||||
copied.Scroll(1);
|
||||
source.Dispose();
|
||||
|
||||
Assert.Equal(4, calls);
|
||||
Assert.True(source.IsDisposalComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MouseFailedDetachRetriesOnlyPendingEdge()
|
||||
{
|
||||
var surface = new MouseSurface();
|
||||
var source = SilkMouseSource.CreateDetached(
|
||||
surface,
|
||||
new Capture(),
|
||||
modifierSource: null,
|
||||
new HostQuiescenceGate());
|
||||
source.Attach();
|
||||
surface.FailRemoveMoveOnce = true;
|
||||
|
||||
source.Dispose();
|
||||
int removes = surface.RemoveCalls;
|
||||
source.Dispose();
|
||||
|
||||
Assert.Equal(removes, surface.RemoveCalls);
|
||||
Assert.Equal(2, surface.MoveRemoveCalls);
|
||||
Assert.True(source.IsDisposalComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MouseDisposeBeforeAttachMakesSourceTerminal()
|
||||
{
|
||||
var surface = new MouseSurface();
|
||||
var source = SilkMouseSource.CreateDetached(
|
||||
surface,
|
||||
new Capture(),
|
||||
modifierSource: null,
|
||||
new HostQuiescenceGate());
|
||||
|
||||
source.Dispose();
|
||||
|
||||
Assert.Throws<ObjectDisposedException>(source.Attach);
|
||||
source.Dispose();
|
||||
Assert.Equal(0, surface.LiveEdges);
|
||||
}
|
||||
|
||||
private sealed class Capture : IInputCaptureSource
|
||||
{
|
||||
public bool WantCaptureMouse => false;
|
||||
public bool WantCaptureKeyboard => false;
|
||||
public bool DevToolsWantCaptureKeyboard => false;
|
||||
}
|
||||
|
||||
private sealed class KeyboardSurface : IKeyboardEventSurface
|
||||
{
|
||||
public int FailAdd { get; init; }
|
||||
public bool FailRemoveDown { get; set; }
|
||||
public bool RaiseDuringAdd { get; init; }
|
||||
public int AddCalls { get; private set; }
|
||||
public int RemoveCalls { get; private set; }
|
||||
public int UpRemoveCalls { get; private set; }
|
||||
public int LiveEdges { get; private set; }
|
||||
private Action<Key>? _down;
|
||||
private Action<Key>? _up;
|
||||
public Action<Key>? Down => _down;
|
||||
|
||||
public void AddKeyDown(Action<Key> callback) => Add(ref _down, callback);
|
||||
public void AddKeyUp(Action<Key> callback) => Add(ref _up, callback);
|
||||
public void RemoveKeyDown(Action<Key> callback)
|
||||
{
|
||||
RemoveCalls++;
|
||||
if (FailRemoveDown) throw new InvalidOperationException("remove down");
|
||||
if (ReferenceEquals(_down, callback)) { _down = null; LiveEdges--; }
|
||||
}
|
||||
public void RemoveKeyUp(Action<Key> callback)
|
||||
{
|
||||
RemoveCalls++;
|
||||
UpRemoveCalls++;
|
||||
if (ReferenceEquals(_up, callback)) { _up = null; LiveEdges--; }
|
||||
}
|
||||
public bool IsKeyPressed(Key key) => false;
|
||||
public void RaiseDown(Key key) => _down?.Invoke(key);
|
||||
|
||||
private void Add(ref Action<Key>? slot, Action<Key> callback)
|
||||
{
|
||||
AddCalls++;
|
||||
slot = callback;
|
||||
LiveEdges++;
|
||||
if (RaiseDuringAdd) callback(Key.A);
|
||||
if (AddCalls == FailAdd) throw new InvalidOperationException("add");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MouseSurface : IMouseEventSurface
|
||||
{
|
||||
public int FailAdd { get; init; }
|
||||
public bool FailRemoveMoveOnce { get; set; }
|
||||
public int AddCalls { get; private set; }
|
||||
public int RemoveCalls { get; private set; }
|
||||
public int MoveRemoveCalls { get; private set; }
|
||||
public int LiveEdges { get; private set; }
|
||||
private Action<MouseButton>? _down;
|
||||
private Action<MouseButton>? _up;
|
||||
private Action<Vector2>? _move;
|
||||
private Action<float>? _scroll;
|
||||
|
||||
public void AddMouseDown(Action<MouseButton> callback) => Add(ref _down, callback);
|
||||
public void AddMouseUp(Action<MouseButton> callback) => Add(ref _up, callback);
|
||||
public void AddMouseMove(Action<Vector2> callback) => Add(ref _move, callback);
|
||||
public void AddScroll(Action<float> callback) => Add(ref _scroll, callback);
|
||||
public void RemoveMouseDown(Action<MouseButton> callback) => Remove(ref _down, callback);
|
||||
public void RemoveMouseUp(Action<MouseButton> callback) => Remove(ref _up, callback);
|
||||
public void RemoveMouseMove(Action<Vector2> callback)
|
||||
{
|
||||
MoveRemoveCalls++;
|
||||
if (FailRemoveMoveOnce)
|
||||
{
|
||||
FailRemoveMoveOnce = false;
|
||||
throw new InvalidOperationException("remove move");
|
||||
}
|
||||
Remove(ref _move, callback);
|
||||
}
|
||||
public void RemoveScroll(Action<float> callback) => Remove(ref _scroll, callback);
|
||||
public bool IsButtonPressed(MouseButton button) => false;
|
||||
|
||||
public (Action<MouseButton> Down, Action<MouseButton> Up, Action<Vector2> Move, Action<float> Scroll)
|
||||
CopyCallbacks() => (_down!, _up!, _move!, _scroll!);
|
||||
|
||||
public void RaiseAll()
|
||||
{
|
||||
_down?.Invoke(MouseButton.Left);
|
||||
_up?.Invoke(MouseButton.Left);
|
||||
_move?.Invoke(Vector2.One);
|
||||
_scroll?.Invoke(1);
|
||||
}
|
||||
|
||||
private void Add<T>(ref Action<T>? slot, Action<T> callback)
|
||||
{
|
||||
AddCalls++;
|
||||
slot = callback;
|
||||
LiveEdges++;
|
||||
if (AddCalls == FailAdd) throw new InvalidOperationException("add");
|
||||
}
|
||||
|
||||
private void Remove<T>(ref Action<T>? slot, Action<T> callback)
|
||||
{
|
||||
RemoveCalls++;
|
||||
if (ReferenceEquals(slot, callback)) { slot = null; LiveEdges--; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
using AcDream.App.Input;
|
||||
using AcDream.App.Rendering;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class FramebufferResizeControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResizePreservesViewportAspectCameraDevToolsOrder()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var aspect = new ViewportAspectState();
|
||||
var owner = new FramebufferResizeController(aspect);
|
||||
owner.BindViewport(new Viewport(calls));
|
||||
owner.BindCamera(new Camera(calls));
|
||||
owner.BindDevTools(new DevTools(calls));
|
||||
|
||||
owner.Resize(1600, 900);
|
||||
|
||||
Assert.Equal(["viewport:1600x900", "camera:1.778", "devtools:1600x900"], calls);
|
||||
Assert.Equal(1600f / 900f, aspect.Aspect, 5);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 720)]
|
||||
[InlineData(1280, 0)]
|
||||
[InlineData(-1, 720)]
|
||||
[InlineData(1280, -1)]
|
||||
public void NonPositiveFramebufferIsRejectedWithoutMutatingTargets(int width, int height)
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var aspect = new ViewportAspectState();
|
||||
var owner = new FramebufferResizeController(aspect);
|
||||
owner.BindViewport(new Viewport(calls));
|
||||
owner.BindCamera(new Camera(calls));
|
||||
owner.BindDevTools(new DevTools(calls));
|
||||
|
||||
owner.Resize(width, height);
|
||||
|
||||
Assert.Empty(calls);
|
||||
Assert.Equal(16f / 9f, aspect.Aspect);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LateBindingDoesNotReplayEarlierResize()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var owner = new FramebufferResizeController(new ViewportAspectState());
|
||||
owner.Resize(1024, 768);
|
||||
|
||||
owner.BindViewport(new Viewport(calls));
|
||||
owner.BindCamera(new Camera(calls));
|
||||
owner.BindDevTools(new DevTools(calls));
|
||||
|
||||
Assert.Empty(calls);
|
||||
owner.Resize(800, 600);
|
||||
Assert.Equal(["viewport:800x600", "camera:1.333", "devtools:800x600"], calls);
|
||||
}
|
||||
|
||||
private sealed class Viewport(List<string> calls) : IFramebufferViewportTarget
|
||||
{
|
||||
public void ResizeViewport(int width, int height) =>
|
||||
calls.Add($"viewport:{width}x{height}");
|
||||
}
|
||||
|
||||
private sealed class Camera(List<string> calls) : IFramebufferCameraTarget
|
||||
{
|
||||
public void SetAspect(float aspect) => calls.Add($"camera:{aspect:F3}");
|
||||
}
|
||||
|
||||
private sealed class DevTools(List<string> calls) : IFramebufferDevToolsTarget
|
||||
{
|
||||
public void ResetLayout(int width, int height) =>
|
||||
calls.Add($"devtools:{width}x{height}");
|
||||
}
|
||||
}
|
||||
|
|
@ -99,7 +99,13 @@ public sealed class GameWindowRenderLeafCompositionTests
|
|||
Assert.Contains("new AcDream.App.Rendering.PrivatePresentationRenderer(", source);
|
||||
Assert.Contains("new AcDream.App.Rendering.RenderFrameOrchestrator(", source);
|
||||
Assert.Contains("new DisplayFramePacingController(", source);
|
||||
Assert.Contains("_inputCapture.WantCaptureMouse", source);
|
||||
string pointerSource = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Input",
|
||||
"CameraPointerInputController.cs"));
|
||||
Assert.Contains("_capture.WantCaptureMouse", pointerSource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -54,14 +54,18 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
body,
|
||||
"_gl = GL.GetApi(_window!);",
|
||||
"_input = _window!.CreateInput();",
|
||||
"_kbSource = new AcDream.App.Input.SilkKeyboardSource(firstKb);",
|
||||
"_mouseSource = new AcDream.App.Input.SilkMouseSource(",
|
||||
"_inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher(",
|
||||
"_kbSource = AcDream.App.Input.SilkKeyboardSource.CreateDetached(",
|
||||
"_kbSource.Attach();",
|
||||
"_mouseSource = AcDream.App.Input.SilkMouseSource.CreateDetached(",
|
||||
"_mouseSource.Attach();",
|
||||
"_inputDispatcher = AcDream.UI.Abstractions.Input.InputDispatcher.CreateDetached(",
|
||||
"_inputDispatcher.Attach();",
|
||||
"_movementInput.Bind(_inputDispatcher);",
|
||||
"_cameraInput.Bind(_inputDispatcher);",
|
||||
"_inputDispatcher.Fired += OnInputAction;",
|
||||
"mouse.MouseMove +=",
|
||||
"_cameraController = new CameraController(orbit, fly);",
|
||||
"_cameraPointerInput = AcDream.App.Input.CameraPointerInputController.Create(",
|
||||
"_cameraPointerInput.AttachRaw();",
|
||||
"_dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir);",
|
||||
"LoadAndApplyPersistedSettings();",
|
||||
"_uiHost = new AcDream.App.UI.UiHost(",
|
||||
|
|
@ -74,6 +78,16 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
"_liveSessionHost = CreateLiveSessionHost();",
|
||||
"_liveSessionHost.Start(_options)");
|
||||
Assert.Equal(1, CountOccurrences(body, "_liveSessionHost.Start(_options)"));
|
||||
Assert.Contains("if (firstKb is not null)", body, StringComparison.Ordinal);
|
||||
Assert.Contains("if (firstMouse is not null)", body, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"if (_kbSource is not null && _mouseSource is not null)",
|
||||
body,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
"if (firstKb is not null && firstMouse is not null)",
|
||||
body,
|
||||
StringComparison.Ordinal);
|
||||
int start = body.IndexOf("_liveSessionHost.Start(_options)", StringComparison.Ordinal);
|
||||
string postStart = body[start..];
|
||||
Assert.Contains("switch (liveStart.Status)", postStart, StringComparison.Ordinal);
|
||||
|
|
@ -144,20 +158,28 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void FramebufferResize_PreservesViewportCameraAndDevtoolsOrder()
|
||||
public void FramebufferResize_IsOneTypedOwnerHandoff()
|
||||
{
|
||||
string load = MethodBody(
|
||||
"private void OnLoad()",
|
||||
"private AcDream.App.Net.LiveSessionHost");
|
||||
string body = MethodBody(
|
||||
"private void OnFramebufferResize(",
|
||||
"private void ApplyDisplayWindowState(");
|
||||
|
||||
Assert.Contains("=> _framebufferResize.Resize(newSize);", body, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Viewport(", body, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("SetAspect(", body, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("ResetLayout(", body, StringComparison.Ordinal);
|
||||
AssertAppearsInOrder(
|
||||
body,
|
||||
"if (newSize.X <= 0 || newSize.Y <= 0) return;",
|
||||
"_gl?.Viewport(",
|
||||
"_viewportAspect.Update(",
|
||||
"_cameraController?.SetAspect(",
|
||||
"_devToolsFramePresenter?.ResetLayout(");
|
||||
Assert.DoesNotContain("_uiHost", body, StringComparison.Ordinal);
|
||||
load,
|
||||
"_framebufferResize.BindViewport(",
|
||||
"_framebufferResize.BindCamera(",
|
||||
"_framebufferResize.Resize(_window!.FramebufferSize);");
|
||||
Assert.DoesNotContain(
|
||||
"_viewportAspect.Update(_window",
|
||||
load,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -167,7 +189,7 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
string update = Slice(
|
||||
source,
|
||||
"private void OnUpdate(double dt)",
|
||||
"private void OnCameraModeChanged(");
|
||||
"private void OnRender(double deltaSeconds)");
|
||||
string render = Slice(
|
||||
source,
|
||||
"private void OnRender(double deltaSeconds)",
|
||||
|
|
@ -189,7 +211,10 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
"_renderFrameOrchestrator!.Render(",
|
||||
"new AcDream.App.Rendering.RenderFrameInput(");
|
||||
Assert.DoesNotContain("FramebufferSize", render, StringComparison.Ordinal);
|
||||
AssertAppearsInOrder(focus, "if (!focused)", "_gameplayInputFrame?.EndMouseLook();");
|
||||
Assert.Contains(
|
||||
"=> _cameraPointerInput?.HandleFocusChanged(focused);",
|
||||
focus,
|
||||
StringComparison.Ordinal);
|
||||
AssertAppearsInOrder(
|
||||
close,
|
||||
"_hostQuiescence.StopAccepting();",
|
||||
|
|
@ -212,7 +237,9 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
|
||||
string[] stages =
|
||||
[
|
||||
"new ResourceShutdownStage(\"input callback deactivation\"",
|
||||
"new ResourceShutdownStage(\"session lifetime\"",
|
||||
"new ResourceShutdownStage(\"input callback detach\"",
|
||||
"new ResourceShutdownStage(\"frame borrowers\"",
|
||||
"new ResourceShutdownStage(\"session dependents\"",
|
||||
"new ResourceShutdownStage(\"live entities\"",
|
||||
|
|
@ -236,6 +263,17 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
"binding.Dispose();",
|
||||
"if (!binding.IsDisposalComplete)",
|
||||
"_windowCallbacks = null;");
|
||||
AssertAppearsInOrder(
|
||||
manifest,
|
||||
"new(\"camera pointer\", () => _cameraPointerInput?.Deactivate())",
|
||||
"new ResourceShutdownStage(\"session lifetime\"",
|
||||
"controller.Dispose();",
|
||||
"new ResourceShutdownStage(\"input callback detach\"",
|
||||
"pointer.Dispose();",
|
||||
"new ResourceShutdownStage(\"session dependents\"",
|
||||
"pointer.ReleaseMouseLookAfterSessionRetirement();",
|
||||
"pointer.UnbindGameplayFrame(gameplayFrame);",
|
||||
"_cameraPointerInput = null;");
|
||||
AssertAppearsInOrder(
|
||||
dispose,
|
||||
"CompleteShutdown();",
|
||||
|
|
|
|||
262
tests/AcDream.App.Tests/UI/RetainedUiInputBindingTests.cs
Normal file
262
tests/AcDream.App.Tests/UI/RetainedUiInputBindingTests.cs
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
using AcDream.App.Rendering;
|
||||
using AcDream.App.UI;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public sealed class RetainedUiInputBindingTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
public void MouseAttachFailureRollsBackEveryPossiblyAcquiredPrefix(int failAdd)
|
||||
{
|
||||
var surface = new MouseSurface { FailAdd = failAdd };
|
||||
var binding = new RetainedMouseInputBinding(
|
||||
surface,
|
||||
new UiRoot(),
|
||||
new HostQuiescenceGate());
|
||||
|
||||
Assert.ThrowsAny<Exception>(binding.Attach);
|
||||
|
||||
Assert.True(binding.IsDisposalComplete);
|
||||
Assert.Equal(failAdd, surface.AddCalls);
|
||||
Assert.Equal(failAdd, surface.RemoveCalls);
|
||||
Assert.Equal(0, surface.LiveEdges);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
public void KeyboardAttachFailureRollsBackEveryPossiblyAcquiredPrefix(int failAdd)
|
||||
{
|
||||
var surface = new KeyboardSurface { FailAdd = failAdd };
|
||||
var binding = new RetainedKeyboardInputBinding(
|
||||
surface,
|
||||
new UiRoot(),
|
||||
new HostQuiescenceGate());
|
||||
|
||||
Assert.ThrowsAny<Exception>(binding.Attach);
|
||||
|
||||
Assert.True(binding.IsDisposalComplete);
|
||||
Assert.Equal(failAdd, surface.AddCalls);
|
||||
Assert.Equal(failAdd, surface.RemoveCalls);
|
||||
Assert.Equal(0, surface.LiveEdges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MouseDeactivateSilencesCopiedDelegateBeforePhysicalDetach()
|
||||
{
|
||||
var surface = new MouseSurface();
|
||||
var root = new UiRoot();
|
||||
int worldClicks = 0;
|
||||
root.WorldMouseFallThrough += (_, _, _, _) => worldClicks++;
|
||||
var binding = new RetainedMouseInputBinding(
|
||||
surface,
|
||||
root,
|
||||
new HostQuiescenceGate());
|
||||
binding.Attach();
|
||||
Action<MouseButton, int, int> copied = surface.Down!;
|
||||
|
||||
copied(MouseButton.Left, 10, 20);
|
||||
binding.Deactivate();
|
||||
copied(MouseButton.Left, 10, 20);
|
||||
binding.Dispose();
|
||||
|
||||
Assert.Equal(1, worldClicks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MouseEventRaisedReentrantlyDuringAttachCannotEnterPartialUiBinding()
|
||||
{
|
||||
var surface = new MouseSurface { RaiseDuringAdd = true };
|
||||
var root = new UiRoot();
|
||||
int worldClicks = 0;
|
||||
root.WorldMouseFallThrough += (_, _, _, _) => worldClicks++;
|
||||
var binding = new RetainedMouseInputBinding(
|
||||
surface,
|
||||
root,
|
||||
new HostQuiescenceGate());
|
||||
|
||||
binding.Attach();
|
||||
Assert.Equal(0, worldClicks);
|
||||
surface.Down!(MouseButton.Left, 1, 2);
|
||||
|
||||
Assert.Equal(1, worldClicks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyboardDeactivateSilencesCopiedDelegateBeforePhysicalDetach()
|
||||
{
|
||||
var surface = new KeyboardSurface();
|
||||
var root = new UiRoot();
|
||||
int worldKeys = 0;
|
||||
root.WorldKeyFallThrough += (_, _) => worldKeys++;
|
||||
var binding = new RetainedKeyboardInputBinding(
|
||||
surface,
|
||||
root,
|
||||
new HostQuiescenceGate());
|
||||
binding.Attach();
|
||||
Action<Key> copied = surface.Down!;
|
||||
|
||||
copied(Key.A);
|
||||
binding.Deactivate();
|
||||
copied(Key.B);
|
||||
binding.Dispose();
|
||||
|
||||
Assert.Equal(1, worldKeys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetainedBindingsAreTerminalAfterDisposeBeforeAttach()
|
||||
{
|
||||
var mouseSurface = new MouseSurface();
|
||||
var mouse = new RetainedMouseInputBinding(
|
||||
mouseSurface,
|
||||
new UiRoot(),
|
||||
new HostQuiescenceGate());
|
||||
var keyboardSurface = new KeyboardSurface();
|
||||
var keyboard = new RetainedKeyboardInputBinding(
|
||||
keyboardSurface,
|
||||
new UiRoot(),
|
||||
new HostQuiescenceGate());
|
||||
|
||||
mouse.Dispose();
|
||||
keyboard.Dispose();
|
||||
|
||||
Assert.Throws<ObjectDisposedException>(mouse.Attach);
|
||||
Assert.Throws<ObjectDisposedException>(keyboard.Attach);
|
||||
mouse.Dispose();
|
||||
keyboard.Dispose();
|
||||
Assert.Equal(0, mouseSurface.LiveEdges);
|
||||
Assert.Equal(0, keyboardSurface.LiveEdges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetainedBindingsRetryOnlyFailedPhysicalEdges()
|
||||
{
|
||||
var mouseSurface = new MouseSurface();
|
||||
var mouse = new RetainedMouseInputBinding(
|
||||
mouseSurface,
|
||||
new UiRoot(),
|
||||
new HostQuiescenceGate());
|
||||
var keyboardSurface = new KeyboardSurface();
|
||||
var keyboard = new RetainedKeyboardInputBinding(
|
||||
keyboardSurface,
|
||||
new UiRoot(),
|
||||
new HostQuiescenceGate());
|
||||
mouse.Attach();
|
||||
keyboard.Attach();
|
||||
mouseSurface.FailRemoveMoveOnce = true;
|
||||
keyboardSurface.FailRemoveUpOnce = true;
|
||||
|
||||
mouse.Dispose();
|
||||
keyboard.Dispose();
|
||||
int mouseRemoves = mouseSurface.RemoveCalls;
|
||||
int keyboardRemoves = keyboardSurface.RemoveCalls;
|
||||
mouse.Dispose();
|
||||
keyboard.Dispose();
|
||||
|
||||
Assert.Equal(mouseRemoves, mouseSurface.RemoveCalls);
|
||||
Assert.Equal(keyboardRemoves, keyboardSurface.RemoveCalls);
|
||||
Assert.Equal(2, mouseSurface.MoveRemoveCalls);
|
||||
Assert.Equal(2, keyboardSurface.UpRemoveCalls);
|
||||
Assert.True(mouse.IsDisposalComplete);
|
||||
Assert.True(keyboard.IsDisposalComplete);
|
||||
}
|
||||
|
||||
private sealed class MouseSurface : IRetainedMouseSurface
|
||||
{
|
||||
public int FailAdd { get; init; }
|
||||
public bool RaiseDuringAdd { get; init; }
|
||||
public bool FailRemoveMoveOnce { get; set; }
|
||||
public int AddCalls { get; private set; }
|
||||
public int RemoveCalls { get; private set; }
|
||||
public int MoveRemoveCalls { get; private set; }
|
||||
public int LiveEdges { get; private set; }
|
||||
public Action<MouseButton, int, int>? Down { get; private set; }
|
||||
private Action<MouseButton, int, int>? _up;
|
||||
private Action<int, int>? _move;
|
||||
private Action<int>? _scroll;
|
||||
|
||||
public void AddMouseDown(Action<MouseButton, int, int> callback) => Add(() => Down = callback);
|
||||
public void AddMouseUp(Action<MouseButton, int, int> callback) => Add(() => _up = callback);
|
||||
public void AddMouseMove(Action<int, int> callback) => Add(() => _move = callback);
|
||||
public void AddScroll(Action<int> callback) => Add(() => _scroll = callback);
|
||||
public void RemoveMouseDown(Action<MouseButton, int, int> callback) => Remove(() => Down = null);
|
||||
public void RemoveMouseUp(Action<MouseButton, int, int> callback) => Remove(() => _up = null);
|
||||
public void RemoveMouseMove(Action<int, int> callback)
|
||||
{
|
||||
MoveRemoveCalls++;
|
||||
if (FailRemoveMoveOnce)
|
||||
{
|
||||
FailRemoveMoveOnce = false;
|
||||
throw new InvalidOperationException("remove move");
|
||||
}
|
||||
Remove(() => _move = null);
|
||||
}
|
||||
public void RemoveScroll(Action<int> callback) => Remove(() => _scroll = null);
|
||||
|
||||
private void Add(Action publish)
|
||||
{
|
||||
AddCalls++;
|
||||
publish();
|
||||
LiveEdges++;
|
||||
if (RaiseDuringAdd)
|
||||
Down?.Invoke(MouseButton.Left, 1, 2);
|
||||
if (AddCalls == FailAdd) throw new InvalidOperationException("add");
|
||||
}
|
||||
private void Remove(Action clear)
|
||||
{
|
||||
RemoveCalls++;
|
||||
clear();
|
||||
LiveEdges--;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class KeyboardSurface : IRetainedKeyboardSurface
|
||||
{
|
||||
public int FailAdd { get; init; }
|
||||
public bool FailRemoveUpOnce { get; set; }
|
||||
public int AddCalls { get; private set; }
|
||||
public int RemoveCalls { get; private set; }
|
||||
public int UpRemoveCalls { get; private set; }
|
||||
public int LiveEdges { get; private set; }
|
||||
public Action<Key>? Down { get; private set; }
|
||||
private Action<Key>? _up;
|
||||
private Action<char>? _char;
|
||||
|
||||
public void AddKeyDown(Action<Key> callback) => Add(() => Down = callback);
|
||||
public void AddKeyUp(Action<Key> callback) => Add(() => _up = callback);
|
||||
public void AddKeyChar(Action<char> callback) => Add(() => _char = callback);
|
||||
public void RemoveKeyDown(Action<Key> callback) => Remove(() => Down = null);
|
||||
public void RemoveKeyUp(Action<Key> callback)
|
||||
{
|
||||
UpRemoveCalls++;
|
||||
if (FailRemoveUpOnce)
|
||||
{
|
||||
FailRemoveUpOnce = false;
|
||||
throw new InvalidOperationException("remove up");
|
||||
}
|
||||
Remove(() => _up = null);
|
||||
}
|
||||
public void RemoveKeyChar(Action<char> callback) => Remove(() => _char = null);
|
||||
|
||||
private void Add(Action publish)
|
||||
{
|
||||
AddCalls++;
|
||||
publish();
|
||||
LiveEdges++;
|
||||
if (AddCalls == FailAdd) throw new InvalidOperationException("add");
|
||||
}
|
||||
private void Remove(Action clear)
|
||||
{
|
||||
RemoveCalls++;
|
||||
clear();
|
||||
LiveEdges--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -39,10 +39,11 @@ public sealed class LiveObjectFrameControllerTests
|
|||
|
||||
private static SettingsVM CreateSettings()
|
||||
{
|
||||
var dispatcher = new InputDispatcher(
|
||||
var dispatcher = InputDispatcher.CreateDetached(
|
||||
new NullKeyboardSource(),
|
||||
new NullMouseSource(),
|
||||
new KeyBindings());
|
||||
dispatcher.Attach();
|
||||
return new SettingsVM(
|
||||
new KeyBindings(),
|
||||
dispatcher,
|
||||
|
|
|
|||
|
|
@ -409,13 +409,18 @@ public sealed class UpdateFrameOrchestratorTests
|
|||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("wantCaptureMouse: ()", source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Equal(2, CountOccurrences(
|
||||
source,
|
||||
"_gameplayInputFrame?.EndMouseLook();"));
|
||||
Assert.Contains(
|
||||
"new(\"mouse capture\", () => _gameplayInputFrame?.EndMouseLook())",
|
||||
Assert.DoesNotContain(
|
||||
"_gameplayInputFrame?.EndMouseLook",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
string pointerSource = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Input",
|
||||
"CameraPointerInputController.cs"));
|
||||
Assert.Contains("_gameplayFrame?.EndMouseLook();", pointerSource,
|
||||
StringComparison.Ordinal);
|
||||
string teleportSource = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
|
|
@ -438,8 +443,7 @@ public sealed class UpdateFrameOrchestratorTests
|
|||
AssertAppearsInOrder(
|
||||
source,
|
||||
"private void OnFocusChanged(bool focused)",
|
||||
"if (!focused)",
|
||||
"_gameplayInputFrame?.EndMouseLook();");
|
||||
"_cameraPointerInput?.HandleFocusChanged(focused);");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -696,7 +700,7 @@ public sealed class UpdateFrameOrchestratorTests
|
|||
"private void OnUpdate(double dt)",
|
||||
"_frameProfiler.BeginStage(",
|
||||
"_updateFrameOrchestrator.Tick(",
|
||||
"private void OnCameraModeChanged");
|
||||
"private void OnRender(double deltaSeconds)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -92,7 +92,8 @@ public class DispatcherToMovementIntegrationTests
|
|||
var kb = new FakeKb();
|
||||
var mouse = new FakeMouse();
|
||||
var bindings = KeyBindings.AcdreamCurrentDefaults();
|
||||
var dispatcher = new InputDispatcher(kb, mouse, bindings);
|
||||
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
|
||||
dispatcher.Attach();
|
||||
|
||||
kb.Press(Key.W);
|
||||
|
||||
|
|
@ -122,7 +123,8 @@ public class DispatcherToMovementIntegrationTests
|
|||
var kb = new FakeKb();
|
||||
var mouse = new FakeMouse();
|
||||
var bindings = KeyBindings.AcdreamCurrentDefaults();
|
||||
var dispatcher = new InputDispatcher(kb, mouse, bindings);
|
||||
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
|
||||
dispatcher.Attach();
|
||||
|
||||
kb.Press(Key.W);
|
||||
// Shift pressed alongside W — real keyboard delivers KeyDown(Shift,
|
||||
|
|
@ -140,7 +142,8 @@ public class DispatcherToMovementIntegrationTests
|
|||
var kb = new FakeKb();
|
||||
var mouse = new FakeMouse();
|
||||
var bindings = KeyBindings.AcdreamCurrentDefaults();
|
||||
var dispatcher = new InputDispatcher(kb, mouse, bindings);
|
||||
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
|
||||
dispatcher.Attach();
|
||||
|
||||
kb.Press(Key.W);
|
||||
Assert.True(BuildInputFromDispatcher(dispatcher).Forward);
|
||||
|
|
@ -188,7 +191,8 @@ public class DispatcherToMovementIntegrationTests
|
|||
var kb = new FakeKb();
|
||||
var mouse = new FakeMouse();
|
||||
var bindings = KeyBindings.AcdreamCurrentDefaults();
|
||||
var dispatcher = new InputDispatcher(kb, mouse, bindings);
|
||||
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
|
||||
dispatcher.Attach();
|
||||
|
||||
var input = BuildInputFromDispatcher(dispatcher);
|
||||
Assert.False(input.Forward);
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ public class InputDispatcherCaptureTests
|
|||
var kb = new FakeKeyboardSource();
|
||||
var mouse = new FakeMouseSource();
|
||||
var bindings = new KeyBindings();
|
||||
var dispatcher = new InputDispatcher(kb, mouse, bindings);
|
||||
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
|
||||
dispatcher.Attach();
|
||||
var fired = new List<(InputAction, ActivationType)>();
|
||||
dispatcher.Fired += (a, t) => fired.Add((a, t));
|
||||
return (dispatcher, kb, mouse, bindings, fired);
|
||||
|
|
|
|||
|
|
@ -33,7 +33,8 @@ public class InputDispatcherDoubleClickTests
|
|||
bindings.Add(new Binding(lmbChord, InputAction.SelectDblLeft, ActivationType.DoubleClick));
|
||||
bindings.Add(new Binding(rmbChord, InputAction.SelectRight));
|
||||
|
||||
var dispatcher = new InputDispatcher(kb, mouse, bindings);
|
||||
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
|
||||
dispatcher.Attach();
|
||||
var fired = new List<(InputAction, ActivationType)>();
|
||||
dispatcher.Fired += (a, t) => fired.Add((a, t));
|
||||
return (dispatcher, mouse, fired);
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ public class InputDispatcherIsActionHeldTests
|
|||
var kb = new FakeKeyboardSource();
|
||||
var mouse = new FakeMouseSource();
|
||||
var bindings = new KeyBindings();
|
||||
var dispatcher = new InputDispatcher(kb, mouse, bindings);
|
||||
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
|
||||
dispatcher.Attach();
|
||||
return (dispatcher, kb, mouse, bindings);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,187 @@
|
|||
using AcDream.UI.Abstractions.Input;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Tests.Input;
|
||||
|
||||
public sealed class InputDispatcherLifetimeTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
[InlineData(5)]
|
||||
public void AttachFailureRollsBackEveryPossiblyAcquiredPrefix(int failAdd)
|
||||
{
|
||||
var source = new ThrowingSources { FailAdd = failAdd };
|
||||
var dispatcher = InputDispatcher.CreateDetached(
|
||||
source,
|
||||
source,
|
||||
new KeyBindings());
|
||||
|
||||
Assert.ThrowsAny<Exception>(dispatcher.Attach);
|
||||
|
||||
Assert.True(dispatcher.IsDisposalComplete);
|
||||
Assert.Equal(failAdd, source.AddCalls);
|
||||
Assert.Equal(failAdd, source.RemoveCalls);
|
||||
Assert.Equal(0, source.LiveEdges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeactivateSilencesCopiedSourceDelegateBeforePhysicalDetach()
|
||||
{
|
||||
var source = new ThrowingSources();
|
||||
var bindings = new KeyBindings();
|
||||
bindings.Add(new Binding(
|
||||
new KeyChord(Key.W, ModifierMask.None),
|
||||
InputAction.MovementForward));
|
||||
var dispatcher = InputDispatcher.CreateDetached(source, source, bindings);
|
||||
int fired = 0;
|
||||
dispatcher.Fired += (_, _) => fired++;
|
||||
dispatcher.Attach();
|
||||
Action<Key, ModifierMask> copied = source.KeyDownDelegate!;
|
||||
|
||||
copied(Key.W, ModifierMask.None);
|
||||
dispatcher.Deactivate();
|
||||
copied(Key.W, ModifierMask.None);
|
||||
dispatcher.Dispose();
|
||||
|
||||
Assert.Equal(1, fired);
|
||||
Assert.True(dispatcher.IsDisposalComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SourceEventRaisedReentrantlyDuringAttachCannotEnterPartialDispatcher()
|
||||
{
|
||||
var source = new ThrowingSources { RaiseDuringKeyDownAdd = true };
|
||||
var bindings = new KeyBindings();
|
||||
bindings.Add(new Binding(
|
||||
new KeyChord(Key.W, ModifierMask.None),
|
||||
InputAction.MovementForward));
|
||||
var dispatcher = InputDispatcher.CreateDetached(source, source, bindings);
|
||||
int fired = 0;
|
||||
dispatcher.Fired += (_, _) => fired++;
|
||||
|
||||
dispatcher.Attach();
|
||||
Assert.Equal(0, fired);
|
||||
source.KeyDownDelegate!(Key.W, ModifierMask.None);
|
||||
|
||||
Assert.Equal(1, fired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedDetachRetainsOnlyPendingSourceForRetry()
|
||||
{
|
||||
var source = new ThrowingSources();
|
||||
var dispatcher = InputDispatcher.CreateDetached(
|
||||
source,
|
||||
source,
|
||||
new KeyBindings());
|
||||
dispatcher.Attach();
|
||||
source.FailRemoveKeyDown = true;
|
||||
|
||||
Assert.Throws<AggregateException>(dispatcher.Dispose);
|
||||
int completedRemoves = source.RemoveCalls - source.KeyDownRemoveCalls;
|
||||
source.FailRemoveKeyDown = false;
|
||||
dispatcher.Dispose();
|
||||
|
||||
Assert.Equal(completedRemoves, source.RemoveCalls - source.KeyDownRemoveCalls);
|
||||
Assert.True(dispatcher.IsDisposalComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisposeBeforeAttachMakesDispatcherTerminal()
|
||||
{
|
||||
var source = new ThrowingSources();
|
||||
var dispatcher = InputDispatcher.CreateDetached(
|
||||
source,
|
||||
source,
|
||||
new KeyBindings());
|
||||
|
||||
dispatcher.Dispose();
|
||||
|
||||
Assert.Throws<ObjectDisposedException>(dispatcher.Attach);
|
||||
dispatcher.Dispose();
|
||||
Assert.Equal(0, source.AddCalls);
|
||||
Assert.Equal(0, source.LiveEdges);
|
||||
}
|
||||
|
||||
private sealed class ThrowingSources : IKeyboardSource, IMouseSource
|
||||
{
|
||||
private Action<Key, ModifierMask>? _keyDown;
|
||||
private Action<Key, ModifierMask>? _keyUp;
|
||||
private Action<MouseButton, ModifierMask>? _mouseDown;
|
||||
private Action<MouseButton, ModifierMask>? _mouseUp;
|
||||
private Action<float>? _scroll;
|
||||
|
||||
public int FailAdd { get; init; }
|
||||
public bool FailRemoveKeyDown { get; set; }
|
||||
public bool RaiseDuringKeyDownAdd { get; init; }
|
||||
public int AddCalls { get; private set; }
|
||||
public int RemoveCalls { get; private set; }
|
||||
public int KeyDownRemoveCalls { get; private set; }
|
||||
public int LiveEdges { get; private set; }
|
||||
public Action<Key, ModifierMask>? KeyDownDelegate => _keyDown;
|
||||
|
||||
public event Action<Key, ModifierMask>? KeyDown
|
||||
{
|
||||
add
|
||||
{
|
||||
Add(ref _keyDown, value!);
|
||||
if (RaiseDuringKeyDownAdd)
|
||||
value?.Invoke(Key.W, ModifierMask.None);
|
||||
}
|
||||
remove
|
||||
{
|
||||
KeyDownRemoveCalls++;
|
||||
if (FailRemoveKeyDown) throw new InvalidOperationException("remove key down");
|
||||
Remove(ref _keyDown, value!);
|
||||
}
|
||||
}
|
||||
public event Action<Key, ModifierMask>? KeyUp
|
||||
{
|
||||
add => Add(ref _keyUp, value!);
|
||||
remove => Remove(ref _keyUp, value!);
|
||||
}
|
||||
public event Action<MouseButton, ModifierMask>? MouseDown
|
||||
{
|
||||
add => Add(ref _mouseDown, value!);
|
||||
remove => Remove(ref _mouseDown, value!);
|
||||
}
|
||||
public event Action<MouseButton, ModifierMask>? MouseUp
|
||||
{
|
||||
add => Add(ref _mouseUp, value!);
|
||||
remove => Remove(ref _mouseUp, value!);
|
||||
}
|
||||
#pragma warning disable CS0067
|
||||
public event Action<float, float>? MouseMove;
|
||||
#pragma warning restore CS0067
|
||||
public event Action<float>? Scroll
|
||||
{
|
||||
add => Add(ref _scroll, value!);
|
||||
remove => Remove(ref _scroll, value!);
|
||||
}
|
||||
|
||||
public ModifierMask CurrentModifiers => ModifierMask.None;
|
||||
public bool WantCaptureMouse => false;
|
||||
public bool WantCaptureKeyboard => false;
|
||||
public bool IsHeld(Key key) => false;
|
||||
public bool IsHeld(MouseButton button) => false;
|
||||
|
||||
private void Add<T>(ref T? slot, T value) where T : Delegate
|
||||
{
|
||||
AddCalls++;
|
||||
slot = (T?)Delegate.Combine(slot, value);
|
||||
LiveEdges++;
|
||||
if (AddCalls == FailAdd) throw new InvalidOperationException("add");
|
||||
}
|
||||
|
||||
private void Remove<T>(ref T? slot, T value) where T : Delegate
|
||||
{
|
||||
RemoveCalls++;
|
||||
if (slot is null) return;
|
||||
slot = (T?)Delegate.Remove(slot, value);
|
||||
LiveEdges--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,8 @@ public class InputDispatcherTests
|
|||
var kb = new FakeKeyboardSource();
|
||||
var mouse = new FakeMouseSource();
|
||||
var bindings = new KeyBindings();
|
||||
var dispatcher = new InputDispatcher(kb, mouse, bindings);
|
||||
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
|
||||
dispatcher.Attach();
|
||||
var fired = new List<(InputAction, ActivationType)>();
|
||||
dispatcher.Fired += (a, t) => fired.Add((a, t));
|
||||
return (dispatcher, kb, mouse, bindings, fired);
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ public sealed class SettingsPanelTests
|
|||
var persisted = new KeyBindings();
|
||||
persisted.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
|
||||
persisted.Add(new Binding(new KeyChord(Key.A, ModifierMask.None), InputAction.MovementTurnLeft));
|
||||
var dispatcher = new InputDispatcher(kb, mouse, persisted);
|
||||
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, persisted);
|
||||
dispatcher.Attach();
|
||||
var vm = new SettingsVM(
|
||||
persisted, dispatcher, _ => { },
|
||||
DisplaySettings.Default, _ => { },
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@ public sealed class SettingsVMTests
|
|||
persisted ??= MakeMinimalBindings();
|
||||
var kb = new FakeKeyboardSource();
|
||||
var mouse = new FakeMouseSource();
|
||||
var dispatcher = new InputDispatcher(kb, mouse, persisted);
|
||||
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, persisted);
|
||||
dispatcher.Attach();
|
||||
var savedHistory = new System.Collections.Generic.List<KeyBindings>();
|
||||
var savedDisplayHistory = new System.Collections.Generic.List<DisplaySettings>();
|
||||
var savedAudioHistory = new System.Collections.Generic.List<AudioSettings>();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue