acdream/tests/AcDream.App.Tests/Input/QuiescentInputContextTests.cs
Erik 8b8afeefa3 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.
2026-07-22 11:59:33 +02:00

344 lines
12 KiB
C#

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}."),
};
}
}