From 7f4b780565ed0fc2f86bfbe1fa09d70204556f74 Mon Sep 17 00:00:00 2001 From: Steffen Carlsen Date: Fri, 5 Jun 2026 14:23:05 +0200 Subject: [PATCH] Add event contract coverage --- Lessons.md | 1 + src/ModernOverlay/Events.cs | 26 ++- src/ModernOverlay/OverlayWindow.cs | 3 +- .../OverlayUiButtonControlTests.cs | 3 + .../OverlayUiCommandTests.cs | 9 + .../OverlayUiElementModelTests.cs | 18 ++ .../OverlayUiFocusKeyboardTests.cs | 39 +++++ .../OverlayUiInputRoutingTests.cs | 70 ++++++-- .../OverlayUiRangeNumericTests.cs | 3 + .../OverlayWindowThreadingTests.cs | 164 +++++++++++++++++- .../TargetTrackingTests.cs | 41 ++++- 11 files changed, 349 insertions(+), 28 deletions(-) diff --git a/Lessons.md b/Lessons.md index 6011e5c..2f9c2eb 100644 --- a/Lessons.md +++ b/Lessons.md @@ -22,6 +22,7 @@ - Treat screenshot-reported UI precision bugs as shared geometry suspects first. Hit testing, caret placement, slider bounds, and popup placement usually belong in shared measurement, transform, bounds, or z-layer code rather than one-off component fixes. - Keep the UI A/B sample useful as a validation tool, not just a showcase. Add visible state, labels, and layout previews when controls otherwise look inert or ambiguous. - For retained text input, caret, selection, and scrolling should share measured text advances. Any fallback heuristic must be treated as a temporary approximation and tested against proportional text. +- For keyboard event contracts, treat Win32 `WasDown` as an auto-repeat signal only on key-down events. Key-up messages normally report the key was previously down and should not make `IsRepeat` true. ## PR Review And Triage diff --git a/src/ModernOverlay/Events.cs b/src/ModernOverlay/Events.cs index 12e9a9c..b47d01f 100644 --- a/src/ModernOverlay/Events.cs +++ b/src/ModernOverlay/Events.cs @@ -129,6 +129,29 @@ public OverlayKeyboardEventArgs( bool wasDown, bool isTransitionState, OverlayModifierKeys modifiers) + : this( + virtualKey, + isSystemKey, + repeatCount, + scanCode, + isExtendedKey, + wasDown, + isTransitionState, + modifiers, + wasDown || repeatCount > 1) + { + } + + internal OverlayKeyboardEventArgs( + int virtualKey, + bool isSystemKey, + int repeatCount, + int scanCode, + bool isExtendedKey, + bool wasDown, + bool isTransitionState, + OverlayModifierKeys modifiers, + bool isRepeat) { VirtualKey = virtualKey; IsSystemKey = isSystemKey; @@ -138,6 +161,7 @@ public OverlayKeyboardEventArgs( WasDown = wasDown; IsTransitionState = isTransitionState; Modifiers = modifiers; + IsRepeat = isRepeat; } public int VirtualKey { get; } @@ -154,7 +178,7 @@ public OverlayKeyboardEventArgs( public bool IsTransitionState { get; } - public bool IsRepeat => WasDown || RepeatCount > 1; + public bool IsRepeat { get; } public OverlayModifierKeys Modifiers { get; } } diff --git a/src/ModernOverlay/OverlayWindow.cs b/src/ModernOverlay/OverlayWindow.cs index 59646a0..54b47db 100644 --- a/src/ModernOverlay/OverlayWindow.cs +++ b/src/ModernOverlay/OverlayWindow.cs @@ -865,7 +865,8 @@ private void HandleKeyboardEvent(Win32KeyboardEvent keyboardEvent) keyboardEvent.IsExtendedKey, keyboardEvent.WasDown, keyboardEvent.IsTransitionState, - modifiers); + modifiers, + keyboardEvent.IsPressed && (keyboardEvent.WasDown || keyboardEvent.RepeatCount > 1)); if (keyboardEvent.IsPressed) { diff --git a/tests/ModernOverlay.Tests/OverlayUiButtonControlTests.cs b/tests/ModernOverlay.Tests/OverlayUiButtonControlTests.cs index c474ccf..4fb841b 100644 --- a/tests/ModernOverlay.Tests/OverlayUiButtonControlTests.cs +++ b/tests/ModernOverlay.Tests/OverlayUiButtonControlTests.cs @@ -50,8 +50,10 @@ public async Task ToggleButtonTogglesByPointerKeyboardCommandAndSkipsDisabledInp using OverlayUiRoot ui = OverlayUi.Attach(overlay, new OverlayUiOptions { RegisterInputRegions = false }); ToggleButton toggle = CreateButton("Toggle", 10f, 10f); int checkedChanges = 0; + int checkStateChanges = 0; int commandCalls = 0; toggle.CheckedChanged += (_, _) => checkedChanges++; + toggle.CheckStateChanged += (_, _) => checkStateChanges++; toggle.Command = new UiCommand(_ => commandCalls++); ui.Root.Children.Add(toggle); ui.Render(new DrawContext()); @@ -69,6 +71,7 @@ public async Task ToggleButtonTogglesByPointerKeyboardCommandAndSkipsDisabledInp Assert.IsFalse(toggle.IsChecked); Assert.AreEqual(2, checkedChanges); + Assert.AreEqual(2, checkStateChanges); Assert.AreEqual(2, commandCalls); } diff --git a/tests/ModernOverlay.Tests/OverlayUiCommandTests.cs b/tests/ModernOverlay.Tests/OverlayUiCommandTests.cs index 78ba76d..c5435c9 100644 --- a/tests/ModernOverlay.Tests/OverlayUiCommandTests.cs +++ b/tests/ModernOverlay.Tests/OverlayUiCommandTests.cs @@ -16,15 +16,24 @@ public void UiCommandPassesParametersAndSkipsExecutionWhenUnavailable() object? executedParameter = null; bool canExecute = false; UiCommand command = new(parameter => executedParameter = parameter, _ => canExecute); + int canExecuteChanges = 0; + command.CanExecuteChanged += (sender, args) => + { + Assert.AreSame(command, sender); + Assert.AreSame(EventArgs.Empty, args); + canExecuteChanges++; + }; command.Execute("blocked"); Assert.IsNull(executedParameter); canExecute = true; + command.RaiseCanExecuteChanged(); command.Execute("allowed"); Assert.AreEqual("allowed", executedParameter); Assert.IsTrue(command.CanExecute("allowed")); + Assert.AreEqual(1, canExecuteChanges); } [TestMethod] diff --git a/tests/ModernOverlay.Tests/OverlayUiElementModelTests.cs b/tests/ModernOverlay.Tests/OverlayUiElementModelTests.cs index d446f70..0513896 100644 --- a/tests/ModernOverlay.Tests/OverlayUiElementModelTests.cs +++ b/tests/ModernOverlay.Tests/OverlayUiElementModelTests.cs @@ -17,13 +17,29 @@ public async Task AddingAndRemovingChildUpdatesParentRootAndLifecycleEvents() await using OverlayWindow overlay = await CreateOverlayAsync(); using OverlayUiRoot ui = OverlayUi.Attach(overlay, new OverlayUiOptions { RegisterInputRegions = false }); var child = new LifecycleElement(); + int attachedEvents = 0; + int detachedEvents = 0; + child.Attached += (sender, args) => + { + Assert.AreSame(child, sender); + Assert.AreSame(EventArgs.Empty, args); + attachedEvents++; + }; + child.Detached += (sender, args) => + { + Assert.AreSame(child, sender); + Assert.AreSame(EventArgs.Empty, args); + detachedEvents++; + }; ui.Root.Children.Add(child); Assert.AreSame(ui.Root, child.Parent); Assert.AreSame(ui, child.Root); Assert.AreEqual(1, child.AttachedCount); + Assert.AreEqual(1, attachedEvents); Assert.AreEqual(0, child.DetachedCount); + Assert.AreEqual(0, detachedEvents); Assert.AreEqual(2, ui.Metrics.ElementCount); Assert.IsTrue(ui.Root.Children.Remove(child)); @@ -31,7 +47,9 @@ public async Task AddingAndRemovingChildUpdatesParentRootAndLifecycleEvents() Assert.IsNull(child.Parent); Assert.IsNull(child.Root); Assert.AreEqual(1, child.AttachedCount); + Assert.AreEqual(1, attachedEvents); Assert.AreEqual(1, child.DetachedCount); + Assert.AreEqual(1, detachedEvents); Assert.AreEqual(1, ui.Metrics.ElementCount); } diff --git a/tests/ModernOverlay.Tests/OverlayUiFocusKeyboardTests.cs b/tests/ModernOverlay.Tests/OverlayUiFocusKeyboardTests.cs index 74e7439..65c145a 100644 --- a/tests/ModernOverlay.Tests/OverlayUiFocusKeyboardTests.cs +++ b/tests/ModernOverlay.Tests/OverlayUiFocusKeyboardTests.cs @@ -17,6 +17,7 @@ public sealed class OverlayUiFocusKeyboardTests "parent-down:Bubble:True:Control", "child-up:Direct", ]; + private static readonly string[] ExpectedTextInputRoute = ["child:Direct:Å"]; [TestMethod] [TestCategory("WindowsIntegration")] @@ -140,6 +141,37 @@ public async Task KeyboardEventsRouteFromFocusedElementAndRespectHandled() CollectionAssert.AreEqual(ExpectedKeyboardRoute, route); } + [TestMethod] + [TestCategory("WindowsIntegration")] + public async Task TextInputEventsRouteFromFocusedElementAndRespectHandled() + { + await using OverlayWindow overlay = await CreateOverlayAsync(); + using OverlayUiRoot ui = OverlayUi.Attach(overlay, new OverlayUiOptions { RegisterInputRegions = false }); + Canvas parent = new() + { + Width = 120f, + Height = 80f, + }; + ProbeElement child = CreateFocusableElement(10f, 10f, 40f, 20f); + List route = []; + child.TextInput += (_, args) => + { + Assert.AreSame(child, args.OriginalSource); + Assert.AreSame(child, args.Source); + Assert.AreEqual("Å", args.Text); + route.Add($"child:{args.RoutePhase}:{args.Text}"); + args.Handled = true; + }; + parent.TextInput += (_, _) => route.Add("parent"); + parent.Children.Add(child); + ui.Root.Children.Add(parent); + + child.Focus(); + DispatchText(overlay, "Å"); + + CollectionAssert.AreEqual(ExpectedTextInputRoute, route); + } + private static async ValueTask CreateOverlayAsync() => await OverlayWindow.CreateAsync(new OverlayWindowOptions { @@ -184,6 +216,13 @@ private static void DispatchKey( method.Invoke(overlay, [new Win32KeyboardEvent(virtualKey, pressed, false, repeatCount, 0, false, wasDown, !pressed, modifiers)]); } + private static void DispatchText(OverlayWindow overlay, string text) + { + MethodInfo method = typeof(OverlayWindow).GetMethod("HandleTextInputEvent", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new MissingMethodException(nameof(OverlayWindow), "HandleTextInputEvent"); + method.Invoke(overlay, [new Win32TextInputEvent(text, false)]); + } + private sealed class ProbeElement : UiElement { } diff --git a/tests/ModernOverlay.Tests/OverlayUiInputRoutingTests.cs b/tests/ModernOverlay.Tests/OverlayUiInputRoutingTests.cs index a0a125f..47becef 100644 --- a/tests/ModernOverlay.Tests/OverlayUiInputRoutingTests.cs +++ b/tests/ModernOverlay.Tests/OverlayUiInputRoutingTests.cs @@ -25,11 +25,22 @@ public async Task PointerEventsRouteDirectThenBubble() }; ProbeElement child = CreateInputElement(20f, 20f, 30f, 30f); List route = []; - child.PointerPressed += (_, args) => route.Add($"child:{args.RoutePhase}"); + child.PointerPressed += (_, args) => + { + Assert.AreSame(child, args.OriginalSource); + Assert.AreSame(child, args.Source); + Assert.AreEqual(OverlayPointerEventKind.Pressed, args.Kind); + Assert.AreEqual(OverlayPointerButton.Left, args.Button); + Assert.AreEqual(new PointF(25f, 25f), args.Position); + route.Add($"child:{args.RoutePhase}"); + }; parent.PointerPressed += (_, args) => { Assert.AreSame(child, args.OriginalSource); Assert.AreSame(parent, args.Source); + Assert.AreEqual(OverlayPointerEventKind.Pressed, args.Kind); + Assert.AreEqual(OverlayPointerButton.Left, args.Button); + Assert.AreEqual(new PointF(25f, 25f), args.Position); route.Add($"parent:{args.RoutePhase}"); }; parent.Children.Add(child); @@ -81,17 +92,31 @@ public async Task PointerEnterLeaveClickDoubleClickAndWheelAreTranslated() Canvas.SetTop(button, 10f); int enterCount = 0; int exitCount = 0; - int wheelDelta = 0; - bool horizontalWheel = false; - List clickCounts = []; - button.PointerEntered += (_, _) => enterCount++; - button.PointerExited += (_, _) => exitCount++; + UiPointerEventArgs? entered = null; + UiPointerEventArgs? exited = null; + UiPointerEventArgs? wheel = null; + UiElement? wheelOriginalSource = null; + UiElement? wheelSource = null; + UiRoutedEventPhase? wheelRoutePhase = null; + List clicks = []; + button.PointerEntered += (_, args) => + { + enterCount++; + entered = args; + }; + button.PointerExited += (_, args) => + { + exitCount++; + exited = args; + }; button.PointerWheel += (_, args) => { - wheelDelta = args.WheelDelta; - horizontalWheel = args.IsHorizontalWheel; + wheel = args; + wheelOriginalSource = args.OriginalSource; + wheelSource = args.Source; + wheelRoutePhase = args.RoutePhase; }; - button.Click += (_, args) => clickCounts.Add(args.ClickCount); + button.Click += (_, args) => clicks.Add(args); ui.Root.Children.Add(button); ui.Render(new DrawContext()); @@ -103,9 +128,30 @@ public async Task PointerEnterLeaveClickDoubleClickAndWheelAreTranslated() Assert.AreEqual(1, enterCount); Assert.AreEqual(1, exitCount); - CollectionAssert.AreEqual(ExpectedClickCounts, clickCounts); - Assert.AreEqual(120, wheelDelta); - Assert.IsTrue(horizontalWheel); + Assert.IsNotNull(entered); + Assert.AreEqual(OverlayPointerEventKind.Moved, entered!.Kind); + Assert.AreEqual(OverlayPointerButton.None, entered.Button); + Assert.AreEqual(new PointF(20f, 20f), entered.Position); + Assert.IsNotNull(exited); + Assert.AreEqual(OverlayPointerEventKind.Moved, exited!.Kind); + Assert.AreEqual(OverlayPointerButton.None, exited.Button); + Assert.AreEqual(new PointF(150f, 100f), exited.Position); + CollectionAssert.AreEqual(ExpectedClickCounts, clicks.Select(click => click.ClickCount).ToArray()); + Assert.AreEqual(new PointF(20f, 20f), clicks[0].Position); + Assert.AreEqual(OverlayPointerButton.Left, clicks[0].Button); + Assert.IsFalse(clicks[0].IsDoubleClick); + Assert.AreEqual(new PointF(20f, 20f), clicks[1].Position); + Assert.AreEqual(OverlayPointerButton.Left, clicks[1].Button); + Assert.IsTrue(clicks[1].IsDoubleClick); + Assert.IsNotNull(wheel); + Assert.AreEqual(OverlayPointerEventKind.Wheel, wheel!.Kind); + Assert.AreEqual(OverlayPointerButton.None, wheel.Button); + Assert.AreEqual(new PointF(20f, 20f), wheel.Position); + Assert.AreSame(button, wheelOriginalSource); + Assert.AreSame(button, wheelSource); + Assert.AreEqual(UiRoutedEventPhase.Direct, wheelRoutePhase); + Assert.AreEqual(120, wheel.WheelDelta); + Assert.IsTrue(wheel.IsHorizontalWheel); } [TestMethod] diff --git a/tests/ModernOverlay.Tests/OverlayUiRangeNumericTests.cs b/tests/ModernOverlay.Tests/OverlayUiRangeNumericTests.cs index f794b03..6e9c8f9 100644 --- a/tests/ModernOverlay.Tests/OverlayUiRangeNumericTests.cs +++ b/tests/ModernOverlay.Tests/OverlayUiRangeNumericTests.cs @@ -35,6 +35,8 @@ public async Task SliderClampsDragsCapturesTracksKeyboardDisablesAndRenders() }; Canvas.SetLeft(slider, 10f); Canvas.SetTop(slider, 10f); + int valueChanges = 0; + slider.ValueChanged += (_, _) => valueChanges++; ui.Root.Children.Add(slider); ui.Render(new DrawContext()); @@ -70,6 +72,7 @@ public async Task SliderClampsDragsCapturesTracksKeyboardDisablesAndRenders() DispatchPointer(overlay, Win32PointerEventKind.Pressed, Win32PointerButton.Left, 60, 20); DispatchKey(overlay, VirtualKeyLeft); Assert.AreEqual(30f, slider.Value); + Assert.AreEqual(6, valueChanges); } [TestMethod] diff --git a/tests/ModernOverlay.Tests/OverlayWindowThreadingTests.cs b/tests/ModernOverlay.Tests/OverlayWindowThreadingTests.cs index 8876d0a..d3b1531 100644 --- a/tests/ModernOverlay.Tests/OverlayWindowThreadingTests.cs +++ b/tests/ModernOverlay.Tests/OverlayWindowThreadingTests.cs @@ -1,3 +1,4 @@ +using System.Reflection; using System.Runtime.InteropServices; using ModernOverlay.Rendering; using ModernOverlay.Win32; @@ -386,16 +387,16 @@ public async Task ShowHideMoveAndResizeUpdateNativeWindowAndRaiseEvents() { var initialBounds = new WindowBounds(30, 40, 180, 100); var movedBounds = new WindowBounds(70, 90, 240, 140); - int visibilityChanges = 0; - int boundsChanges = 0; + List visibilityBounds = []; + List boundsEvents = []; await using OverlayWindow overlay = await OverlayWindow.CreateAsync(new OverlayWindowOptions { Bounds = initialBounds, IsVisible = false, }); - overlay.VisibilityChanged += (_, _) => visibilityChanges++; - overlay.BoundsChanged += (_, _) => boundsChanges++; + overlay.VisibilityChanged += (_, args) => visibilityBounds.Add(args.Bounds); + overlay.BoundsChanged += (_, args) => boundsEvents.Add(args.Bounds); Assert.IsFalse(Win32WindowQuery.IsVisible(overlay.Hwnd.Value)); @@ -413,8 +414,36 @@ public async Task ShowHideMoveAndResizeUpdateNativeWindowAndRaiseEvents() Assert.AreEqual(movedBounds.Y, nativeBounds.Y); Assert.AreEqual(movedBounds.Width, nativeBounds.Width); Assert.AreEqual(movedBounds.Height, nativeBounds.Height); - Assert.AreEqual(2, visibilityChanges); - Assert.AreEqual(2, boundsChanges); + CollectionAssert.AreEqual(new[] { initialBounds, initialBounds }, visibilityBounds); + CollectionAssert.AreEqual(new[] { initialBounds with { X = movedBounds.X, Y = movedBounds.Y }, movedBounds }, boundsEvents); + } + + [TestMethod] + [TestCategory("WindowsIntegration")] + public async Task DisposeAsyncRaisesDisposedEventOnce() + { + OverlayWindow overlay = await OverlayWindow.CreateAsync(new OverlayWindowOptions + { + IsVisible = false, + }); + int disposed = 0; + overlay.Disposed += sender => + { + Assert.AreSame(overlay, sender); + disposed++; + }; + + try + { + await overlay.DisposeAsync(); + await overlay.DisposeAsync(); + + Assert.AreEqual(1, disposed); + } + finally + { + await overlay.DisposeAsync(); + } } [TestMethod] @@ -441,6 +470,43 @@ public async Task InteractiveOverlayReceivesPointerPressEvents() Assert.AreEqual(24f, pointer.Position.Y); } + [TestMethod] + [TestCategory("WindowsIntegration")] + public async Task InteractiveOverlayReceivesPointerMoveAndReleaseEvents() + { + var pointerMoved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var pointerReleased = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using OverlayWindow overlay = await OverlayWindow.CreateAsync(new OverlayWindowOptions + { + IsVisible = false, + InputMode = OverlayInputMode.Interactive, + }); + overlay.PointerMoved += (_, args) => pointerMoved.TrySetResult(args); + overlay.PointerReleased += (_, args) => pointerReleased.TrySetResult(args); + + _ = SendMessage(overlay.Hwnd.Value, WmMouseMove, 0, MakeLParam(15, 25)); + _ = SendMessage(overlay.Hwnd.Value, WmLButtonUp, 0, MakeLParam(16, 26)); + + OverlayPointerEventArgs moved = await pointerMoved.Task.WaitAsync(TimeSpan.FromSeconds(5)); + OverlayPointerEventArgs released = await pointerReleased.Task.WaitAsync(TimeSpan.FromSeconds(5)); + DpiScale dpi = overlay.DpiScale; + + Assert.AreEqual(OverlayPointerEventKind.Moved, moved.Kind); + Assert.AreEqual(OverlayPointerButton.None, moved.Button); + Assert.AreEqual(15, moved.PixelX); + Assert.AreEqual(25, moved.PixelY); + Assert.AreEqual(15f / dpi.X, moved.Position.X, 0.001f); + Assert.AreEqual(25f / dpi.Y, moved.Position.Y, 0.001f); + + Assert.AreEqual(OverlayPointerEventKind.Released, released.Kind); + Assert.AreEqual(OverlayPointerButton.Left, released.Button); + Assert.AreEqual(16, released.PixelX); + Assert.AreEqual(26, released.PixelY); + Assert.AreEqual(16f / dpi.X, released.Position.X, 0.001f); + Assert.AreEqual(26f / dpi.Y, released.Position.Y, 0.001f); + } + [TestMethod] [TestCategory("WindowsIntegration")] public async Task InteractiveOverlayReceivesPointerWheelEvents() @@ -492,12 +558,82 @@ public async Task InteractiveOverlayReceivesHorizontalPointerWheelEvents() OverlayPointerEventArgs pointer = await pointerWheel.Task.WaitAsync(TimeSpan.FromSeconds(5)); Assert.AreEqual(OverlayPointerEventKind.Wheel, pointer.Kind); + Assert.AreEqual(OverlayPointerButton.None, pointer.Button); Assert.AreEqual(14, pointer.PixelX); Assert.AreEqual(28, pointer.PixelY); + Assert.AreEqual(14f, pointer.Position.X); + Assert.AreEqual(28f, pointer.Position.Y); Assert.AreEqual(wheelDelta, pointer.WheelDelta); Assert.IsTrue(pointer.IsHorizontalWheel); } + [TestMethod] + [TestCategory("WindowsIntegration")] + public async Task OverlayKeyboardAndTextEventsCopyNativePayloadValues() + { + List keyPressed = []; + List keyReleased = []; + List textInput = []; + + await using OverlayWindow overlay = await OverlayWindow.CreateAsync(new OverlayWindowOptions + { + IsVisible = false, + }); + overlay.KeyPressed += (_, args) => keyPressed.Add(args); + overlay.KeyReleased += (_, args) => keyReleased.Add(args); + overlay.TextInput += (_, args) => textInput.Add(args); + + DispatchKeyboard(overlay, new Win32KeyboardEvent( + VirtualKey: 0x70, + IsPressed: true, + IsSystemKey: true, + RepeatCount: 3, + ScanCode: 0x3B, + IsExtendedKey: true, + WasDown: true, + IsTransitionState: false, + Modifiers: Win32ModifierKeys.Control | Win32ModifierKeys.Shift)); + DispatchKeyboard(overlay, new Win32KeyboardEvent( + VirtualKey: 0x70, + IsPressed: false, + IsSystemKey: true, + RepeatCount: 1, + ScanCode: 0x3B, + IsExtendedKey: true, + WasDown: true, + IsTransitionState: true, + Modifiers: Win32ModifierKeys.Alt)); + DispatchTextInput(overlay, new Win32TextInputEvent("ø", true)); + + Assert.AreEqual(1, keyPressed.Count); + OverlayKeyboardEventArgs pressed = keyPressed[0]; + Assert.AreEqual(0x70, pressed.VirtualKey); + Assert.IsTrue(pressed.IsSystemKey); + Assert.AreEqual(3, pressed.RepeatCount); + Assert.AreEqual(0x3B, pressed.ScanCode); + Assert.IsTrue(pressed.IsExtendedKey); + Assert.IsTrue(pressed.WasDown); + Assert.IsFalse(pressed.IsTransitionState); + Assert.IsTrue(pressed.IsRepeat); + Assert.AreEqual(OverlayModifierKeys.Control | OverlayModifierKeys.Shift, pressed.Modifiers); + + Assert.AreEqual(1, keyReleased.Count); + OverlayKeyboardEventArgs released = keyReleased[0]; + Assert.AreEqual(0x70, released.VirtualKey); + Assert.IsTrue(released.IsSystemKey); + Assert.AreEqual(1, released.RepeatCount); + Assert.AreEqual(0x3B, released.ScanCode); + Assert.IsTrue(released.IsExtendedKey); + Assert.IsTrue(released.WasDown); + Assert.IsTrue(released.IsTransitionState); + Assert.IsFalse(released.IsRepeat); + Assert.AreEqual(OverlayModifierKeys.Alt, released.Modifiers); + + Assert.AreEqual(1, textInput.Count); + Assert.AreEqual("ø", textInput[0].Text); + Assert.IsTrue(textInput[0].IsSystemCharacter); + } + [TestMethod] [TestCategory("WindowsIntegration")] public async Task SelectiveClickThroughNcHitTestUsesInputRegionResolver() @@ -601,6 +737,8 @@ public async Task KeyboardLParamHighBitDoesNotOverflowOn64Bit() } private const uint WmLButtonDown = 0x0201; + private const uint WmLButtonUp = 0x0202; + private const uint WmMouseMove = 0x0200; private const uint WmNcHitTest = 0x0084; private const uint WmKeyDown = 0x0100; private const uint WmKeyUp = 0x0101; @@ -636,6 +774,20 @@ private static nint MakeKeyLParam(int repeatCount, int scanCode, bool wasDown, b return new(value); } + private static void DispatchKeyboard(OverlayWindow overlay, Win32KeyboardEvent keyboard) + { + MethodInfo method = typeof(OverlayWindow).GetMethod("HandleKeyboardEvent", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new MissingMethodException(nameof(OverlayWindow), "HandleKeyboardEvent"); + method.Invoke(overlay, [keyboard]); + } + + private static void DispatchTextInput(OverlayWindow overlay, Win32TextInputEvent textInput) + { + MethodInfo method = typeof(OverlayWindow).GetMethod("HandleTextInputEvent", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new MissingMethodException(nameof(OverlayWindow), "HandleTextInputEvent"); + method.Invoke(overlay, [textInput]); + } + private sealed class SingleBackendProvider(IRenderBackend backend) : IRenderBackendProvider { public IRenderBackend CreateBackend(OverlayWindowOptions options) => backend; diff --git a/tests/ModernOverlay.Tests/TargetTrackingTests.cs b/tests/ModernOverlay.Tests/TargetTrackingTests.cs index 55b344f..74a2fe7 100644 --- a/tests/ModernOverlay.Tests/TargetTrackingTests.cs +++ b/tests/ModernOverlay.Tests/TargetTrackingTests.cs @@ -51,6 +51,9 @@ public async Task OverlaySyncsTargetWindowBoundsBeforeRendering() { using Win32OverlayWindow target = CreateHiddenTarget(10, 20, 200, 120); using var runCancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var movedBounds = new WindowBounds(40, 50, 240, 160); + List boundsEvents = []; + List targetEvents = []; await using OverlayWindow overlay = await OverlayWindow.CreateAsync(new OverlayWindowOptions { @@ -59,19 +62,28 @@ public async Task OverlaySyncsTargetWindowBoundsBeforeRendering() FrameRateLimit = FrameRateLimit.Fixed(120), Target = WindowTarget.FromHwnd(new WindowHandle(target.Hwnd)), }); + overlay.BoundsChanged += (_, args) => boundsEvents.Add(args.Bounds); + overlay.TargetChanged += (_, args) => targetEvents.Add(args); - target.SetBounds(40, 50, 240, 160); + target.SetBounds(movedBounds.X, movedBounds.Y, movedBounds.Width, movedBounds.Height); overlay.Render += _ => runCancellation.Cancel(); await overlay.RunAsync(runCancellation.Token); Assert.IsTrue(Win32WindowQuery.TryGetWindowBounds(overlay.Hwnd.Value, clientArea: false, out Win32WindowBounds bounds)); - Assert.AreEqual(40, bounds.X); - Assert.AreEqual(50, bounds.Y); - Assert.AreEqual(240, bounds.Width); - Assert.AreEqual(160, bounds.Height); + Assert.AreEqual(movedBounds.X, bounds.X); + Assert.AreEqual(movedBounds.Y, bounds.Y); + Assert.AreEqual(movedBounds.Width, bounds.Width); + Assert.AreEqual(movedBounds.Height, bounds.Height); Assert.AreEqual(new WindowHandle(target.Hwnd), overlay.FrameStats.TargetHwnd); - Assert.AreEqual(new WindowBounds(40, 50, 240, 160), overlay.FrameStats.TargetBounds); + Assert.AreEqual(movedBounds, overlay.FrameStats.TargetBounds); + CollectionAssert.AreEqual(new[] { movedBounds }, boundsEvents); + Assert.AreEqual(1, targetEvents.Count); + Assert.AreEqual(overlay.Options.Target, targetEvents[0].Target); + Assert.IsNotNull(targetEvents[0].TargetHwnd); + Assert.AreEqual(new WindowHandle(target.Hwnd), targetEvents[0].TargetHwnd!.Value); + Assert.IsNotNull(targetEvents[0].Bounds); + Assert.AreEqual(movedBounds, targetEvents[0].Bounds!.Value); } [TestMethod] @@ -327,6 +339,10 @@ public async Task TargetLossAndReacquireEventsFire() string title = $"ModernOverlay reacquire target {Guid.NewGuid():N}"; Win32OverlayWindow? target = CreateHiddenTarget(105, 115, 240, 120, title: title); using var runCancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var originalHwnd = new WindowHandle(target.Hwnd); + var originalBounds = new WindowBounds(105, 115, 240, 120); + WindowHandle reacquiredHwnd = default; + var reacquiredBounds = new WindowBounds(125, 135, 250, 130); bool disposedOriginal = false; bool lost = false; bool reacquired = false; @@ -344,14 +360,23 @@ public async Task TargetLossAndReacquireEventsFire() overlay.TargetLost += (_, args) => { lost = true; + Assert.AreEqual(overlay.Options.Target, args.Target); Assert.IsNotNull(args.TargetHwnd); - target = CreateHiddenTarget(125, 135, 250, 130, title: title); + Assert.AreEqual(originalHwnd, args.TargetHwnd!.Value); + Assert.IsNotNull(args.Bounds); + Assert.AreEqual(originalBounds, args.Bounds!.Value); + target = CreateHiddenTarget(reacquiredBounds.X, reacquiredBounds.Y, reacquiredBounds.Width, reacquiredBounds.Height, title: title); + reacquiredHwnd = new WindowHandle(target.Hwnd); }; overlay.TargetReacquired += (_, args) => { reacquired = true; Assert.IsTrue(lost); - Assert.AreEqual(new WindowBounds(125, 135, 250, 130), args.Bounds); + Assert.AreEqual(overlay.Options.Target, args.Target); + Assert.IsNotNull(args.TargetHwnd); + Assert.AreEqual(reacquiredHwnd, args.TargetHwnd!.Value); + Assert.IsNotNull(args.Bounds); + Assert.AreEqual(reacquiredBounds, args.Bounds!.Value); runCancellation.Cancel(); }; overlay.Render += _ =>