From 38b5e7b48508fc637d503699eea04f41f2e7e151 Mon Sep 17 00:00:00 2001 From: Gleb Khmyznikov Date: Fri, 28 Aug 2026 14:07:34 -0700 Subject: [PATCH] initial commit --- PowerToys.slnx | 4 + .../UITestAutomation.Next/KeyboardHelper.cs | 9 +- .../UITestAutomation.Next/MouseHelper.cs | 51 +- .../UITestAutomation.Next/NamedEventHelper.cs | 61 ++ .../MouseUtils.UITests.Next/AssemblyInfo.cs | 7 + .../CursorWrapTests.cs | 337 ++++++++++ .../FindMyMouseTests.cs | 418 ++++++++++++ .../MouseHighlighterTests.cs | 613 ++++++++++++++++++ .../MouseUtils.UITests.Next/MouseJumpTests.cs | 346 ++++++++++ .../MousePointerCrosshairsTests.cs | 414 ++++++++++++ .../MouseUtils.UITests.Next.csproj | 35 + .../MouseUtilsTestHelper.cs | 268 ++++++++ .../MouseUtils.UITests.Next/app.manifest | 16 + ...lease-Test-Checklist-Migration-Progress.md | 150 +++-- 14 files changed, 2661 insertions(+), 68 deletions(-) create mode 100644 src/modules/MouseUtils/MouseUtils.UITests.Next/AssemblyInfo.cs create mode 100644 src/modules/MouseUtils/MouseUtils.UITests.Next/CursorWrapTests.cs create mode 100644 src/modules/MouseUtils/MouseUtils.UITests.Next/FindMyMouseTests.cs create mode 100644 src/modules/MouseUtils/MouseUtils.UITests.Next/MouseHighlighterTests.cs create mode 100644 src/modules/MouseUtils/MouseUtils.UITests.Next/MouseJumpTests.cs create mode 100644 src/modules/MouseUtils/MouseUtils.UITests.Next/MousePointerCrosshairsTests.cs create mode 100644 src/modules/MouseUtils/MouseUtils.UITests.Next/MouseUtils.UITests.Next.csproj create mode 100644 src/modules/MouseUtils/MouseUtils.UITests.Next/MouseUtilsTestHelper.cs create mode 100644 src/modules/MouseUtils/MouseUtils.UITests.Next/app.manifest diff --git a/PowerToys.slnx b/PowerToys.slnx index e6b34c2baa..d71f338f89 100644 --- a/PowerToys.slnx +++ b/PowerToys.slnx @@ -605,6 +605,10 @@ + + + + diff --git a/src/common/UITestAutomation.Next/KeyboardHelper.cs b/src/common/UITestAutomation.Next/KeyboardHelper.cs index eeab43a831..28f3dba746 100644 --- a/src/common/UITestAutomation.Next/KeyboardHelper.cs +++ b/src/common/UITestAutomation.Next/KeyboardHelper.cs @@ -11,6 +11,8 @@ namespace Microsoft.PowerToys.UITest.Next; public enum Key : byte { Ctrl = 0x11, + LCtrl = 0xA2, + RCtrl = 0xA3, Shift = 0x10, LShift = 0xA0, Alt = 0x12, @@ -81,6 +83,7 @@ public enum Key : byte F10 = 0x79, F11 = 0x7A, F12 = 0x7B, + OemPeriod = 0xBE, } /// @@ -122,7 +125,9 @@ public static class KeyboardHelper keybd_event(VK_LWIN, 0, 0, UIntPtr.Zero); winDown = true; break; - case Key.Ctrl: chord.Append('^'); break; + case Key.Ctrl: + case Key.LCtrl: + case Key.RCtrl: chord.Append('^'); break; case Key.Shift: case Key.LShift: chord.Append('+'); break; case Key.Alt: chord.Append('%'); break; @@ -153,6 +158,7 @@ public static class KeyboardHelper case Key.F10: chord.Append("{F10}"); break; case Key.F11: chord.Append("{F11}"); break; case Key.F12: chord.Append("{F12}"); break; + case Key.OemPeriod: chord.Append('.'); break; default: // Letter / digit keys map to their lowercase character for SendKeys. chord.Append(((char)k).ToString().ToLowerInvariant()); @@ -210,6 +216,7 @@ public static class KeyboardHelper } private static bool IsExtended(Key key) => key is + Key.RCtrl or Key.Left or Key.Up or Key.Right or Key.Down or Key.Home or Key.End or Key.PageUp or Key.PageDown or Key.Insert or Key.Delete; diff --git a/src/common/UITestAutomation.Next/MouseHelper.cs b/src/common/UITestAutomation.Next/MouseHelper.cs index 7a0db7b7a0..f829a6064a 100644 --- a/src/common/UITestAutomation.Next/MouseHelper.cs +++ b/src/common/UITestAutomation.Next/MouseHelper.cs @@ -41,6 +41,7 @@ public static class MouseHelper private const uint INPUT_MOUSE = 0; + private const uint MouseEventMove = 0x01; private const uint MOUSEEVENTF_LEFTDOWN = 0x02; private const uint MOUSEEVENTF_LEFTUP = 0x04; private const uint MOUSEEVENTF_RIGHTDOWN = 0x08; @@ -52,10 +53,10 @@ public static class MouseHelper private const int ClickDelayMs = 100; private const int WheelTick = 120; - [DllImport("user32.dll")] + [DllImport("user32.dll", SetLastError = true)] private static extern bool SetCursorPos(int x, int y); - [DllImport("user32.dll")] + [DllImport("user32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool GetCursorPos(out POINT lpPoint); @@ -63,12 +64,48 @@ public static class MouseHelper private static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize); /// Move the OS cursor to absolute screen coordinates. - public static void MoveTo(int x, int y) => SetCursorPos(x, y); + public static void MoveTo(int x, int y) + { + if (!SetCursorPos(x, y)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + } + + /// + /// Move the cursor by a relative delta using real mouse input. Stepped movement is useful for + /// utilities that observe low-level or raw mouse movement rather than only the final position. + /// + public static void MoveBy(int deltaX, int deltaY, int steps = 1, int delayMs = 15) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(steps); + ArgumentOutOfRangeException.ThrowIfNegative(delayMs); + + var previousX = 0; + var previousY = 0; + for (var step = 1; step <= steps; step++) + { + var targetX = (int)Math.Round((double)deltaX * step / steps); + var targetY = (int)Math.Round((double)deltaY * step / steps); + SendMouseInput(MouseEventMove, dx: targetX - previousX, dy: targetY - previousY); + previousX = targetX; + previousY = targetY; + + if (delayMs > 0 && step < steps) + { + Thread.Sleep(delayMs); + } + } + } /// Current cursor position in screen pixels. public static (int X, int Y) GetMousePosition() { - GetCursorPos(out var p); + if (!GetCursorPos(out var p)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + return (p.X, p.Y); } @@ -178,7 +215,7 @@ public static class MouseHelper /// Button and wheel events fire at the current cursor position, so /// only carries the wheel delta for MOUSEEVENTF_WHEEL. /// - private static void SendMouseInput(uint flags, int data = 0) + private static void SendMouseInput(uint flags, int data = 0, int dx = 0, int dy = 0) { var inputs = new INPUT[] { @@ -187,8 +224,8 @@ public static class MouseHelper Type = INPUT_MOUSE, Mi = new MOUSEINPUT { - Dx = 0, - Dy = 0, + Dx = dx, + Dy = dy, MouseData = (uint)data, DwFlags = flags, Time = 0, diff --git a/src/common/UITestAutomation.Next/NamedEventHelper.cs b/src/common/UITestAutomation.Next/NamedEventHelper.cs index a01b5321b8..cfe8e3099f 100644 --- a/src/common/UITestAutomation.Next/NamedEventHelper.cs +++ b/src/common/UITestAutomation.Next/NamedEventHelper.cs @@ -19,6 +19,21 @@ public static class NamedEventHelper /// Toggles the FancyZones layout editor open/closed. public const string FancyZonesEditorToggle = @"Local\FancyZones-ToggleEditorEvent-1e174338-06a3-472b-874d-073b21c62f14"; + /// Triggers Find My Mouse. + public const string FindMyMouseTrigger = @"Local\FindMyMouseTriggerEvent-5a9dc5f4-1c74-4f2f-a66f-1b9b6a2f9b23"; + + /// Toggles Mouse Highlighter. + public const string MouseHighlighterToggle = @"Local\MouseHighlighterTriggerEvent-1e3c9c3d-3fdf-4f9a-9a52-31c9b3c3a8f4"; + + /// Toggles Mouse Pointer Crosshairs. + public const string MouseCrosshairsToggle = @"Local\MouseCrosshairsTriggerEvent-0d4c7f92-0a5c-4f5c-b64b-8a2a2f7e0b21"; + + /// Shows the Mouse Jump preview. + public const string MouseJumpShowPreview = @"Local\MouseJumpEvent-aa0be051-3396-4976-b7ba-1a9cc7d236a5"; + + /// Toggles Cursor Wrap. + public const string CursorWrapToggle = @"Local\CursorWrapTriggerEvent-1f8452b5-4e6e-45b3-8b09-13f14a5900c9"; + /// Set an existing named event. Returns false when no module currently owns it. public static bool TrySignal(string name) { @@ -40,6 +55,33 @@ public static class NamedEventHelper } } + /// Whether a named event currently exists. + public static bool Exists(string name) + { + try + { + if (!EventWaitHandle.TryOpenExisting(name, out var handle)) + { + return false; + } + + handle.Dispose(); + return true; + } + catch (Exception) + { + return false; + } + } + + /// Wait until a module creates a named event without signaling it. + public static bool WaitUntilAvailable(string name, int timeoutMS = 15_000, int pollIntervalMS = 250) => + WaitForAvailability(name, expected: true, timeoutMS, pollIntervalMS); + + /// Wait until a module closes a named event. + public static bool WaitUntilUnavailable(string name, int timeoutMS = 15_000, int pollIntervalMS = 250) => + WaitForAvailability(name, expected: false, timeoutMS, pollIntervalMS); + /// Wait until a module has created the named event, then signal it. public static bool WaitAndSignal(string name, int timeoutMS = 15_000, int pollIntervalMS = 250) { @@ -59,4 +101,23 @@ public static class NamedEventHelper Thread.Sleep(pollIntervalMS); } } + + private static bool WaitForAvailability(string name, bool expected, int timeoutMS, int pollIntervalMS) + { + var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS); + while (true) + { + if (Exists(name) == expected) + { + return true; + } + + if (DateTime.UtcNow >= deadline) + { + return false; + } + + Thread.Sleep(pollIntervalMS); + } + } } diff --git a/src/modules/MouseUtils/MouseUtils.UITests.Next/AssemblyInfo.cs b/src/modules/MouseUtils/MouseUtils.UITests.Next/AssemblyInfo.cs new file mode 100644 index 0000000000..8c5a6eed6d --- /dev/null +++ b/src/modules/MouseUtils/MouseUtils.UITests.Next/AssemblyInfo.cs @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[assembly: DoNotParallelize] diff --git a/src/modules/MouseUtils/MouseUtils.UITests.Next/CursorWrapTests.cs b/src/modules/MouseUtils/MouseUtils.UITests.Next/CursorWrapTests.cs new file mode 100644 index 0000000000..40cb4a9dae --- /dev/null +++ b/src/modules/MouseUtils/MouseUtils.UITests.Next/CursorWrapTests.cs @@ -0,0 +1,337 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.PowerToys.UITest.Next; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace MouseUtils.UITests; + +[TestClass] +public class CursorWrapTests : UITestBase +{ + private const string ModuleName = "CursorWrap"; + private static readonly IDisposable ModuleSettings = SettingsConfigHelper.PreserveModuleSettings(ModuleName); + + public CursorWrapTests() + : base(PowerToysModule.PowerToysSettings, enableModules: new[] { ModuleName }) + { + } + + [ClassCleanup] + public static void RestoreModuleSettings() => ModuleSettings.Dispose(); + + protected override void PrepareTestState() + { + var configuration = TestContext.TestName switch + { + nameof(HorizontalOnlyWrapsOnlyHorizontalEdges) => new CursorWrapConfiguration(WrapMode: 2), + nameof(VerticalOnlyWrapsOnlyVerticalEdges) => new CursorWrapConfiguration(WrapMode: 1), + nameof(CtrlActivationModeRequiresCtrl) => new CursorWrapConfiguration(ActivationMode: 1), + nameof(ShiftActivationModeRequiresShift) => new CursorWrapConfiguration(ActivationMode: 2), + nameof(SingleMonitorSuppressionBlocksWrapping) => new CursorWrapConfiguration(DisableOnSingleMonitor: true), + nameof(AutoActivateStartsWrappingWithoutShortcut) => new CursorWrapConfiguration(AutoActivate: true), + nameof(ChangedShortcutTogglesWrapping) => new CursorWrapConfiguration(ShortcutCode: (int)Key.Y), + _ => new CursorWrapConfiguration(), + }; + + MouseUtilsTestHelper.ReplaceModuleSettings(ModuleName, CreateSettings(configuration)); + } + + [TestCleanup] + public async Task CleanupInput() + { + await CaptureFailureArtifactsBeforeCleanupAsync(); + MouseHelper.LeftUp(); + MouseHelper.RightUp(); + KeyboardHelper.ReleaseKey(Key.Ctrl); + KeyboardHelper.ReleaseKey(Key.Shift); + KeyboardHelper.ReleaseKey(Key.Alt); + KeyboardHelper.ReleaseKey(Key.LWin); + } + + [TestMethod] + [TestCategory("MouseUtils")] + [TestCategory("CursorWrap")] + public void ShortcutTogglesWrappingAndModuleDisableStopsIt() + { + MouseUtilsTestHelper.NavigateToMouseUtilities(this); + var toggle = Session.Find(By.Name("CursorWrap"), 5_000); + toggle.Toggle(true); + Assert.IsTrue(toggle.WaitForProperty("ToggleState", "On", 5_000), "CursorWrap toggle did not reach On."); + Assert.IsTrue( + NamedEventHelper.WaitUntilAvailable(NamedEventHelper.CursorWrapToggle), + "CursorWrap did not create its trigger event after being enabled."); + + MouseUtilsTestHelper.Step(this, "Activating CursorWrap with Win+Alt+U"); + KeyboardHelper.SendKeys(Key.LWin, Key.Alt, Key.U); + AssertWraps(CursorEdge.Left); + + MouseUtilsTestHelper.Step(this, "Deactivating CursorWrap with Win+Alt+U"); + KeyboardHelper.SendKeys(Key.LWin, Key.Alt, Key.U); + AssertDoesNotWrap(CursorEdge.Left); + + toggle.Toggle(false); + Assert.IsTrue(toggle.WaitForProperty("ToggleState", "Off", 5_000), "CursorWrap toggle did not reach Off."); + Assert.IsTrue( + NamedEventHelper.WaitUntilUnavailable(NamedEventHelper.CursorWrapToggle), + "CursorWrap trigger event remained available after the module was disabled."); + AssertDoesNotWrap(CursorEdge.Left); + } + + [TestMethod] + [TestCategory("MouseUtils")] + [TestCategory("CursorWrap")] + public void BothModeWrapsAllEdges() + { + ActivateWithNamedEvent(); + foreach (var edge in Enum.GetValues()) + { + AssertWraps(edge); + } + } + + [TestMethod] + [TestCategory("MouseUtils")] + [TestCategory("CursorWrap")] + public void HorizontalOnlyWrapsOnlyHorizontalEdges() + { + ActivateWithNamedEvent(); + AssertWraps(CursorEdge.Left); + AssertWraps(CursorEdge.Right); + AssertDoesNotWrap(CursorEdge.Top); + AssertDoesNotWrap(CursorEdge.Bottom); + } + + [TestMethod] + [TestCategory("MouseUtils")] + [TestCategory("CursorWrap")] + public void VerticalOnlyWrapsOnlyVerticalEdges() + { + ActivateWithNamedEvent(); + AssertWraps(CursorEdge.Top); + AssertWraps(CursorEdge.Bottom); + AssertDoesNotWrap(CursorEdge.Left); + AssertDoesNotWrap(CursorEdge.Right); + } + + [TestMethod] + [TestCategory("MouseUtils")] + [TestCategory("CursorWrap")] + public void CtrlActivationModeRequiresCtrl() + { + ActivateWithNamedEvent(); + AssertDoesNotWrap(CursorEdge.Left); + AssertWraps(CursorEdge.Left, Key.Ctrl); + } + + [TestMethod] + [TestCategory("MouseUtils")] + [TestCategory("CursorWrap")] + public void ShiftActivationModeRequiresShift() + { + ActivateWithNamedEvent(); + AssertDoesNotWrap(CursorEdge.Top); + AssertWraps(CursorEdge.Top, Key.Shift); + } + + [TestMethod] + [TestCategory("MouseUtils")] + [TestCategory("CursorWrap")] + public void DragSuppressionBlocksWrappingWhileLeftButtonIsDown() + { + ActivateWithNamedEvent(); + AssertDoesNotWrap(CursorEdge.Left, holdLeftButton: true); + AssertWraps(CursorEdge.Left); + } + + [TestMethod] + [TestCategory("MouseUtils")] + [TestCategory("CursorWrap")] + public void SingleMonitorSuppressionBlocksWrapping() + { + Assert.AreEqual(1, MonitorInfo.Count, "This scenario requires the single-monitor VM profile."); + ActivateWithNamedEvent(); + foreach (var edge in Enum.GetValues()) + { + AssertDoesNotWrap(edge); + } + } + + [TestMethod] + [TestCategory("MouseUtils")] + [TestCategory("CursorWrap")] + public void AutoActivateStartsWrappingWithoutShortcut() + { + Assert.IsTrue( + NamedEventHelper.WaitUntilAvailable(NamedEventHelper.CursorWrapToggle), + "CursorWrap did not create its trigger event."); + AssertWraps(CursorEdge.Right); + } + + [TestMethod] + [TestCategory("MouseUtils")] + [TestCategory("CursorWrap")] + public void ChangedShortcutTogglesWrapping() + { + Assert.IsTrue( + NamedEventHelper.WaitUntilAvailable(NamedEventHelper.CursorWrapToggle), + "CursorWrap did not create its trigger event."); + + KeyboardHelper.SendKeys(Key.LWin, Key.Alt, Key.U); + AssertDoesNotWrap(CursorEdge.Left); + + KeyboardHelper.SendKeys(Key.LWin, Key.Alt, Key.Y); + AssertWraps(CursorEdge.Left); + } + + private static string CreateSettings(CursorWrapConfiguration configuration) => $$""" + { + "name": "CursorWrap", + "version": "1.0", + "properties": { + "activation_shortcut": { "win": true, "ctrl": false, "alt": true, "shift": false, "code": {{configuration.ShortcutCode}}, "key": "" }, + "auto_activate": { "value": {{configuration.AutoActivate.ToString().ToLowerInvariant()}} }, + "disable_wrap_during_drag": { "value": {{configuration.DisableDuringDrag.ToString().ToLowerInvariant()}} }, + "wrap_mode": { "value": {{configuration.WrapMode}} }, + "activation_mode": { "value": {{configuration.ActivationMode}} }, + "disable_cursor_wrap_on_single_monitor": { "value": {{configuration.DisableOnSingleMonitor.ToString().ToLowerInvariant()}} } + } + } + """; + + private void ActivateWithNamedEvent() + { + MouseUtilsTestHelper.Step(this, "Activating CursorWrap through its named event"); + Assert.IsTrue( + NamedEventHelper.WaitAndSignal(NamedEventHelper.CursorWrapToggle), + "CursorWrap did not create or respond to its trigger event."); + } + + private void AssertWraps(CursorEdge edge, Key? heldKey = null, bool holdLeftButton = false) + { + var monitor = MonitorInfo.GetPrimary()!; + var attempts = new List<(int X, int Y)>(); + for (var attempt = 1; attempt <= 3; attempt++) + { + var after = MoveAcrossEdge(edge, heldKey, holdLeftButton); + attempts.Add(after); + var wrapped = edge switch + { + CursorEdge.Left => after.X >= monitor.Right - 10, + CursorEdge.Right => after.X <= monitor.Left + 10, + CursorEdge.Top => after.Y >= monitor.Bottom - 10, + CursorEdge.Bottom => after.Y <= monitor.Top + 10, + _ => false, + }; + if (wrapped) + { + return; + } + + Thread.Sleep(100); + } + + Assert.Fail($"Cursor did not wrap from {edge} after three complete crossings; final positions: {string.Join(", ", attempts.Select(position => $"({position.X},{position.Y})"))}."); + } + + private void AssertDoesNotWrap(CursorEdge edge, Key? heldKey = null, bool holdLeftButton = false) + { + var after = MoveAcrossEdge(edge, heldKey, holdLeftButton); + var monitor = MonitorInfo.GetPrimary()!; + var stayed = edge switch + { + CursorEdge.Left => after.X <= monitor.Left + 10, + CursorEdge.Right => after.X >= monitor.Right - 10, + CursorEdge.Top => after.Y <= monitor.Top + 10, + CursorEdge.Bottom => after.Y >= monitor.Bottom - 10, + _ => false, + }; + + Assert.IsTrue(stayed, $"Cursor unexpectedly wrapped from {edge}; final position was ({after.X},{after.Y})."); + } + + private (int X, int Y) MoveAcrossEdge(CursorEdge edge, Key? heldKey, bool holdLeftButton) + { + var monitor = MonitorInfo.GetPrimary(); + Assert.IsNotNull(monitor, "No primary monitor was reported."); + var centerX = monitor.Left + (monitor.Width / 2); + var centerY = monitor.Top + (monitor.Height / 2); + MouseHelper.MoveTo(centerX, centerY); + MouseHelper.MoveBy(2, 2); + + var start = edge switch + { + CursorEdge.Left => (monitor.Left + 120, centerY), + CursorEdge.Right => (monitor.Right - 121, centerY), + CursorEdge.Top => (centerX, monitor.Top + 120), + CursorEdge.Bottom => (centerX, monitor.Bottom - 121), + _ => throw new ArgumentOutOfRangeException(nameof(edge)), + }; + var inward = edge switch + { + CursorEdge.Left => (2, 0), + CursorEdge.Right => (-2, 0), + CursorEdge.Top => (0, 2), + CursorEdge.Bottom => (0, -2), + _ => throw new ArgumentOutOfRangeException(nameof(edge)), + }; + var outward = edge switch + { + CursorEdge.Left => (-500, 0), + CursorEdge.Right => (500, 0), + CursorEdge.Top => (0, -500), + CursorEdge.Bottom => (0, 500), + _ => throw new ArgumentOutOfRangeException(nameof(edge)), + }; + + MouseHelper.MoveTo(start.Item1, start.Item2); + if (heldKey.HasValue) + { + KeyboardHelper.PressKey(heldKey.Value); + } + + if (holdLeftButton) + { + MouseHelper.LeftDown(); + } + + try + { + MouseHelper.MoveBy(inward.Item1, inward.Item2); + MouseHelper.MoveBy(outward.Item1, outward.Item2); + Thread.Sleep(150); + var after = MouseHelper.GetMousePosition(); + MouseUtilsTestHelper.Step(this, $"Cursor {edge} crossing ended at ({after.X},{after.Y})"); + return after; + } + finally + { + if (holdLeftButton) + { + MouseHelper.LeftUp(); + } + + if (heldKey.HasValue) + { + KeyboardHelper.ReleaseKey(heldKey.Value); + } + } + } + + private sealed record CursorWrapConfiguration( + bool AutoActivate = false, + bool DisableDuringDrag = true, + int WrapMode = 0, + int ActivationMode = 0, + bool DisableOnSingleMonitor = false, + int ShortcutCode = (int)Key.U); + + private enum CursorEdge + { + Left, + Right, + Top, + Bottom, + } +} diff --git a/src/modules/MouseUtils/MouseUtils.UITests.Next/FindMyMouseTests.cs b/src/modules/MouseUtils/MouseUtils.UITests.Next/FindMyMouseTests.cs new file mode 100644 index 0000000000..5c0fbf4d27 --- /dev/null +++ b/src/modules/MouseUtils/MouseUtils.UITests.Next/FindMyMouseTests.cs @@ -0,0 +1,418 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Drawing; +using Microsoft.PowerToys.UITest.Next; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace MouseUtils.UITests; + +[TestClass] +public class FindMyMouseTests : UITestBase +{ + private const string ModuleName = "FindMyMouse"; + private const string ToggleId = "MouseUtils_FindMyMouseToggleId"; + private const string WindowClass = "FindMyMouse"; + private static readonly IDisposable ModuleSettings = SettingsConfigHelper.PreserveModuleSettings(ModuleName); + private static IDisposable? clientAreaAnimations; + + public FindMyMouseTests() + : base(PowerToysModule.PowerToysSettings, enableModules: new[] { ModuleName }) + { + } + + [ClassInitialize] + public static void PrepareClass(TestContext testContext) + { + _ = testContext; + clientAreaAnimations = MouseUtilsTestHelper.PreserveClientAreaAnimationsEnabled(); + } + + [ClassCleanup] + public static void RestoreClassState() + { + try + { + clientAreaAnimations?.Dispose(); + } + finally + { + ModuleSettings.Dispose(); + } + } + + protected override void PrepareTestState() + { + var configuration = TestContext.TestName switch + { + nameof(AppearanceColorsRadiusAndAlphaAreApplied) => new FindMyMouseConfiguration( + BackgroundColor: "#80FF0000", + SpotlightColor: "#8000FF00", + Radius: 80, + AnimationDurationMs: 1, + InitialZoom: 1), + nameof(InitialZoomIsApplied) => new FindMyMouseConfiguration( + BackgroundColor: "#FFFF0000", + SpotlightColor: "#FF00FF00", + Radius: 40, + AnimationDurationMs: 2_000, + InitialZoom: 1), + nameof(AnimationDurationIsApplied) => new FindMyMouseConfiguration( + BackgroundColor: "#FFFF0000", + SpotlightColor: "#FF00FF00", + Radius: 40, + AnimationDurationMs: 10_000, + InitialZoom: 9), + nameof(RightControlActivates) => new FindMyMouseConfiguration(ActivationMethod: 1), + nameof(CustomShortcutActivates) => new FindMyMouseConfiguration(ActivationMethod: 3), + nameof(IncludeWinKeyGatesDoubleControlActivation) => new FindMyMouseConfiguration(IncludeWinKey: true), + nameof(ExcludedForegroundAppBlocksActivation) => new FindMyMouseConfiguration(ExcludedApps: "PowerToys.Settings.exe"), + _ => new FindMyMouseConfiguration(), + }; + + MouseUtilsTestHelper.ReplaceModuleSettings(ModuleName, CreateSettings(configuration)); + } + + [TestCleanup] + public async Task CleanupInput() + { + await CaptureFailureArtifactsBeforeCleanupAsync(); + MouseHelper.LeftUp(); + MouseHelper.RightUp(); + KeyboardHelper.ReleaseKey(Key.LCtrl); + KeyboardHelper.ReleaseKey(Key.RCtrl); + KeyboardHelper.ReleaseKey(Key.LWin); + } + + [TestMethod] + [TestCategory("MouseUtils")] + [TestCategory("Mouse Utils #1")] + [TestCategory("Mouse Utils #2")] + [TestCategory("Mouse Utils #3")] + [TestCategory("Mouse Utils #4")] + public void ActivationAndKeyboardMouseDismissal() + { + MouseUtilsTestHelper.NavigateToMouseUtilities(this); + MouseUtilsTestHelper.SetModuleEnabled(this, ToggleId, true); + var window = MouseUtilsTestHelper.WaitForWindowClass(WindowClass); + + using (var keyboardDismissal = new WindowShowWatcher(WindowClass, window.Hwnd.ToInt64())) + { + DoubleTap(Key.LCtrl); + Assert.IsTrue(keyboardDismissal.Wait(5_000), "Double left Ctrl did not show Find My Mouse."); + KeyboardHelper.SendKey(Key.A); + Assert.IsTrue(keyboardDismissal.WaitForHidden(5_000), "A keyboard key did not dismiss Find My Mouse."); + } + + using var mouseDismissal = new WindowShowWatcher(WindowClass, window.Hwnd.ToInt64()); + DoubleTap(Key.LCtrl); + Assert.IsTrue(mouseDismissal.Wait(5_000), "Second double left Ctrl did not show Find My Mouse."); + MouseHelper.LeftClick(); + Assert.IsTrue(mouseDismissal.WaitForHidden(5_000), "A mouse button did not dismiss Find My Mouse."); + } + + [TestMethod] + [TestCategory("MouseUtils")] + [TestCategory("Mouse Utils #5")] + [TestCategory("Mouse Utils #6")] + public void DisabledModuleRejectsActivationAndReenableWorks() + { + MouseUtilsTestHelper.NavigateToMouseUtilities(this); + var toggle = MouseUtilsTestHelper.SetModuleEnabled(this, ToggleId, false); + Assert.IsTrue( + NamedEventHelper.WaitUntilUnavailable(NamedEventHelper.FindMyMouseTrigger), + "Find My Mouse trigger event remained available after disabling the module."); + + using (var disabledWatcher = new WindowShowWatcher(WindowClass)) + { + DoubleTap(Key.LCtrl); + Assert.IsFalse(disabledWatcher.Wait(1_500), "Find My Mouse appeared while disabled."); + } + + _ = toggle; + MouseUtilsTestHelper.SetModuleEnabled(this, ToggleId, true); + Assert.IsTrue( + NamedEventHelper.WaitUntilAvailable(NamedEventHelper.FindMyMouseTrigger), + "Find My Mouse trigger event was not recreated after enabling."); + var window = MouseUtilsTestHelper.WaitForWindowClass(WindowClass); + using var enabledWatcher = new WindowShowWatcher(WindowClass, window.Hwnd.ToInt64()); + TriggerUntilShown(enabledWatcher, () => DoubleTap(Key.LCtrl), "double left Ctrl after re-enabling"); + } + + [TestMethod] + [TestCategory("MouseUtils")] + [TestCategory("Mouse Utils #10")] + [TestCategory("Mouse Utils #11")] + [TestCategory("Mouse Utils #12")] + public void AppearanceColorsRadiusAndAlphaAreApplied() + { + WindowHelper.MinimizeWindow(new IntPtr(Session.WindowHandle)); + var (centerX, centerY) = WindowHelper.GetScreenCenter(); + MouseHelper.MoveTo(centerX, centerY); + var spotlightPoint = (X: centerX + 40, Y: centerY); + var backgroundPoint = (X: centerX + 120, Y: centerY); + var spotlightBase = GetStablePixel(spotlightPoint.X, spotlightPoint.Y); + var backgroundBase = GetStablePixel(backgroundPoint.X, backgroundPoint.Y); + var window = MouseUtilsTestHelper.WaitForWindowClass(WindowClass); + using var watcher = new WindowShowWatcher(WindowClass, window.Hwnd.ToInt64()); + + Assert.IsTrue(NamedEventHelper.WaitAndSignal(NamedEventHelper.FindMyMouseTrigger), "Find My Mouse trigger event was unavailable."); + Assert.IsTrue(watcher.Wait(5_000), "Find My Mouse did not show for appearance validation."); + + var expectedSpotlight = Blend(Color.Lime, spotlightBase, 128); + var expectedBackground = Blend(Color.Red, backgroundBase, 128); + AssertPixelNear(spotlightPoint.X, spotlightPoint.Y, expectedSpotlight, "spotlight color/alpha inside the configured radius"); + AssertPixelNear(backgroundPoint.X, backgroundPoint.Y, expectedBackground, "background color/alpha outside the configured radius"); + } + + [TestMethod] + [TestCategory("MouseUtils")] + public void InitialZoomIsApplied() + { + MouseUtilsTestHelper.RunWithClientAreaAnimationsEnabled(() => + { + WindowHelper.MinimizeWindow(new IntPtr(Session.WindowHandle)); + var (centerX, centerY) = WindowHelper.GetScreenCenter(); + MouseHelper.MoveTo(centerX, centerY); + var window = MouseUtilsTestHelper.WaitForWindowClass(WindowClass); + using var watcher = new WindowShowWatcher(WindowClass, window.Hwnd.ToInt64()); + + Assert.IsTrue(NamedEventHelper.WaitAndSignal(NamedEventHelper.FindMyMouseTrigger), "Find My Mouse trigger event was unavailable."); + Assert.IsTrue(watcher.Wait(5_000), "Find My Mouse did not show for initial-zoom validation."); + var result = WaitHelper.WaitForStable( + () => new + { + Inside = WindowHelper.GetPixelColor(centerX + 20, centerY), + Outside = WindowHelper.GetPixelColor(centerX + 80, centerY), + }, + sample => sample is not null && sample.Inside.G > sample.Inside.R && sample.Outside.R > sample.Outside.G, + 5_000, + requiredConsecutiveMatches: 2, + pollIntervalMS: 25); + + Assert.IsTrue(result.Succeeded, $"The configured 1x initial zoom did not keep the 80px probe outside the 40px spotlight. Last sample: {result.LastObservation}."); + }); + } + + [TestMethod] + [TestCategory("MouseUtils")] + public void AnimationDurationIsApplied() + { + MouseUtilsTestHelper.RunWithClientAreaAnimationsEnabled(() => + { + WindowHelper.MinimizeWindow(new IntPtr(Session.WindowHandle)); + var (centerX, centerY) = WindowHelper.GetScreenCenter(); + MouseHelper.MoveTo(centerX, centerY); + var probeX = centerX + 80; + _ = MouseUtilsTestHelper.WaitForWindowClass(WindowClass); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + Assert.IsTrue(NamedEventHelper.WaitAndSignal(NamedEventHelper.FindMyMouseTrigger), "Find My Mouse trigger event was unavailable."); + var initialSpotlight = WaitHelper.WaitForStable( + () => new + { + Center = WindowHelper.GetPixelColor(centerX + 20, centerY), + Probe = WindowHelper.GetPixelColor(probeX, centerY), + }, + sample => sample is not null && sample.Center.G > sample.Center.R && sample.Probe.G > sample.Probe.R, + 8_000, + pollIntervalMS: 20); + Assert.IsTrue( + initialSpotlight.Succeeded, + $"The 9x initial spotlight never covered the 80px probe; last sample: {initialSpotlight.LastObservation}."); + var finalRadius = WaitHelper.WaitForStable( + () => new + { + Center = WindowHelper.GetPixelColor(centerX + 20, centerY), + Probe = WindowHelper.GetPixelColor(probeX, centerY), + }, + sample => sample is not null && sample.Center.G > sample.Center.R && sample.Probe.R > sample.Probe.G, + 12_000, + requiredConsecutiveMatches: 3, + pollIntervalMS: 20); + Assert.IsTrue(finalRadius.Succeeded, "The spotlight did not reach its configured 40px radius after the 10-second animation."); + var crossingTime = stopwatch.Elapsed; + Assert.IsTrue( + crossingTime >= TimeSpan.FromSeconds(3) && crossingTime <= TimeSpan.FromSeconds(12), + $"Configured 10-second spotlight animation crossed the 80px probe after {crossingTime.TotalSeconds:F1}s; expected 3-12s for the eased transition."); + }); + } + + [TestMethod] + [TestCategory("MouseUtils")] + public void RightControlActivates() + { + AssertActivation(() => DoubleTap(Key.RCtrl), "double right Ctrl"); + } + + [TestMethod] + [TestCategory("MouseUtils")] + public void CustomShortcutActivates() + { + AssertActivation(() => KeyboardHelper.SendKeys(Key.LWin, Key.Shift, Key.F), "Win+Shift+F"); + } + + [TestMethod] + [TestCategory("MouseUtils")] + public void IncludeWinKeyGatesDoubleControlActivation() + { + var window = MouseUtilsTestHelper.WaitForWindowClass(WindowClass); + using (var withoutWin = new WindowShowWatcher(WindowClass, window.Hwnd.ToInt64())) + { + DoubleTap(Key.LCtrl); + Assert.IsFalse(withoutWin.Wait(1_500), "Double Ctrl activated despite the required Windows key not being held."); + } + + using var withWin = new WindowShowWatcher(WindowClass, window.Hwnd.ToInt64()); + KeyboardHelper.PressKey(Key.LWin); + try + { + DoubleTap(Key.LCtrl); + } + finally + { + KeyboardHelper.ReleaseKey(Key.LWin); + } + + Assert.IsTrue(withWin.Wait(5_000), "Win plus double Ctrl did not activate Find My Mouse."); + } + + [TestMethod] + [TestCategory("MouseUtils")] + public void ExcludedForegroundAppBlocksActivation() + { + var settingsHwnd = new IntPtr(Session.WindowHandle); + Assert.IsTrue(WindowControl.WaitForForeground(settingsHwnd, 5_000, 2), "Settings could not become foreground for the exclusion precondition."); + using (var excluded = new WindowShowWatcher(WindowClass)) + { + Assert.IsTrue(NamedEventHelper.WaitAndSignal(NamedEventHelper.FindMyMouseTrigger), "Find My Mouse trigger event was unavailable."); + Assert.IsFalse(excluded.Wait(1_500), "Find My Mouse activated while excluded PowerToys.Settings.exe owned foreground."); + } + + using var notepad = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("notepad.exe") { UseShellExecute = true }); + Assert.IsNotNull(notepad, "Could not start the allowed foreground Notepad fixture."); + try + { + var notepadWindow = WindowsFinder.WaitForWindowByProcess("notepad", 10_000); + Assert.IsNotNull(notepadWindow, "Notepad did not create a visible window."); + Assert.IsTrue( + WindowControl.WaitForForeground(new IntPtr(notepadWindow.WindowHandle), 5_000, 2), + $"Notepad could not become foreground. Current foreground: {WindowControl.GetForegroundWindowInfo()}."); + + using var allowed = new WindowShowWatcher(WindowClass); + Assert.IsTrue(NamedEventHelper.TrySignal(NamedEventHelper.FindMyMouseTrigger), "Find My Mouse trigger event disappeared."); + Assert.IsTrue(allowed.Wait(5_000), "Find My Mouse remained blocked after an allowed app gained foreground."); + } + finally + { + WindowControl.TryKillProcessTreeByNameAndWait("notepad", 5_000); + } + } + + private static string CreateSettings(FindMyMouseConfiguration configuration) => $$""" + { + "name": "FindMyMouse", + "version": "1.1", + "properties": { + "activation_method": { "value": {{configuration.ActivationMethod}} }, + "include_win_key": { "value": {{configuration.IncludeWinKey.ToString().ToLowerInvariant()}} }, + "activation_shortcut": { "win": true, "ctrl": false, "alt": false, "shift": true, "code": 70, "key": "" }, + "do_not_activate_on_game_mode": { "value": false }, + "background_color": { "value": "{{configuration.BackgroundColor}}" }, + "spotlight_color": { "value": "{{configuration.SpotlightColor}}" }, + "spotlight_radius": { "value": {{configuration.Radius}} }, + "animation_duration_ms": { "value": {{configuration.AnimationDurationMs}} }, + "spotlight_initial_zoom": { "value": {{configuration.InitialZoom}} }, + "excluded_apps": { "value": "{{configuration.ExcludedApps}}" }, + "shaking_minimum_distance": { "value": 100 }, + "shaking_interval_ms": { "value": 2000 }, + "shaking_factor": { "value": 150 } + } + } + """; + + private static void DoubleTap(Key controlKey) + { + KeyboardHelper.SendKey(controlKey); + Thread.Sleep(150); + KeyboardHelper.SendKey(controlKey); + } + + private void AssertActivation(Action trigger, string description) + { + var window = MouseUtilsTestHelper.WaitForWindowClass(WindowClass); + using var watcher = new WindowShowWatcher(WindowClass, window.Hwnd.ToInt64()); + TriggerUntilShown(watcher, trigger, description); + } + + private static void TriggerUntilShown(WindowShowWatcher watcher, Action trigger, string description) + { + for (var attempt = 1; attempt <= 3; attempt++) + { + if (watcher.Wait(0)) + { + return; + } + + trigger(); + if (watcher.Wait(5_000)) + { + return; + } + } + + Assert.Fail($"Find My Mouse did not activate from {description} after three attempts."); + } + + private static Color Blend(Color foreground, Color background, int alpha) + { + var inverse = 255 - alpha; + return Color.FromArgb( + ((foreground.R * alpha) + (background.R * inverse) + 127) / 255, + ((foreground.G * alpha) + (background.G * inverse) + 127) / 255, + ((foreground.B * alpha) + (background.B * inverse) + 127) / 255); + } + + private static Color GetStablePixel(int x, int y) + { + Color? previous = null; + var result = WaitHelper.WaitForStable( + () => WindowHelper.GetPixelColor(x, y), + color => + { + var matchesPrevious = previous.HasValue && color.ToArgb() == previous.Value.ToArgb(); + previous = color; + return matchesPrevious; + }, + 2_000, + requiredConsecutiveMatches: 4, + pollIntervalMS: 100); + return result.LastObservation; + } + + private static void AssertPixelNear(int x, int y, Color expected, string description) + { + const int tolerance = 4; + var result = WaitHelper.WaitForStable( + () => WindowHelper.GetPixelColor(x, y), + actual => + Math.Abs(actual.R - expected.R) <= tolerance && + Math.Abs(actual.G - expected.G) <= tolerance && + Math.Abs(actual.B - expected.B) <= tolerance, + 5_000, + requiredConsecutiveMatches: 2, + pollIntervalMS: 100); + Assert.IsTrue(result.Succeeded, $"Unexpected {description} at ({x},{y}). Expected {expected}; observed {result.LastObservation}."); + } + + private sealed record FindMyMouseConfiguration( + int ActivationMethod = 0, + bool IncludeWinKey = false, + string BackgroundColor = "#FFFF0000", + string SpotlightColor = "#FF00FF00", + int Radius = 80, + int AnimationDurationMs = 1, + int InitialZoom = 1, + string ExcludedApps = ""); +} diff --git a/src/modules/MouseUtils/MouseUtils.UITests.Next/MouseHighlighterTests.cs b/src/modules/MouseUtils/MouseUtils.UITests.Next/MouseHighlighterTests.cs new file mode 100644 index 0000000000..9dcd693164 --- /dev/null +++ b/src/modules/MouseUtils/MouseUtils.UITests.Next/MouseHighlighterTests.cs @@ -0,0 +1,613 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Drawing; +using Microsoft.PowerToys.UITest.Next; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace MouseUtils.UITests; + +[TestClass] +public class MouseHighlighterTests : UITestBase +{ + private const string ModuleName = "MouseHighlighter"; + private const string ToggleId = "MouseUtils_MouseHighlighterToggleId"; + private const string WindowClass = "MouseHighlighter"; + private static readonly IDisposable ModuleSettings = SettingsConfigHelper.PreserveModuleSettings(ModuleName); + private static IDisposable? clientAreaAnimations; + + public MouseHighlighterTests() + : base(PowerToysModule.PowerToysSettings, enableModules: new[] { ModuleName }) + { + } + + [ClassInitialize] + public static void PrepareClass(TestContext testContext) + { + _ = testContext; + clientAreaAnimations = MouseUtilsTestHelper.PreserveClientAreaAnimationsEnabled(); + } + + [ClassCleanup] + public static void RestoreClassState() + { + try + { + clientAreaAnimations?.Dispose(); + } + finally + { + ModuleSettings.Dispose(); + } + } + + protected override void PrepareTestState() + { + var configuration = TestContext.TestName switch + { + nameof(ChangedShortcutTogglesAndDisabledModuleRejectsActivation) => new HighlighterConfiguration(ShortcutCode: (int)Key.O), + nameof(CircleAlphaRadiusAndFadeTimingAreApplied) => new HighlighterConfiguration( + LeftColor: "#80FF0000", + RightColor: "#8000FF00", + Radius: 100, + FadeDelayMs: 2_000, + FadeDurationMs: 4_000), + nameof(SpotlightModeUsesAlwaysColorAndRadius) => new HighlighterConfiguration( + AlwaysColor: "#80FF0000", + Radius: 80, + SpotlightMode: true), + nameof(RippleQuickClickUsesSizeIntensityAndDuration) => RippleConfiguration(), + nameof(RippleHeldIndicatorFollowsDragWhenEnabled) => RippleConfiguration(showDragTrail: true), + nameof(RippleHeldIndicatorStaysAtPressWhenDisabled) => RippleConfiguration(showDragTrail: false), + nameof(RippleRightReleasePulseIsDrawn) => RippleConfiguration(showReleasePulse: true), + nameof(AutoActivateStartsVisible) => new HighlighterConfiguration(AutoActivate: true), + _ => new HighlighterConfiguration(), + }; + + MouseUtilsTestHelper.ReplaceModuleSettings(ModuleName, CreateSettings(configuration)); + } + + [TestCleanup] + public async Task CleanupInput() + { + await CaptureFailureArtifactsBeforeCleanupAsync(); + MouseHelper.LeftUp(); + MouseHelper.RightUp(); + KeyboardHelper.ReleaseKey(Key.LWin); + KeyboardHelper.ReleaseKey(Key.Shift); + } + + [TestMethod] + [TestCategory("MouseUtils")] + [TestCategory("Mouse Utils #17")] + [TestCategory("Mouse Utils #18")] + [TestCategory("Mouse Utils #19")] + [TestCategory("Mouse Utils #20")] + public void CircleClicksAndDragsFollowCursor() + { + var window = MouseUtilsTestHelper.WaitForWindowClass(WindowClass); + WindowHelper.MinimizeWindow(new IntPtr(Session.WindowHandle)); + using var activationWatcher = new WindowShowWatcher(WindowClass, window.Hwnd.ToInt64()); + KeyboardHelper.SendKeys(Key.LWin, Key.Shift, Key.H); + Assert.IsTrue(activationWatcher.Wait(5_000), "The default Win+Shift+H shortcut did not show Mouse Highlighter."); + var (centerX, centerY) = WindowHelper.GetScreenCenter(); + MouseHelper.MoveTo(centerX, centerY); + + MouseHelper.LeftDown(); + AssertPixelNear(centerX + 25, centerY, Color.Red, "left-button circle"); + MouseHelper.MoveBy(180, 100, steps: 20, delayMs: 20); + var moved = MouseHelper.GetMousePosition(); + AssertColorNearPoint(moved.X, moved.Y, Color.Red, 45, "left-button circle did not follow the drag"); + MouseHelper.LeftUp(); + + MouseHelper.MoveTo(centerX, centerY); + MouseHelper.RightDown(); + AssertPixelNear(centerX + 25, centerY, Color.Lime, "right-button circle"); + MouseHelper.MoveBy(-180, 100, steps: 20, delayMs: 20); + moved = MouseHelper.GetMousePosition(); + AssertColorNearPoint(moved.X, moved.Y, Color.Lime, 45, "right-button circle did not follow the drag"); + MouseHelper.RightUp(); + + KeyboardHelper.SendKeys(Key.LWin, Key.Shift, Key.H); + Assert.IsTrue(activationWatcher.WaitForHidden(5_000), "The default shortcut did not hide Mouse Highlighter."); + using var deactivatedClick = new WindowShowWatcher(WindowClass, window.Hwnd.ToInt64()); + MouseHelper.LeftClickAt(centerX, centerY); + Assert.IsFalse(deactivatedClick.Wait(1_000), "A click showed Mouse Highlighter after the shortcut toggled it off."); + } + + [TestMethod] + [TestCategory("MouseUtils")] + [TestCategory("Mouse Utils #21")] + [TestCategory("Mouse Utils #22")] + public void ChangedShortcutTogglesAndDisabledModuleRejectsActivation() + { + var window = MouseUtilsTestHelper.WaitForWindowClass(WindowClass); + using (var enabledWatcher = new WindowShowWatcher(WindowClass, window.Hwnd.ToInt64())) + { + KeyboardHelper.SendKeys(Key.LWin, Key.Shift, Key.O); + Assert.IsTrue(enabledWatcher.Wait(5_000), "Changed Win+Shift+O shortcut did not show Mouse Highlighter."); + KeyboardHelper.SendKeys(Key.LWin, Key.Shift, Key.O); + Assert.IsTrue(enabledWatcher.WaitForHidden(5_000), "Changed shortcut did not hide Mouse Highlighter."); + } + + MouseUtilsTestHelper.NavigateToMouseUtilities(this); + MouseUtilsTestHelper.SetModuleEnabled(this, ToggleId, false); + Assert.IsTrue( + NamedEventHelper.WaitUntilUnavailable(NamedEventHelper.MouseHighlighterToggle), + "Mouse Highlighter trigger event remained available after disabling the module."); + using var disabledWatcher = new WindowShowWatcher(WindowClass); + KeyboardHelper.SendKeys(Key.LWin, Key.Shift, Key.O); + Assert.IsFalse(disabledWatcher.Wait(1_500), "Mouse Highlighter appeared from its shortcut while disabled."); + } + + [TestMethod] + [TestCategory("MouseUtils")] + [TestCategory("Mouse Utils #23")] + [TestCategory("Mouse Utils #24")] + public void CircleAlphaRadiusAndFadeTimingAreApplied() + { + MouseUtilsTestHelper.RunWithClientAreaAnimationsEnabled(() => + { + WindowHelper.MinimizeWindow(new IntPtr(Session.WindowHandle)); + var (centerX, centerY) = WindowHelper.GetScreenCenter(); + MouseHelper.MoveTo(centerX, centerY); + var inner = (X: centerX + 60, Y: centerY); + var fadePoint = (X: centerX + 35, Y: centerY); + var outer = (X: centerX + 130, Y: centerY); + var innerBase = GetStablePixel(inner.X, inner.Y); + var fadeBase = GetStablePixel(fadePoint.X, fadePoint.Y); + var outerBase = GetStablePixel(outer.X, outer.Y); + Activate(); + + MouseHelper.LeftDown(); + var expectedInner = Blend(Color.Red, innerBase, 128); + var expectedFadePoint = Blend(Color.Red, fadeBase, 128); + AssertPixelNear(inner.X, inner.Y, expectedInner, "semi-transparent left circle inside its 100px radius and 70px pressed radius"); + AssertPixelNear(fadePoint.X, fadePoint.Y, expectedFadePoint, "semi-transparent left circle inside its pressed radius"); + AssertPixelNear(outer.X, outer.Y, outerBase, "desktop outside the configured 100px radius"); + var fullContrast = ColorDistance(expectedFadePoint, fadeBase); + var fadeStopwatch = System.Diagnostics.Stopwatch.StartNew(); + MouseHelper.LeftUp(); + + var fadeStarted = WaitHelper.WaitForStable( + () => WindowHelper.GetPixelColor(fadePoint.X, fadePoint.Y), + color => ColorDistance(color, fadeBase) <= fullContrast * 0.9, + 4_000, + pollIntervalMS: 20); + Assert.IsTrue(fadeStarted.Succeeded, "The circle did not begin fading after the button was released."); + var fadeStartedAt = fadeStopwatch.Elapsed; + Assert.IsTrue( + fadeStartedAt >= TimeSpan.FromSeconds(1.4) && fadeStartedAt <= TimeSpan.FromSeconds(3.5), + $"Configured 2-second fade delay began after {fadeStartedAt.TotalSeconds:F1}s; expected 1.4-3.5s."); + var fadeCompleted = WaitHelper.WaitForStable( + () => WindowHelper.GetPixelColor(fadePoint.X, fadePoint.Y), + color => IsNear(color, fadeBase, 5), + 7_000, + requiredConsecutiveMatches: 3, + pollIntervalMS: 50); + Assert.IsTrue(fadeCompleted.Succeeded, "Circle did not return to the desktop color after its configured fade duration."); + var observedFadeDuration = fadeStopwatch.Elapsed - fadeStartedAt; + Assert.IsTrue( + observedFadeDuration >= TimeSpan.FromSeconds(1.5) && observedFadeDuration <= TimeSpan.FromSeconds(6.5), + $"Configured 4-second fade completed {observedFadeDuration.TotalSeconds:F1}s after its first visible transition; expected 1.5-6.5s."); + + MouseHelper.RightDown(); + var expectedRight = Blend(Color.Lime, fadeBase, 128); + AssertPixelNear(fadePoint.X, fadePoint.Y, expectedRight, "semi-transparent right circle inside its 70px radius"); + MouseHelper.RightUp(); + }); + } + + [TestMethod] + [TestCategory("MouseUtils")] + public void SpotlightModeUsesAlwaysColorAndRadius() + { + WindowHelper.MinimizeWindow(new IntPtr(Session.WindowHandle)); + var (centerX, centerY) = WindowHelper.GetScreenCenter(); + MouseHelper.MoveTo(centerX, centerY); + var inside = (X: centerX + 40, Y: centerY); + var outside = (X: centerX + 120, Y: centerY); + var insideBase = GetStablePixel(inside.X, inside.Y); + var outsideBase = GetStablePixel(outside.X, outside.Y); + MouseHelper.MoveBy(160, 80, steps: 20, delayMs: 20); + var calibratedTarget = MouseHelper.GetMousePosition(); + var movedInside = (X: calibratedTarget.X + 30, Y: calibratedTarget.Y); + var movedOutside = (X: calibratedTarget.X + 120, Y: calibratedTarget.Y); + var movedInsideBase = GetStablePixel(movedInside.X, movedInside.Y); + var movedOutsideBase = GetStablePixel(movedOutside.X, movedOutside.Y); + MouseHelper.MoveTo(centerX, centerY); + Activate(); + + AssertPixelNear(inside.X, inside.Y, insideBase, "transparent Spotlight hole inside the configured radius"); + AssertPixelNear(outside.X, outside.Y, Blend(Color.Red, outsideBase, 128), "Spotlight tint outside the configured radius"); + + MouseHelper.MoveBy(160, 80, steps: 20, delayMs: 20); + var moved = MouseHelper.GetMousePosition(); + Assert.IsTrue( + Distance(moved.X, moved.Y, calibratedTarget.X, calibratedTarget.Y) <= 10, + $"Calibrated relative movement ended at ({calibratedTarget.X},{calibratedTarget.Y}), but overlay movement ended at ({moved.X},{moved.Y})."); + AssertPixelNear(movedInside.X, movedInside.Y, movedInsideBase, "transparent Spotlight hole after cursor movement"); + AssertPixelNear(movedOutside.X, movedOutside.Y, Blend(Color.Red, movedOutsideBase, 128), "Spotlight tint after cursor movement"); + } + + [TestMethod] + [TestCategory("MouseUtils")] + public void RippleQuickClickUsesSizeIntensityAndDuration() + { + WindowHelper.MinimizeWindow(new IntPtr(Session.WindowHandle)); + var (centerX, centerY) = WindowHelper.GetScreenCenter(); + var nearPoint = (X: centerX + 10, Y: centerY); + var farPoint = (X: centerX + 100, Y: centerY); + var defaultMaximumRadius = (int)Math.Ceiling(60 * 1.4); + var configuredMaximumRadius = (int)Math.Ceiling(120 * 1.4); + var captureRadius = configuredMaximumRadius + 10; + MouseHelper.MoveTo(centerX - 250, centerY - 200); + var nearBase = GetStablePixel(nearPoint.X, nearPoint.Y); + var farBase = GetStablePixel(farPoint.X, farPoint.Y); + using var outerBaseline = CaptureSquare(centerX, centerY, captureRadius); + MouseHelper.MoveTo(centerX, centerY); + Activate(); + + MouseHelper.LeftClick(); + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + MouseHelper.MoveTo(centerX + 250, centerY + 200); + var expectedHighIntensityGlow = Blend(Color.Magenta, nearBase, 95); + Assert.IsTrue( + WaitHelper.WaitForStable( + () => WindowHelper.GetPixelColor(nearPoint.X, nearPoint.Y), + color => IsNear(color, expectedHighIntensityGlow, 20), + 350, + requiredConsecutiveMatches: 1, + pollIntervalMS: 10).Succeeded, + "The configured high-intensity Ripple glow did not render near the click point."); + if (stopwatch.ElapsedMilliseconds < 900) + { + Thread.Sleep(900 - (int)stopwatch.ElapsedMilliseconds); + } + + using var rippleFrame = CaptureSquare(centerX, centerY, captureRadius); + var changedOuterSamples = CountRipplePixels( + outerBaseline, + rippleFrame, + minimumRadius: defaultMaximumRadius + 4, + maximumRadius: configuredMaximumRadius + 4); + var missingRippleMessage = $"The configured 120px, 1.8-second Ripple was absent outside the default maximum radius after 900ms; " + + $"annulus={defaultMaximumRadius + 4}-{configuredMaximumRadius + 4}px, changed pixels={changedOuterSamples}."; + Assert.IsTrue(changedOuterSamples >= 12, missingRippleMessage); + Assert.IsTrue( + WaitHelper.WaitForStable( + () => new + { + Near = WindowHelper.GetPixelColor(nearPoint.X, nearPoint.Y), + Far = WindowHelper.GetPixelColor(farPoint.X, farPoint.Y), + }, + sample => sample is not null && IsNear(sample.Near, nearBase, 5) && IsNear(sample.Far, farBase, 5), + 2_000, + requiredConsecutiveMatches: 5, + pollIntervalMS: 50).Succeeded, + "Ripple remained after its configured 1.8-second duration."); + } + + [TestMethod] + [TestCategory("MouseUtils")] + public void RippleHeldIndicatorFollowsDragWhenEnabled() + { + AssertRippleDragTrail(expectedToFollow: true); + } + + [TestMethod] + [TestCategory("MouseUtils")] + public void RippleHeldIndicatorStaysAtPressWhenDisabled() + { + AssertRippleDragTrail(expectedToFollow: false); + } + + [TestMethod] + [TestCategory("MouseUtils")] + public void RippleRightReleasePulseIsDrawn() + { + WindowHelper.MinimizeWindow(new IntPtr(Session.WindowHandle)); + var (centerX, centerY) = WindowHelper.GetScreenCenter(); + MouseHelper.MoveTo(centerX, centerY); + Activate(); + + MouseHelper.RightDown(); + Thread.Sleep(350); + const int captureRadius = 55; + using var heldFrame = CaptureSquare(centerX, centerY, captureRadius); + MouseHelper.RightUp(); + MouseHelper.MoveTo(centerX + 250, centerY + 200); + var releasePulse = WaitHelper.WaitForStable( + () => + { + using var current = CaptureSquare(centerX, centerY, captureRadius); + return CountAxisPixelsTowardColor(heldFrame, current, Color.Yellow, minimumRadius: 18, maximumRadius: 50); + }, + changedPixels => changedPixels >= 20, + 1_500, + pollIntervalMS: 20); + Assert.IsTrue( + releasePulse.Succeeded, + $"Right-button release did not draw the configured yellow crosshair lines; maximum qualifying axis pixels={releasePulse.LastObservation}."); + } + + [TestMethod] + [TestCategory("MouseUtils")] + public void AutoActivateStartsVisible() + { + Assert.IsTrue( + WaitHelper.WaitForStable( + () => WindowControl.IsAnyWindowOfClassVisible(WindowClass), + visible => visible, + 10_000, + requiredConsecutiveMatches: 3).Succeeded, + "Mouse Highlighter did not start visible when auto-activate was enabled."); + } + + private static HighlighterConfiguration RippleConfiguration(bool showDragTrail = true, bool showReleasePulse = true) => new( + LeftColor: "#FFFF00FF", + RightColor: "#FFFFFF00", + AlwaysColor: "#00000000", + RippleMode: true, + RippleSize: 120, + RippleIntensity: 1.35, + RippleDurationMs: 1_800, + RippleShowDragTrail: showDragTrail, + RippleShowReleasePulse: showReleasePulse); + + private static string CreateSettings(HighlighterConfiguration configuration) => $$""" + { + "name": "MouseHighlighter", + "version": "1.2", + "properties": { + "activation_shortcut": { "win": true, "ctrl": false, "alt": false, "shift": true, "code": {{configuration.ShortcutCode}}, "key": "" }, + "left_button_click_color": { "value": "{{configuration.LeftColor}}" }, + "right_button_click_color": { "value": "{{configuration.RightColor}}" }, + "always_color": { "value": "{{configuration.AlwaysColor}}" }, + "highlight_radius": { "value": {{configuration.Radius}} }, + "highlight_fade_delay_ms": { "value": {{configuration.FadeDelayMs}} }, + "highlight_fade_duration_ms": { "value": {{configuration.FadeDurationMs}} }, + "auto_activate": { "value": {{configuration.AutoActivate.ToString().ToLowerInvariant()}} }, + "spotlight_mode": { "value": {{configuration.SpotlightMode.ToString().ToLowerInvariant()}} }, + "ripple_mode": { "value": {{configuration.RippleMode.ToString().ToLowerInvariant()}} }, + "ripple_size": { "value": {{configuration.RippleSize}} }, + "ripple_intensity": { "value": {{configuration.RippleIntensity.ToString(System.Globalization.CultureInfo.InvariantCulture)}} }, + "ripple_duration_ms": { "value": {{configuration.RippleDurationMs}} }, + "ripple_show_drag_trail": { "value": {{configuration.RippleShowDragTrail.ToString().ToLowerInvariant()}} }, + "ripple_show_release_pulse": { "value": {{configuration.RippleShowReleasePulse.ToString().ToLowerInvariant()}} } + } + } + """; + + private static void Activate() + { + Assert.IsTrue( + NamedEventHelper.WaitAndSignal(NamedEventHelper.MouseHighlighterToggle), + "Mouse Highlighter did not create or respond to its trigger event."); + Assert.IsTrue( + WaitHelper.WaitForStable( + () => WindowControl.IsAnyWindowOfClassVisible(WindowClass), + visible => visible, + 5_000, + requiredConsecutiveMatches: 2).Succeeded, + "Mouse Highlighter window did not become visible."); + } + + private void AssertRippleDragTrail(bool expectedToFollow) + { + WindowHelper.MinimizeWindow(new IntPtr(Session.WindowHandle)); + var (centerX, centerY) = WindowHelper.GetScreenCenter(); + MouseHelper.MoveTo(centerX, centerY); + Activate(); + + MouseHelper.LeftDown(); + Thread.Sleep(350); + AssertRippleRingNear(centerX, centerY, "held Ripple indicator did not appear at the press point"); + MouseHelper.MoveBy(180, 80, steps: 20, delayMs: 20); + var moved = MouseHelper.GetMousePosition(); + if (expectedToFollow) + { + AssertRippleRingNear(moved.X, moved.Y, "held Ripple indicator did not follow the drag"); + } + else + { + AssertRippleRingNear(centerX, centerY, "held Ripple indicator moved despite drag trail being disabled"); + } + + MouseHelper.LeftUp(); + } + + private static void AssertRippleRingNear(int centerX, int centerY, string message) + { + Assert.IsTrue( + WaitHelper.WaitForStable( + () => Enumerable.Range(42, 28) + .SelectMany(radius => new[] + { + WindowHelper.GetPixelColor(centerX + radius, centerY), + WindowHelper.GetPixelColor(centerX - radius, centerY), + WindowHelper.GetPixelColor(centerX, centerY + radius), + WindowHelper.GetPixelColor(centerX, centerY - radius), + }) + .Count(color => color.R > color.G + 30 && color.B > color.G + 30), + count => count >= 2, + 1_500, + requiredConsecutiveMatches: 1, + pollIntervalMS: 30).Succeeded, + message); + } + + private static void AssertColorNearPoint(int centerX, int centerY, Color expected, int searchRadius, string message) + { + Assert.IsTrue( + WaitHelper.WaitForStable( + () => Enumerable.Range(-searchRadius, (searchRadius * 2) + 1) + .Where(offset => offset % 5 == 0) + .SelectMany(offset => new[] + { + WindowHelper.GetPixelColor(centerX + offset, centerY), + WindowHelper.GetPixelColor(centerX, centerY + offset), + }) + .Any(color => IsNear(color, expected, 5)), + found => found, + 2_000, + requiredConsecutiveMatches: 1, + pollIntervalMS: 50).Succeeded, + message); + } + + private static Color GetStablePixel(int x, int y) + { + Color? previous = null; + var result = WaitHelper.WaitForStable( + () => WindowHelper.GetPixelColor(x, y), + color => + { + var matchesPrevious = previous.HasValue && color.ToArgb() == previous.Value.ToArgb(); + previous = color; + return matchesPrevious; + }, + 2_000, + requiredConsecutiveMatches: 4, + pollIntervalMS: 100); + return result.LastObservation; + } + + private static double Distance(int x1, int y1, int x2, int y2) + { + var deltaX = x2 - x1; + var deltaY = y2 - y1; + return Math.Sqrt((deltaX * deltaX) + (deltaY * deltaY)); + } + + private static Bitmap CaptureSquare(int centerX, int centerY, int radius) + { + var bitmap = new Bitmap((radius * 2) + 1, (radius * 2) + 1); + try + { + using var graphics = Graphics.FromImage(bitmap); + graphics.CopyFromScreen(centerX - radius, centerY - radius, 0, 0, bitmap.Size); + return bitmap; + } + catch + { + bitmap.Dispose(); + throw; + } + } + + private static int CountRipplePixels(Bitmap baseline, Bitmap current, int minimumRadius, int maximumRadius) + { + var center = baseline.Width / 2; + var minimumSquared = minimumRadius * minimumRadius; + var maximumSquared = maximumRadius * maximumRadius; + var count = 0; + for (var y = 0; y < baseline.Height; y++) + { + var deltaY = y - center; + for (var x = 0; x < baseline.Width; x++) + { + var deltaX = x - center; + var distanceSquared = (deltaX * deltaX) + (deltaY * deltaY); + if (distanceSquared < minimumSquared || distanceSquared > maximumSquared) + { + continue; + } + + var before = baseline.GetPixel(x, y); + var after = current.GetPixel(x, y); + if (ColorDistance(after, Color.Magenta) <= ColorDistance(before, Color.Magenta) - 6) + { + count++; + } + } + } + + return count; + } + + private static int CountAxisPixelsTowardColor(Bitmap baseline, Bitmap current, Color target, int minimumRadius, int maximumRadius) + { + var center = baseline.Width / 2; + var count = 0; + for (var radius = minimumRadius; radius <= maximumRadius; radius++) + { + for (var bandOffset = -2; bandOffset <= 2; bandOffset++) + { + var points = new[] + { + (X: center + radius, Y: center + bandOffset), + (X: center - radius, Y: center + bandOffset), + (X: center + bandOffset, Y: center + radius), + (X: center + bandOffset, Y: center - radius), + }; + foreach (var point in points) + { + var beforeDistance = ColorDistance(baseline.GetPixel(point.X, point.Y), target); + var afterDistance = ColorDistance(current.GetPixel(point.X, point.Y), target); + if (afterDistance <= 60 && afterDistance <= beforeDistance - 20) + { + count++; + } + } + } + } + + return count; + } + + private static double ColorDistance(Color first, Color second) + { + var red = first.R - second.R; + var green = first.G - second.G; + var blue = first.B - second.B; + return Math.Sqrt((red * red) + (green * green) + (blue * blue)); + } + + private static Color Blend(Color foreground, Color background, int alpha) + { + var inverse = 255 - alpha; + return Color.FromArgb( + ((foreground.R * alpha) + (background.R * inverse) + 127) / 255, + ((foreground.G * alpha) + (background.G * inverse) + 127) / 255, + ((foreground.B * alpha) + (background.B * inverse) + 127) / 255); + } + + private static void AssertPixelNear(int x, int y, Color expected, string description) + { + Assert.IsTrue( + WaitHelper.WaitForStable( + () => WindowHelper.GetPixelColor(x, y), + color => IsNear(color, expected, 5), + 5_000, + requiredConsecutiveMatches: 2, + pollIntervalMS: 50).Succeeded, + $"Unexpected {description} at ({x},{y}); expected {expected}, observed {WindowHelper.GetPixelColor(x, y)}."); + } + + private static void AssertColorNear(Color actual, Color expected, int tolerance, string message) => + Assert.IsTrue(IsNear(actual, expected, tolerance), $"{message}. Expected {expected}; observed {actual}."); + + private static bool IsNear(Color actual, Color expected, int tolerance) => + Math.Abs(actual.R - expected.R) <= tolerance && + Math.Abs(actual.G - expected.G) <= tolerance && + Math.Abs(actual.B - expected.B) <= tolerance; + + private sealed record HighlighterConfiguration( + int ShortcutCode = (int)Key.H, + string LeftColor = "#FFFF0000", + string RightColor = "#FF00FF00", + string AlwaysColor = "#00000000", + int Radius = 50, + int FadeDelayMs = 100, + int FadeDurationMs = 300, + bool AutoActivate = false, + bool SpotlightMode = false, + bool RippleMode = false, + int RippleSize = 60, + double RippleIntensity = 0.7, + int RippleDurationMs = 480, + bool RippleShowDragTrail = true, + bool RippleShowReleasePulse = true); +} diff --git a/src/modules/MouseUtils/MouseUtils.UITests.Next/MouseJumpTests.cs b/src/modules/MouseUtils/MouseUtils.UITests.Next/MouseJumpTests.cs new file mode 100644 index 0000000000..4f928f7aa1 --- /dev/null +++ b/src/modules/MouseUtils/MouseUtils.UITests.Next/MouseJumpTests.cs @@ -0,0 +1,346 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics; +using System.Drawing; +using Microsoft.PowerToys.UITest.Next; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace MouseUtils.UITests; + +[TestClass] +public class MouseJumpTests : UITestBase +{ + private const string ModuleName = "MouseJump"; + private const string ProcessName = "PowerToys.MouseJump.WinUI3"; + private const string WindowTitle = "MouseJump.WinUI3"; + private const string ToggleId = "MouseUtils_MouseJumpToggleId"; + private static readonly IDisposable ModuleSettings = SettingsConfigHelper.PreserveModuleSettings(ModuleName); + + public MouseJumpTests() + : base(PowerToysModule.PowerToysSettings, enableModules: new[] { ModuleName }) + { + } + + protected override IReadOnlyList StaleProcessNames { get; } = new[] + { + "PowerToys", + "PowerToys.Settings", + "PowerToys.FancyZonesEditor", + ProcessName, + "PowerToys.MouseJumpUI", + }; + + [ClassCleanup] + public static void RestoreModuleSettings() => ModuleSettings.Dispose(); + + protected override void PrepareTestState() + { + var configuration = TestContext.TestName switch + { + nameof(ChangedShortcutActivatesPreview) => new MouseJumpConfiguration(ShortcutCode: (int)Key.Z), + nameof(ThumbnailSizeSetsPreviewBounds) => new MouseJumpConfiguration(Width: 640, Height: 480, PreviewType: "Compact"), + nameof(CustomPreviewStyleRendersConfiguredColors) => new MouseJumpConfiguration( + Width: 640, + Height: 480, + PreviewType: "Custom", + BackgroundColor1: "#FF00FF", + BackgroundColor2: "#FF00FF", + BorderThickness: 12, + BorderColor: "#00FF00", + BorderPadding: 12, + BezelThickness: 12, + BezelColor: "#FFFF00", + ScreenMargin: 10, + ScreenColor1: "#00FFFF", + ScreenColor2: "#00FFFF"), + _ => new MouseJumpConfiguration(), + }; + + MouseUtilsTestHelper.ReplaceModuleSettings(ModuleName, CreateSettings(configuration)); + } + + [TestCleanup] + public async Task CleanupWindows() + { + await CaptureFailureArtifactsBeforeCleanupAsync(); + KeyboardHelper.SendKeys(Key.Esc); + WindowControl.TryKillProcessTreeByNameAndWait("notepad", 5_000); + WindowControl.TryKillProcessTreeByNameAndWait(ProcessName, 10_000); + } + + [TestMethod] + [TestCategory("MouseUtils")] + [TestCategory("Mouse Utils #39")] + public void SettingsPageLoadsAndProcessRuns() + { + MouseUtilsTestHelper.NavigateToMouseUtilities(this); + var toggle = MouseUtilsTestHelper.SetModuleEnabled(this, ToggleId, true); + Assert.IsTrue(toggle.IsOn, "Mouse Jump toggle should be on."); + Assert.IsTrue(WaitForProcess(expected: true), "Mouse Jump WinUI3 process did not start."); + Assert.IsTrue( + NamedEventHelper.WaitUntilAvailable(NamedEventHelper.MouseJumpShowPreview), + "Mouse Jump did not create its show-preview event."); + var hwnd = WaitForPreviewHwnd(); + Assert.AreNotEqual(IntPtr.Zero, hwnd, "Mouse Jump did not create its hidden preview HWND."); + + using var shortcutWatcher = new WindowShowWatcher(GetPreviewWindow().ClassName, hwnd.ToInt64()); + KeyboardHelper.SendKeys(Key.LWin, Key.Shift, Key.D); + Assert.IsTrue(shortcutWatcher.Wait(10_000), "The default Win+Shift+D shortcut did not show Mouse Jump."); + } + + [TestMethod] + [TestCategory("MouseUtils")] + [TestCategory("Mouse Utils #39")] + [TestCategory("Mouse Utils #41")] + [TestCategory("Mouse Utils #45")] + public void PreviewClickMovesCursorAndDisableStopsActivation() + { + var preview = ShowPreview(); + var previewClassName = GetPreviewWindow().ClassName; + var bounds = WindowHelper.GetWindowBounds(new IntPtr(preview.WindowHandle)); + var clickX = bounds.Left + ((bounds.Right - bounds.Left) / 2); + var clickY = bounds.Top + ((bounds.Bottom - bounds.Top) / 2); + using (var watcher = new WindowShowWatcher(GetPreviewWindow().ClassName, preview.WindowHandle)) + { + var previewHwnd = new IntPtr(preview.WindowHandle); + WindowControl.WaitForForeground(previewHwnd, 2_000); + Assert.IsTrue( + WindowControl.IsPointOwnedByWindow(previewHwnd, clickX, clickY), + $"Mouse Jump did not own its preview midpoint ({clickX},{clickY}) before the click."); + MouseHelper.LeftClickAt(clickX, clickY); + Assert.IsTrue(watcher.WaitForHidden(5_000), "Clicking the Mouse Jump preview did not hide it."); + } + + var primary = MonitorInfo.GetPrimary(); + Assert.IsNotNull(primary, "No primary monitor was reported."); + var expectedX = primary.Left + (primary.Width / 2); + var expectedY = primary.Top + (primary.Height / 2); + var cursor = MouseHelper.GetMousePosition(); + Assert.IsTrue( + Distance(cursor.X, cursor.Y, expectedX, expectedY) <= 20, + $"Preview-center click mapped to ({cursor.X},{cursor.Y}), expected primary midpoint ({expectedX},{expectedY})."); + + MouseUtilsTestHelper.NavigateToMouseUtilities(this); + MouseUtilsTestHelper.SetModuleEnabled(this, ToggleId, false); + Assert.IsTrue(WaitForProcess(expected: false), "Mouse Jump process did not exit after disabling the module."); + using var disabledActivation = new WindowShowWatcher(previewClassName); + KeyboardHelper.SendKeys(Key.LWin, Key.Shift, Key.D); + Assert.IsFalse(disabledActivation.Wait(2_000), "Mouse Jump preview appeared while the module was disabled."); + Assert.IsFalse(WaitForProcess(expected: true, timeoutMs: 1_000), "Mouse Jump process restarted while the module was disabled."); + } + + [TestMethod] + [TestCategory("MouseUtils")] + [TestCategory("Mouse Utils #40")] + public void ChangedShortcutActivatesPreview() + { + var hwnd = WaitForPreviewHwnd(); + var className = GetPreviewWindow().ClassName; + using (var defaultShortcut = new WindowShowWatcher(className, hwnd.ToInt64())) + { + KeyboardHelper.SendKeys(Key.LWin, Key.Shift, Key.D); + Assert.IsFalse(defaultShortcut.Wait(1_500), "The old Mouse Jump shortcut still showed the preview."); + } + + using var changedShortcut = new WindowShowWatcher(className, hwnd.ToInt64()); + KeyboardHelper.SendKeys(Key.LWin, Key.Shift, Key.Z); + Assert.IsTrue(changedShortcut.Wait(10_000), "Changed Win+Shift+Z shortcut did not show Mouse Jump."); + } + + [TestMethod] + [TestCategory("MouseUtils")] + public void FocusLossDismissesPreview() + { + var preview = ShowPreview(); + Assert.IsTrue( + WindowControl.WaitForForeground(new IntPtr(preview.WindowHandle), 5_000, 2), + $"Mouse Jump was not foreground before the focus-loss transition. Current foreground: {WindowControl.GetForegroundWindowInfo()}."); + using var focusWatcher = new WindowShowWatcher(GetPreviewWindow().ClassName, preview.WindowHandle); + using var notepad = Process.Start(new ProcessStartInfo("notepad.exe") { UseShellExecute = true }); + Assert.IsNotNull(notepad, "Could not start the focus-loss Notepad fixture."); + var notepadWindow = WindowsFinder.WaitForWindowByProcess("notepad", 10_000); + Assert.IsNotNull(notepadWindow, "Notepad did not create a visible window."); + Assert.IsTrue( + WindowControl.WaitForForeground(new IntPtr(notepadWindow.WindowHandle), 5_000, 2), + "Notepad could not gain foreground to exercise Mouse Jump focus-loss dismissal."); + Assert.IsTrue(focusWatcher.WaitForHidden(5_000), "Mouse Jump did not dismiss after losing focus."); + } + + [TestMethod] + [TestCategory("MouseUtils")] + public void ThumbnailSizeSetsPreviewBounds() + { + var preview = ShowPreview(); + var bounds = WindowHelper.GetWindowBounds(new IntPtr(preview.WindowHandle)); + var width = bounds.Right - bounds.Left; + var height = bounds.Bottom - bounds.Top; + Assert.IsTrue(width is >= 620 and <= 650, $"Configured 640px preview width rendered as {width}px."); + Assert.IsTrue(height <= 480, $"Configured 480px maximum preview height rendered as {height}px."); + + var primary = MonitorInfo.GetPrimary(); + Assert.IsNotNull(primary, "No primary monitor was reported."); + var renderedContentAspectRatio = (width - 12d) / (height - 12d); + var displayAspectRatio = primary.Width / (double)primary.Height; + Assert.IsTrue( + Math.Abs(renderedContentAspectRatio - displayAspectRatio) <= 0.02, + $"Preview content aspect ratio {renderedContentAspectRatio:F3} did not preserve display ratio {displayAspectRatio:F3}."); + } + + [TestMethod] + [TestCategory("MouseUtils")] + public void CustomPreviewStyleRendersConfiguredColors() + { + var preview = ShowPreview(); + var path = Path.Combine(TestContext.TestResultsDirectory ?? Path.GetTempPath(), $"mouse-jump-custom-{Guid.NewGuid():N}.png"); + WindowHelper.CaptureVisibleWindow(new IntPtr(preview.WindowHandle), path); + try + { + using var image = new Bitmap(path); + var colors = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["#FF00FF"] = 0, + ["#00FF00"] = 0, + ["#FFFF00"] = 0, + }; + + for (var y = 0; y < image.Height; y += 2) + { + for (var x = 0; x < image.Width; x += 2) + { + var pixel = image.GetPixel(x, y); + foreach (var key in colors.Keys.ToArray()) + { + var expected = ColorTranslator.FromHtml(key); + if (IsNear(pixel, expected, 4)) + { + colors[key]++; + } + } + } + } + + Assert.IsTrue(colors["#FF00FF"] > 100, $"Custom canvas background was not rendered; magenta sample count={colors["#FF00FF"]}."); + Assert.IsTrue(colors["#00FF00"] > 100, $"Custom canvas border was not rendered; green sample count={colors["#00FF00"]}."); + Assert.IsTrue(colors["#FFFF00"] > 100, $"Custom screen bezel was not rendered; yellow sample count={colors["#FFFF00"]}."); + } + finally + { + File.Delete(path); + } + } + + private static string CreateSettings(MouseJumpConfiguration configuration) => $$""" + { + "name": "MouseJump", + "version": "1.1", + "properties": { + "activation_shortcut": { "win": true, "ctrl": false, "alt": false, "shift": true, "code": {{configuration.ShortcutCode}}, "key": "" }, + "thumbnail_size": { "width": {{configuration.Width}}, "height": {{configuration.Height}} }, + "preview_type": "{{configuration.PreviewType}}", + "background_color_1": "{{configuration.BackgroundColor1}}", + "background_color_2": "{{configuration.BackgroundColor2}}", + "border_thickness": {{configuration.BorderThickness}}, + "border_color": "{{configuration.BorderColor}}", + "border_3d_depth": 0, + "border_padding": {{configuration.BorderPadding}}, + "bezel_thickness": {{configuration.BezelThickness}}, + "bezel_color": "{{configuration.BezelColor}}", + "bezel_3d_depth": 0, + "screen_margin": {{configuration.ScreenMargin}}, + "screen_color_1": "{{configuration.ScreenColor1}}", + "screen_color_2": "{{configuration.ScreenColor2}}" + } + } + """; + + private static Session ShowPreview() + { + Assert.IsTrue( + NamedEventHelper.WaitUntilAvailable(NamedEventHelper.MouseJumpShowPreview), + "Mouse Jump module did not create its show-preview event."); + if (!WaitForProcess(expected: true, timeoutMs: 1_000)) + { + KeyboardHelper.SendKeys(Key.LWin, Key.Shift, Key.D); + Assert.IsTrue(WaitForProcess(expected: true), "Mouse Jump WinUI3 process did not start after activation."); + } + + Assert.AreNotEqual(IntPtr.Zero, WaitForPreviewHwnd(), "Mouse Jump hidden preview HWND was not created."); + + for (var attempt = 1; attempt <= 3; attempt++) + { + Assert.IsTrue( + NamedEventHelper.WaitAndSignal(NamedEventHelper.MouseJumpShowPreview, 10_000), + "Mouse Jump show-preview event was unavailable."); + var preview = WindowsFinder.WaitForWindowByApp( + ProcessName, + window => window.Title.Equals(WindowTitle, StringComparison.OrdinalIgnoreCase), + timeoutMS: 5_000); + if (preview is not null) + { + WindowControl.WaitForForeground(new IntPtr(preview.WindowHandle), 2_000); + return preview; + } + } + + Assert.Fail("Mouse Jump preview did not become visible after three show-event attempts."); + return null!; + } + + private static WindowControl.ProcessWindow GetPreviewWindow() + { + var processIds = Process.GetProcessesByName(ProcessName).Select(process => process.Id).ToArray(); + return WindowControl.EnumerateProcessWindows(processIds) + .FirstOrDefault(window => window.Title.Equals(WindowTitle, StringComparison.OrdinalIgnoreCase)); + } + + private static IntPtr WaitForPreviewHwnd() + { + var result = WaitHelper.WaitForStable( + GetPreviewWindow, + window => window.Hwnd != IntPtr.Zero, + 15_000, + requiredConsecutiveMatches: 2, + pollIntervalMS: 100); + return result.LastObservation.Hwnd; + } + + private static bool WaitForProcess(bool expected, int timeoutMs = 15_000) + { + return WaitHelper.WaitForStable( + () => Process.GetProcessesByName(ProcessName).Length > 0, + running => running == expected, + timeoutMs, + requiredConsecutiveMatches: 2, + pollIntervalMS: 100).Succeeded; + } + + private static double Distance(int x1, int y1, int x2, int y2) + { + var deltaX = x2 - x1; + var deltaY = y2 - y1; + return Math.Sqrt((deltaX * deltaX) + (deltaY * deltaY)); + } + + private static bool IsNear(Color actual, Color expected, int tolerance) => + Math.Abs(actual.R - expected.R) <= tolerance && + Math.Abs(actual.G - expected.G) <= tolerance && + Math.Abs(actual.B - expected.B) <= tolerance; + + private sealed record MouseJumpConfiguration( + int ShortcutCode = (int)Key.D, + int Width = 800, + int Height = 600, + string PreviewType = "Bezelled", + string BackgroundColor1 = "#0D57D2", + string BackgroundColor2 = "#0344C0", + int BorderThickness = 6, + string BorderColor = "#0078D4", + int BorderPadding = 4, + int BezelThickness = 12, + string BezelColor = "#222222", + int ScreenMargin = 4, + string ScreenColor1 = "#191970", + string ScreenColor2 = "#191970"); + } diff --git a/src/modules/MouseUtils/MouseUtils.UITests.Next/MousePointerCrosshairsTests.cs b/src/modules/MouseUtils/MouseUtils.UITests.Next/MousePointerCrosshairsTests.cs new file mode 100644 index 0000000000..ed0a2c930a --- /dev/null +++ b/src/modules/MouseUtils/MouseUtils.UITests.Next/MousePointerCrosshairsTests.cs @@ -0,0 +1,414 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Drawing; +using Microsoft.PowerToys.UITest.Next; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace MouseUtils.UITests; + +[TestClass] +public class MousePointerCrosshairsTests : UITestBase +{ + private const string ModuleName = "MousePointerCrosshairs"; + private const string ToggleId = "MouseUtils_MousePointerCrosshairsToggleId"; + private const string WindowClass = "MousePointerCrosshairs"; + private const string CrosshairsColor = "#FF0000"; + + private static readonly IDisposable ModuleSettings = SettingsConfigHelper.PreserveModuleSettings(ModuleName); + + public MousePointerCrosshairsTests() + : base(PowerToysModule.PowerToysSettings, enableModules: new[] { ModuleName }) + { + } + + protected override void PrepareTestState() + { + var configuration = TestContext.TestName switch + { + "DisabledModuleRejectsActivationAndChangedShortcutWorks" => new CrosshairsConfiguration(ShortcutCode: (int)Key.O), + "HorizontalOrientationFixedLengthAndBorderAreApplied" => new CrosshairsConfiguration( + Color: "#00FF00", + Radius: 35, + Thickness: 12, + BorderColor: "#0000FF", + BorderSize: 6, + Orientation: 2, + FixedLengthEnabled: true, + FixedLength: 120), + nameof(OpacityBlendsWithDesktop) => new CrosshairsConfiguration(Opacity: 50), + "AutoActivateStartsVisible" => new CrosshairsConfiguration(AutoActivate: true), + _ => new CrosshairsConfiguration(), + }; + + MouseUtilsTestHelper.ReplaceModuleSettings(ModuleName, CreateSettings(configuration)); + } + + [ClassCleanup] + public static void RestoreModuleSettings() => ModuleSettings.Dispose(); + + [TestCleanup] + public async Task CleanupInput() + { + await CaptureFailureArtifactsBeforeCleanupAsync(); + MouseHelper.LeftUp(); + MouseHelper.RightUp(); + KeyboardHelper.ReleaseKey(Key.Ctrl); + KeyboardHelper.ReleaseKey(Key.Shift); + KeyboardHelper.ReleaseKey(Key.Alt); + KeyboardHelper.ReleaseKey(Key.LWin); + } + + [TestMethod] + [TestCategory("MouseUtils")] + [TestCategory("Mouse Utils #29")] + [TestCategory("Mouse Utils #30")] + public void ActivationTracksCursorAndHides() + { + MouseUtilsTestHelper.NavigateToMouseUtilities(this); + MouseUtilsTestHelper.SetModuleEnabled(this, ToggleId, true); + Key[] activationKeys = [Key.LWin, Key.Alt, Key.P]; + + var crosshairsWindow = MouseUtilsTestHelper.WaitForWindowClass(WindowClass); + Assert.IsFalse(crosshairsWindow.IsVisible, "Crosshairs should start hidden before activation."); + + WindowHelper.MinimizeWindow(new IntPtr(Session.WindowHandle)); + var (centerX, centerY) = WindowHelper.GetScreenCenter(); + MouseHelper.MoveTo(centerX, centerY); + + MouseUtilsTestHelper.Step(this, "Signaling the Crosshairs named event as a positive detector control"); + using (var detectorControl = new WindowShowWatcher(WindowClass, crosshairsWindow.Hwnd.ToInt64())) + { + Assert.IsTrue( + NamedEventHelper.WaitAndSignal(NamedEventHelper.MouseCrosshairsToggle, 10_000), + "The Crosshairs named event was not created by the enabled module."); + Assert.IsTrue( + detectorControl.Wait(5_000), + "The known-good named event did not produce a Crosshairs SHOW event."); + AssertCrosshairsAt(centerX, centerY); + + Assert.IsTrue( + NamedEventHelper.TrySignal(NamedEventHelper.MouseCrosshairsToggle), + "The Crosshairs named event disappeared before the positive control could hide the overlay."); + Assert.IsTrue( + detectorControl.WaitForHidden(5_000), + $"The positive-control Crosshairs HWND did not hide. Events: {string.Join(", ", detectorControl.Events)}"); + } + + using var watcher = new WindowShowWatcher(WindowClass, crosshairsWindow.Hwnd.ToInt64()); + MouseUtilsTestHelper.Step(this, "Sending the Crosshairs shortcut after the module-ready positive control"); + KeyboardHelper.SendKeys(activationKeys); + var shown = watcher.Wait(10_000); + + MouseUtilsTestHelper.Step(this, $"Crosshairs window events: {string.Join(", ", watcher.Events)}"); + Assert.IsTrue(shown, "Crosshairs did not emit a SHOW event after the activation shortcut."); + AssertCrosshairsAt(centerX, centerY); + + MouseUtilsTestHelper.Step(this, "Moving the cursor with relative input and checking the new crosshair origin"); + MouseHelper.MoveBy(160, 100, steps: 10); + var moved = MouseHelper.GetMousePosition(); + Assert.IsTrue( + Math.Abs(moved.X - centerX) > 50 || Math.Abs(moved.Y - centerY) > 50, + $"Relative input did not move the cursor far enough: ({centerX},{centerY}) -> ({moved.X},{moved.Y})."); + var movedOrigin = AssertCrosshairsNear(moved.X, moved.Y); + Assert.IsTrue( + Math.Abs(movedOrigin.X - centerX) > 50 || Math.Abs(movedOrigin.Y - centerY) > 50, + $"Crosshairs did not follow the cursor away from ({centerX},{centerY}); observed origin ({movedOrigin.X},{movedOrigin.Y})."); + + MouseUtilsTestHelper.Step(this, "Sending the shortcut again and waiting for the exact Crosshairs HWND to hide"); + KeyboardHelper.SendKeys(activationKeys); + Assert.IsTrue( + watcher.WaitForHidden(5_000), + $"Crosshairs HWND 0x{crosshairsWindow.Hwnd:X} did not hide. Events: {string.Join(", ", watcher.Events)}"); + Assert.IsFalse(WindowControl.IsAnyWindowOfClassVisible(WindowClass), "Crosshairs remained visible after the second shortcut."); + } + + [TestMethod] + [TestCategory("MouseUtils")] + [TestCategory("Mouse Utils #31")] + [TestCategory("Mouse Utils #32")] + public void DisabledModuleRejectsActivationAndChangedShortcutWorks() + { + MouseUtilsTestHelper.NavigateToMouseUtilities(this); + var toggle = MouseUtilsTestHelper.SetModuleEnabled(this, ToggleId, false); + Assert.IsTrue( + NamedEventHelper.WaitUntilUnavailable(NamedEventHelper.MouseCrosshairsToggle), + "Crosshairs trigger event remained available after disabling the module."); + + using (var disabledWatcher = new WindowShowWatcher(WindowClass)) + { + KeyboardHelper.SendKeys(Key.LWin, Key.Alt, Key.O); + Assert.IsFalse(disabledWatcher.Wait(1_500), "The changed shortcut showed Crosshairs while the module was disabled."); + } + + toggle.Toggle(true); + Assert.IsTrue(toggle.WaitForProperty("ToggleState", "On", 5_000), "Crosshairs toggle did not return to On."); + Assert.IsTrue( + NamedEventHelper.WaitUntilAvailable(NamedEventHelper.MouseCrosshairsToggle), + "Crosshairs trigger event was not recreated after enabling the module."); + var window = MouseUtilsTestHelper.WaitForWindowClass(WindowClass); + using var enabledWatcher = new WindowShowWatcher(WindowClass, window.Hwnd.ToInt64()); + KeyboardHelper.SendKeys(Key.LWin, Key.Alt, Key.O); + Assert.IsTrue(enabledWatcher.Wait(5_000), "The changed Win+Alt+O shortcut did not show Crosshairs."); + } + + [TestMethod] + [TestCategory("MouseUtils")] + [TestCategory("Mouse Utils #33")] + public void HorizontalOrientationFixedLengthAndBorderAreApplied() + { + WindowHelper.MinimizeWindow(new IntPtr(Session.WindowHandle)); + var (centerX, centerY) = WindowHelper.GetScreenCenter(); + MouseHelper.MoveTo(centerX, centerY); + Assert.IsTrue(NamedEventHelper.WaitAndSignal(NamedEventHelper.MouseCrosshairsToggle), "Crosshairs trigger event was unavailable."); + + var result = WaitHelper.WaitForStable( + () => new + { + Gap = WindowHelper.GetPixelColorHex(centerX + 25, centerY), + BorderStart = WindowHelper.GetPixelColorHex(centerX + 31, centerY), + Core = WindowHelper.GetPixelColorHex(centerX + 45, centerY), + CoreThickness = WindowHelper.GetPixelColorHex(centerX + 60, centerY + 5), + BorderThickness = WindowHelper.GetPixelColorHex(centerX + 60, centerY + 9), + OutsideThickness = WindowHelper.GetPixelColorHex(centerX + 60, centerY + 13), + CoreEnd = WindowHelper.GetPixelColorHex(centerX + 150, centerY), + BorderEnd = WindowHelper.GetPixelColorHex(centerX + 158, centerY), + Vertical = WindowHelper.GetPixelColorHex(centerX, centerY - 60), + Beyond = WindowHelper.GetPixelColorHex(centerX + 165, centerY), + }, + sample => sample is not null && + !sample.Gap.Equals("#00FF00", StringComparison.OrdinalIgnoreCase) && + sample.BorderStart.Equals("#0000FF", StringComparison.OrdinalIgnoreCase) && + sample.Core.Equals("#00FF00", StringComparison.OrdinalIgnoreCase) && + sample.CoreThickness.Equals("#00FF00", StringComparison.OrdinalIgnoreCase) && + sample.BorderThickness.Equals("#0000FF", StringComparison.OrdinalIgnoreCase) && + !sample.OutsideThickness.Equals("#00FF00", StringComparison.OrdinalIgnoreCase) && + !sample.OutsideThickness.Equals("#0000FF", StringComparison.OrdinalIgnoreCase) && + sample.CoreEnd.Equals("#00FF00", StringComparison.OrdinalIgnoreCase) && + sample.BorderEnd.Equals("#0000FF", StringComparison.OrdinalIgnoreCase) && + !sample.Vertical.Equals("#00FF00", StringComparison.OrdinalIgnoreCase) && + !sample.Vertical.Equals("#0000FF", StringComparison.OrdinalIgnoreCase) && + !sample.Beyond.Equals("#00FF00", StringComparison.OrdinalIgnoreCase) && + !sample.Beyond.Equals("#0000FF", StringComparison.OrdinalIgnoreCase), + timeoutMS: 5_000, + pollIntervalMS: 100); + + Assert.IsTrue(result.Succeeded, $"Horizontal fixed-length Crosshairs geometry did not match. Last sample: {result.LastObservation}."); + } + + [TestMethod] + [TestCategory("MouseUtils")] + public void OpacityBlendsWithDesktop() + { + WindowHelper.MinimizeWindow(new IntPtr(Session.WindowHandle)); + var (centerX, centerY) = WindowHelper.GetScreenCenter(); + MouseHelper.MoveTo(centerX, centerY); + var probeX = centerX + 60; + Color? previous = null; + var baseline = WaitHelper.WaitForStable( + () => WindowHelper.GetPixelColor(probeX, centerY), + color => + { + var matchesPrevious = previous.HasValue && color.ToArgb() == previous.Value.ToArgb(); + previous = color; + return matchesPrevious; + }, + 2_000, + requiredConsecutiveMatches: 4, + pollIntervalMS: 100).LastObservation; + + Assert.IsTrue(NamedEventHelper.WaitAndSignal(NamedEventHelper.MouseCrosshairsToggle), "Crosshairs trigger event was unavailable."); + var expected = Blend(Color.Red, baseline, 128); + var result = WaitHelper.WaitForStable( + () => WindowHelper.GetPixelColor(probeX, centerY), + color => IsNear(color, expected, 5), + 5_000, + requiredConsecutiveMatches: 3, + pollIntervalMS: 100); + + Assert.IsTrue(result.Succeeded, $"50% Crosshairs opacity did not blend as expected. Expected {expected}; observed {result.LastObservation}."); + } + + [TestMethod] + [TestCategory("MouseUtils")] + public void AutoActivateStartsVisible() + { + var result = WaitHelper.WaitForStable( + () => WindowControl.IsAnyWindowOfClassVisible(WindowClass), + visible => visible, + timeoutMS: 10_000, + requiredConsecutiveMatches: 3, + pollIntervalMS: 100); + Assert.IsTrue(result.Succeeded, "Crosshairs did not start visible when auto-activate was enabled."); + } + + [TestMethod] + [TestCategory("MouseUtils")] + public void GlidingCursorMovesAndEscapeCancels() + { + WindowHelper.MinimizeWindow(new IntPtr(Session.WindowHandle)); + var (centerX, centerY) = WindowHelper.GetScreenCenter(); + MouseHelper.MoveTo(centerX, centerY); + var window = MouseUtilsTestHelper.WaitForWindowClass(WindowClass); + using var watcher = new WindowShowWatcher(WindowClass, window.Hwnd.ToInt64()); + + KeyboardHelper.PressKey(Key.LWin); + KeyboardHelper.PressKey(Key.Alt); + try + { + KeyboardHelper.SendKey(Key.OemPeriod); + } + finally + { + KeyboardHelper.ReleaseKey(Key.Alt); + KeyboardHelper.ReleaseKey(Key.LWin); + } + + Assert.IsTrue(watcher.Wait(5_000), "The gliding-cursor shortcut did not show Crosshairs."); + var reset = WaitHelper.WaitForStable( + MouseHelper.GetMousePosition, + position => position.X < centerX - 100, + 5_000, + requiredConsecutiveMatches: 1, + pollIntervalMS: 20); + Assert.IsTrue(reset.Succeeded, $"Gliding cursor did not reset to the left side. Last position: {reset.LastObservation}."); + var resetPosition = reset.LastObservation; + var moved = WaitHelper.WaitForStable( + MouseHelper.GetMousePosition, + position => position.X >= resetPosition.X + 100, + 5_000, + requiredConsecutiveMatches: 2, + pollIntervalMS: 20); + Assert.IsTrue(moved.Succeeded, $"Gliding cursor did not travel at least 100px after reset. Reset: {resetPosition}; last: {moved.LastObservation}."); + + KeyboardHelper.SendKeys(Key.Esc); + Assert.IsTrue(watcher.WaitForHidden(5_000), "Escape did not hide the gliding-cursor Crosshairs."); + var stoppedAt = MouseHelper.GetMousePosition(); + var stopped = WaitHelper.WaitForStable( + MouseHelper.GetMousePosition, + position => Math.Abs(position.X - stoppedAt.X) <= 2 && Math.Abs(position.Y - stoppedAt.Y) <= 2, + 2_000, + requiredConsecutiveMatches: 5, + pollIntervalMS: 100); + Assert.IsTrue(stopped.Succeeded, "The cursor continued gliding after Escape."); + } + + private static void AssertCrosshairsAt(int cursorX, int cursorY) + { + var result = WaitHelper.WaitForStable( + () => new[] + { + WindowHelper.GetPixelColorHex(cursorX - 60, cursorY), + WindowHelper.GetPixelColorHex(cursorX + 60, cursorY), + WindowHelper.GetPixelColorHex(cursorX, cursorY - 60), + WindowHelper.GetPixelColorHex(cursorX, cursorY + 60), + }, + colors => colors is not null && colors.All(color => color.Equals(CrosshairsColor, StringComparison.OrdinalIgnoreCase)), + timeoutMS: 5_000, + requiredConsecutiveMatches: 3, + pollIntervalMS: 100); + + Assert.IsTrue( + result.Succeeded, + $"Expected red Crosshairs arms around ({cursorX},{cursorY}); last samples: {string.Join(", ", result.LastObservation ?? Array.Empty())}"); + } + + private static CrosshairsOrigin AssertCrosshairsNear(int cursorX, int cursorY) + { + const int searchRadius = 80; + const int maximumLag = 64; + var result = WaitHelper.WaitForStable( + () => FindCrosshairsOriginNear(cursorX, cursorY, searchRadius), + origin => origin is not null && + Math.Abs(origin.X - cursorX) <= maximumLag && + Math.Abs(origin.Y - cursorY) <= maximumLag, + timeoutMS: 5_000, + requiredConsecutiveMatches: 1, + pollIntervalMS: 100); + + Assert.IsTrue( + result.Succeeded, + $"Expected Crosshairs within {maximumLag}px of ({cursorX},{cursorY}); last observed origin: {result.LastObservation}."); + return result.LastObservation!; + } + + private static CrosshairsOrigin? FindCrosshairsOriginNear(int cursorX, int cursorY, int searchRadius) + { + var verticalProbeY = cursorY - searchRadius; + var horizontalProbeX = cursorX - searchRadius; + var verticalPixels = new List(); + var horizontalPixels = new List(); + + for (var offset = -searchRadius; offset <= searchRadius; offset += 2) + { + if (WindowHelper.GetPixelColorHex(cursorX + offset, verticalProbeY).Equals(CrosshairsColor, StringComparison.OrdinalIgnoreCase)) + { + verticalPixels.Add(cursorX + offset); + } + + if (WindowHelper.GetPixelColorHex(horizontalProbeX, cursorY + offset).Equals(CrosshairsColor, StringComparison.OrdinalIgnoreCase)) + { + horizontalPixels.Add(cursorY + offset); + } + } + + return verticalPixels.Count > 0 && horizontalPixels.Count > 0 + ? new CrosshairsOrigin((int)verticalPixels.Average(), (int)horizontalPixels.Average()) + : null; + } + + private static Color Blend(Color foreground, Color background, int alpha) + { + var inverse = 255 - alpha; + return Color.FromArgb( + ((foreground.R * alpha) + (background.R * inverse) + 127) / 255, + ((foreground.G * alpha) + (background.G * inverse) + 127) / 255, + ((foreground.B * alpha) + (background.B * inverse) + 127) / 255); + } + + private static bool IsNear(Color actual, Color expected, int tolerance) => + Math.Abs(actual.R - expected.R) <= tolerance && + Math.Abs(actual.G - expected.G) <= tolerance && + Math.Abs(actual.B - expected.B) <= tolerance; + + private static string CreateSettings(CrosshairsConfiguration configuration) => $$""" + { + "name": "MousePointerCrosshairs", + "version": "1.0", + "properties": { + "activation_shortcut": { "win": true, "ctrl": false, "alt": true, "shift": false, "code": {{configuration.ShortcutCode}}, "key": "" }, + "gliding_cursor_activation_shortcut": { "win": true, "ctrl": false, "alt": true, "shift": false, "code": 190, "key": "" }, + "crosshairs_color": { "value": "{{configuration.Color}}" }, + "crosshairs_opacity": { "value": {{configuration.Opacity}} }, + "crosshairs_radius": { "value": {{configuration.Radius}} }, + "crosshairs_thickness": { "value": {{configuration.Thickness}} }, + "crosshairs_border_color": { "value": "{{configuration.BorderColor}}" }, + "crosshairs_border_size": { "value": {{configuration.BorderSize}} }, + "crosshairs_orientation": { "value": {{configuration.Orientation}} }, + "crosshairs_auto_hide": { "value": {{configuration.AutoHide.ToString().ToLowerInvariant()}} }, + "crosshairs_is_fixed_length_enabled": { "value": {{configuration.FixedLengthEnabled.ToString().ToLowerInvariant()}} }, + "crosshairs_fixed_length": { "value": {{configuration.FixedLength}} }, + "auto_activate": { "value": {{configuration.AutoActivate.ToString().ToLowerInvariant()}} }, + "gliding_travel_speed": { "value": 25 }, + "gliding_delay_speed": { "value": 5 } + } + } + """; + + private sealed record CrosshairsOrigin(int X, int Y); + + private sealed record CrosshairsConfiguration( + int ShortcutCode = (int)Key.P, + string Color = "#FF0000", + int Opacity = 100, + int Radius = 20, + int Thickness = 9, + string BorderColor = "#00FF00", + int BorderSize = 0, + int Orientation = 0, + bool AutoHide = false, + bool FixedLengthEnabled = false, + int FixedLength = 100, + bool AutoActivate = false); +} diff --git a/src/modules/MouseUtils/MouseUtils.UITests.Next/MouseUtils.UITests.Next.csproj b/src/modules/MouseUtils/MouseUtils.UITests.Next/MouseUtils.UITests.Next.csproj new file mode 100644 index 0000000000..195e442fb2 --- /dev/null +++ b/src/modules/MouseUtils/MouseUtils.UITests.Next/MouseUtils.UITests.Next.csproj @@ -0,0 +1,35 @@ + + + + + + Exe + net10.0-windows10.0.26100.0 + enable + enable + false + false + MouseUtils.UITests + MouseUtils.UITests.Next + app.manifest + + true + true + false + + + false + + + + $(RepoRoot)$(Platform)\$(Configuration)\tests\MouseUtils.UITests.Next\ + + + + + + + + + + \ No newline at end of file diff --git a/src/modules/MouseUtils/MouseUtils.UITests.Next/MouseUtilsTestHelper.cs b/src/modules/MouseUtils/MouseUtils.UITests.Next/MouseUtilsTestHelper.cs new file mode 100644 index 0000000000..ac63585930 --- /dev/null +++ b/src/modules/MouseUtils/MouseUtils.UITests.Next/MouseUtilsTestHelper.cs @@ -0,0 +1,268 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.ExceptionServices; +using System.Runtime.InteropServices; +using System.Text.Json.Nodes; +using Microsoft.PowerToys.UITest.Next; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace MouseUtils.UITests; + +internal static class MouseUtilsTestHelper +{ + private static readonly string[] ShortcutSeparators = [" + ", "+", " "]; + + internal const string InputOutputNavItemId = "InputOutputNavItem"; + internal const string MouseUtilitiesNavItemId = "MouseUtilitiesNavItem"; + + internal static void NavigateToMouseUtilities(UITestBase testBase) + { + Step(testBase, "Navigating to Mouse Utilities settings"); + if (!testBase.Session.Has(By.AccessibilityId(MouseUtilitiesNavItemId), 500)) + { + testBase.Session.Find(By.AccessibilityId(InputOutputNavItemId), 5_000).Click(msPostAction: 500); + } + + testBase.Session.Find(By.AccessibilityId(MouseUtilitiesNavItemId), 5_000).Click(msPostAction: 800); + } + + internal static ToggleSwitch SetModuleEnabled(UITestBase testBase, string toggleId, bool enabled) + { + Step(testBase, $"Setting {toggleId} to {(enabled ? "On" : "Off")}"); + var expectedState = enabled ? "On" : "Off"; + ToggleSwitch? toggle = null; + for (var attempt = 1; attempt <= 3; attempt++) + { + toggle = testBase.Session.Find(By.AccessibilityId(toggleId), 10_000); + var actualState = toggle.GetProperty("ToggleState"); + if (actualState.Equals(expectedState, StringComparison.OrdinalIgnoreCase)) + { + return toggle; + } + + var oppositeState = enabled ? "Off" : "On"; + if (!actualState.Equals(oppositeState, StringComparison.OrdinalIgnoreCase)) + { + Step(testBase, $"{toggleId} returned unreadable ToggleState '{actualState}' on attempt {attempt}/3; reacquiring it"); + if (toggle.WaitForProperty("ToggleState", expectedState, 3_000)) + { + return toggle; + } + + continue; + } + + toggle.Invoke(msPostAction: 500); + if (toggle.WaitForProperty("ToggleState", expectedState, 15_000)) + { + return toggle; + } + + Step(testBase, $"{toggleId} did not reach {expectedState} on attempt {attempt}/3; reacquiring it"); + } + + Assert.Fail($"{toggleId} did not reach {expectedState} after three coordinate-free attempts."); + return toggle!; + } + + internal static WindowControl.ProcessWindow WaitForWindowClass(string className, int timeoutMs = 10_000) + { + var result = WaitHelper.WaitForStable( + () => WindowControl.EnumerateAllWindows().FirstOrDefault(window => + window.ClassName.Equals(className, StringComparison.OrdinalIgnoreCase)), + window => window.Hwnd != IntPtr.Zero, + timeoutMs, + requiredConsecutiveMatches: 2, + pollIntervalMS: 100); + + Assert.IsTrue(result.Succeeded, $"Top-level window class '{className}' did not appear within {timeoutMs} ms."); + return result.LastObservation; + } + + internal static Key[] ReadShortcut(UITestBase testBase, string groupId, int ordinal = 0) + { + Element? group = null; + for (var attempt = 0; attempt < 12 && group is null; attempt++) + { + group = testBase.Session.FindAll(By.AccessibilityId(groupId), 0).FirstOrDefault(); + if (group is null) + { + MouseHelper.ScrollDown(); + Thread.Sleep(150); + } + } + + Assert.IsNotNull(group, $"Settings group '{groupId}' was not found after scrolling the Mouse Utilities page."); + group.ScrollIntoView(); + var buttons = testBase.Session.FindAll