namespace XFEExtension.NetCore.InputSimulator.Tests; [TestFixture] public class ControllerTests { private static SentInput Key(ScanCode key, bool up = false) => new("Key", (int)key, up); [Test] public void CombinationPressesInOrderAndReleasesInReverse() { var backend = new RecordingBackend(); using var input = new InputController(backend); input.PressCombination([ScanCode.LeftControl, ScanCode.LeftShift, ScanCode.S], 0); Assert.Equal>(new[] { Key(ScanCode.LeftControl), Key(ScanCode.LeftShift), Key(ScanCode.S), Key(ScanCode.S, true), Key(ScanCode.LeftShift, true), Key(ScanCode.LeftControl, true) }, backend.Events); } [Test] public void InvalidCombinationIsRejectedBeforeAnyKeyIsPressed() { var backend = new RecordingBackend(); using var input = new InputController(backend); Assert.IsType(Assert.Throws(() => input.PressCombination([ScanCode.LeftControl, (ScanCode)0], 0))); Assert.IsType(Assert.Throws(() => input.PressCombination([], 0))); Assert.IsType(Assert.Throws(() => input.PressKey(ScanCode.W, -1))); Assert.Empty(backend.Events); } [Test] public void LowerCaseCharDoesNotBecomeANumpadVirtualKey() { var backend = new RecordingBackend(); using var input = new InputController(backend); input.PressKey('w', 0); input.PressKey('W', 0); Assert.Equal>(new[] { Key(ScanCode.W), Key(ScanCode.W, true), Key(ScanCode.W), Key(ScanCode.W, true) }, backend.Events); Assert.IsType(Assert.Throws(() => input.PressKey('中', 0))); } [Test] public void RepeatedManualDownAndNestedHoldsPreserveOwnership() { var backend = new RecordingBackend(); using var input = new InputController(backend); input.KeyDown(ScanCode.LeftControl); input.KeyDown(ScanCode.LeftControl); using (input.HoldKey(ScanCode.LeftControl)) { input.PressCombination([ScanCode.LeftControl, ScanCode.C, ScanCode.C], 0); } Assert.Equal>(new[] { Key(ScanCode.LeftControl), Key(ScanCode.C), Key(ScanCode.C, true) }, backend.Events); input.KeyUp(ScanCode.LeftControl); Assert.Equal(Key(ScanCode.LeftControl, true), backend.Events[^1]); } [Test] public void OverlappingHoldsReleaseOnlyWhenLastOwnerFinishes() { var backend = new RecordingBackend(); using var input = new InputController(backend); var first = input.HoldKey(ScanCode.W); var second = input.HoldKey(ScanCode.W); first.Dispose(); first.Dispose(); Assert.Single(backend.Events); second.Dispose(); Assert.Equal>(new[] { Key(ScanCode.W), Key(ScanCode.W, true) }, backend.Events); } [Test] public void OldScopeCannotReleaseAKeyPressedAfterReleaseAll() { var backend = new RecordingBackend(); using var input = new InputController(backend); var old = input.HoldKey(ScanCode.W); input.ReleaseAll(); var current = input.HoldKey(ScanCode.W); old.Dispose(); Assert.Equal(3, backend.Events.Count); current.Dispose(); Assert.Equal(Key(ScanCode.W, true), backend.Events[^1]); } [Test] public async Task CancellingACombinationReleasesAllKeys() { var backend = new RecordingBackend(); using var input = new InputController(backend); using var cancellation = new CancellationTokenSource(); var task = input.PressCombinationAsync([ScanCode.LeftShift, ScanCode.W], 10000, cancellation.Token); Assert.Equal(2, backend.Events.Count); cancellation.Cancel(); await Assert.ThrowsAsync(() => task); Assert.Equal>(new[] { Key(ScanCode.LeftShift), Key(ScanCode.W), Key(ScanCode.W, true), Key(ScanCode.LeftShift, true) }, backend.Events); } [Test] public async Task AlreadyCancelledAndInvalidDelaysNeverInject() { var backend = new RecordingBackend(); using var input = new InputController(backend); using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); await Assert.ThrowsAsync(() => input.PressKeyAsync(ScanCode.W, 1, cancellation.Token)); await Assert.ThrowsAsync(() => input.TypeTextAsync("abc", 0, cancellation.Token)); Assert.IsType(await Assert.ThrowsAsync(() => input.PressKeyAsync(ScanCode.W, -1))); Assert.IsType(await Assert.ThrowsAsync(() => input.MouseClickAsync(MouseButton.Left, -1))); Assert.IsType(await Assert.ThrowsAsync(() => input.InputKeysAsync("abc", 0, -1))); Assert.Empty(backend.Events); } [Test] public void FailedDownInCombinationReleasesEarlierModifiers() { var backend = new RecordingBackend { BeforeSend = input => { if (input == Key(ScanCode.S)) throw new IOException("Sending S failed"); }}; using var input = new InputController(backend); Assert.IsType(Assert.Throws(() => input.PressCombination([ScanCode.LeftControl, ScanCode.S], 0))); Assert.Equal>(new[] { Key(ScanCode.LeftControl), Key(ScanCode.LeftControl, true) }, backend.Events); } [Test] public void FailedReleaseDoesNotSkipOtherModifiersAndCanBeRetried() { var backend = new RecordingBackend { BeforeSend = input => { if (input == Key(ScanCode.S, true)) throw new IOException("Releasing S failed"); }}; using var input = new InputController(backend); Assert.IsType(Assert.Throws(() => input.PressCombination([ScanCode.LeftControl, ScanCode.S], 0))); Assert.Equal(Key(ScanCode.LeftControl, true), backend.Events[^1]); backend.BeforeSend = null; input.ReleaseAll(); Assert.Equal(Key(ScanCode.S, true), backend.Events[^1]); } [Test] public void ReleaseAllContinuesAfterFailureAndRetainsOnlyFailedKeys() { var backend = new RecordingBackend(); using var input = new InputController(backend); input.KeyDown(ScanCode.LeftShift); input.KeyDown(ScanCode.W); input.MouseDown(MouseButton.Left); backend.BeforeSend = sent => { if (sent == Key(ScanCode.W, true)) throw new IOException(); }; Assert.IsType(Assert.Throws(() => input.ReleaseAll())); Assert.Equal>(new[] { new SentInput("Button", (int)MouseButton.Left, true), Key(ScanCode.LeftShift, true) }, backend.Events.Skip(3)); backend.BeforeSend = null; input.ReleaseAll(); Assert.Equal(Key(ScanCode.W, true), backend.Events[^1]); var count = backend.Events.Count; input.ReleaseAll(); Assert.Equal(count, backend.Events.Count); } [Test] public async Task CancellingMouseClickReleasesSideButton() { var backend = new RecordingBackend(); using var input = new InputController(backend); using var cancellation = new CancellationTokenSource(); var task = input.MouseClickAsync(MouseButton.XButton2, 10000, cancellation.Token); cancellation.Cancel(); await Assert.ThrowsAsync(() => task); Assert.Equal>(new[] { new SentInput("Button", 4), new SentInput("Button", 4, true) }, backend.Events); } [Test] public void UnicodePreservesCaseChineseAndSurrogatePairs() { var backend = new RecordingBackend(); using var input = new InputController(backend); const string text = "aA中🙂"; input.TypeText(text); Assert.Equal>(text.SelectMany(c => new[] { new SentInput("Text", c), new SentInput("Text", c, true) }), backend.Events); } [Test] public async Task CancellationDoesNotSplitASurrogatePair() { using var cancellation = new CancellationTokenSource(); var backend = new RecordingBackend { BeforeSend = sent => { if (sent.Kind == "Text" && char.IsHighSurrogate((char)sent.Code) && sent.Up) cancellation.Cancel(); }}; using var input = new InputController(backend); await Assert.ThrowsAsync(() => input.TypeTextAsync("🙂x", 1, cancellation.Token)); Assert.Equal>("🙂".SelectMany(c => new[] { new SentInput("Text", c), new SentInput("Text", c, true) }), backend.Events); } [Test] public void UnsupportedTextDoesNotFallBackToAnotherBackend() { var backend = new RecordingBackend { SupportsUnicodeText = false }; using var input = new InputController(backend); Assert.IsType(Assert.Throws(() => input.TypeText("test"))); Assert.Empty(backend.Events); } [Test] public void DisposeReleasesInReverseAndInvalidatesOutstandingScopes() { var backend = new RecordingBackend(); var input = new InputController(backend); var scope = input.HoldKey(ScanCode.W); input.MouseDown(MouseButton.Right); input.Dispose(); scope.Dispose(); input.Dispose(); Assert.Equal>(new[] { Key(ScanCode.W), new SentInput("Button", 1), new SentInput("Button", 1, true), Key(ScanCode.W, true) }, backend.Events); Assert.True(backend.Disposed); Assert.IsType(Assert.Throws(() => input.KeyDown(ScanCode.W))); Assert.IsType(Assert.Throws(() => input.TypeText(""))); } [Test] public void DisposeStillClosesBackendWhenReleaseFails() { var backend = new RecordingBackend(); var input = new InputController(backend); var scope = input.HoldKey(ScanCode.W); backend.BeforeSend = sent => { if (sent.Up) throw new IOException(); }; Assert.IsType(Assert.Throws(() => input.Dispose())); Assert.True(backend.Disposed); scope.Dispose(); } [Test] public void CustomBackendReceivesMouseCoordinatesAndSignedWheelDeltas() { var backend = new RecordingBackend(); using var input = new InputController(backend); input.MouseMove(-20, 40); input.LocateToNormalized(65535, 0); input.MouseWheelRoll(-120); input.MouseHorizontalWheelRoll(240); Assert.Equal>(new[] { new SentInput("Move", X: -20, Y: 40), new SentInput("Move", X: 65535, Y: 0, Absolute: true, VirtualDesktop: true), new SentInput("Wheel", -120), new SentInput("HWheel", 240) }, backend.Events); } [Test] public async Task CancellationAndReleaseFailureAreBothReported() { var backend = new RecordingBackend(); using var input = new InputController(backend); using var cancellation = new CancellationTokenSource(); var task = input.PressKeyAsync(ScanCode.W, 10000, cancellation.Token); backend.BeforeSend = sent => { if (sent.Up) throw new IOException("release failed"); }; cancellation.Cancel(); var error = Assert.IsType(await Assert.ThrowsAsync(() => task)); var failures = error.Flatten().InnerExceptions; Assert.True(failures.Any(e => e is OperationCanceledException), "Expected an exception of type OperationCanceledException."); Assert.True(failures.Any(e => e is IOException), "Expected an exception of type IOException."); backend.BeforeSend = null; input.ReleaseAll(); Assert.Equal(new SentInput("Key", (int)ScanCode.W, true), backend.Events[^1]); } [Test] public void DisposeReportsBothReleaseAndBackendErrors() { var backend = new RecordingBackend { OnDispose = () => throw new InvalidOperationException("backend cleanup failed") }; var input = new InputController(backend); input.KeyDown(ScanCode.W); backend.BeforeSend = sent => { if (sent.Up) throw new IOException("release failed"); }; var error = Assert.IsType(Assert.Throws(() => input.Dispose())); var failures = error.Flatten().InnerExceptions; Assert.True(failures.Any(e => e is IOException), "Expected an exception of type IOException."); Assert.True(failures.Any(e => e is InvalidOperationException), "Expected an exception of type InvalidOperationException."); Assert.True(backend.Disposed); input.Dispose(); } }