diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 06879da4e24b..2b915a5c0517 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -2341,3 +2341,4 @@ YTimer zamora zonability Zorder +LShaped diff --git a/PowerToys.slnx b/PowerToys.slnx index d6009aa512b1..ddc6269e5ea0 100644 --- a/PowerToys.slnx +++ b/PowerToys.slnx @@ -521,6 +521,7 @@ + diff --git a/src/modules/MouseUtils/CursorWrap/UnitTests/TopologyTests.cpp b/src/modules/MouseUtils/CursorWrap/UnitTests/TopologyTests.cpp new file mode 100644 index 000000000000..3b311d17a188 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/UnitTests/TopologyTests.cpp @@ -0,0 +1,416 @@ +// 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. + +// Unit tests for MonitorTopology, mirroring the Rust test suite in +// src/rust/libs/cursorwrap-core/src/topology.rs. +// +// These are pure-logic tests that construct MonitorInfo vectors and exercise +// topology initialization, outer edge detection, edge adjacency, and wrap +// destination calculation without requiring real monitors. + +#include "pch.h" + +#pragma warning(push) +#pragma warning(disable : 26466) +#include "CppUnitTest.h" +#pragma warning(pop) + +#include "../MonitorTopology.h" +#include "../CursorWrapCore.h" + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace CursorWrapUnitTests +{ + // ── helpers ────────────────────────────────────────────────────────────── + + // Create a MonitorInfo with a fake HMONITOR handle derived from the index. + static MonitorInfo MakeMonitor(int index, LONG left, LONG top, LONG right, LONG bottom, bool primary = false) + { + MonitorInfo mi{}; + mi.hMonitor = reinterpret_cast(static_cast(index) + 1u); + mi.rect = { left, top, right, bottom }; + mi.isPrimary = primary; + mi.monitorId = index; + return mi; + } + + static HMONITOR HandleForIndex(int index) + { + return reinterpret_cast(static_cast(index) + 1u); + } + + // Count outer edges belonging to a specific monitor. + static int CountOuterEdgesForMonitor(const MonitorTopology& topo, int monitorIndex) + { + int count = 0; + for (const auto& e : topo.GetOuterEdges()) + { + if (e.monitorIndex == monitorIndex) + ++count; + } + return count; + } + + // ── test class ────────────────────────────────────────────────────────── + + TEST_CLASS(TopologyTests) + { + public: + // ── Single monitor ────────────────────────────────────────────── + + // Product code: MonitorTopology.h — Initialize(), IdentifyOuterEdges() + // What: Verifies single monitor has all 4 edges marked as outer (no adjacent monitors) + // Why: Single-monitor is the base case; if outer-edge detection fails here, wrap logic breaks everywhere + // Risk: CursorWrap would never wrap (or always wrap incorrectly) on single-monitor setups + TEST_METHOD(SingleMonitor_AllEdgesAreOuter) + { + MonitorTopology topo; + std::vector monitors = { + MakeMonitor(0, 0, 0, 1920, 1080, true), + }; + topo.Initialize(monitors); + + Assert::AreEqual(4, static_cast(topo.GetOuterEdges().size()), + L"Single monitor should have 4 outer edges"); + } + + // Product code: MonitorTopology.h — IsOnOuterEdge(), GetWrapDestination() + // What: Verifies IsOnOuterEdge detects the left edge and GetWrapDestination returns a different point (self-wrap) + // Why: When no opposite monitor exists, wrapping falls back to same-monitor opposite edge + // Risk: Cursor would get stuck at edges on single-monitor setups instead of wrapping + TEST_METHOD(SingleMonitor_NoWrapPartner) + { + MonitorTopology topo; + std::vector monitors = { + MakeMonitor(0, 0, 0, 1920, 1080, true), + }; + topo.Initialize(monitors); + + // Cursor at the left edge. IsOnOuterEdge should be true and + // GetWrapDestination should wrap to the opposite edge of the same + // monitor when no opposite monitor exists. + EdgeType edgeType{}; + POINT cursor = { 0, 540 }; + bool isOuter = topo.IsOnOuterEdge(HandleForIndex(0), cursor, edgeType, WrapMode::Both); + + Assert::IsTrue(isOuter, L"Left edge of a single monitor should be detected as an outer edge"); + + POINT dest = topo.GetWrapDestination(HandleForIndex(0), cursor, edgeType); + // With no opposite monitor, wrap destination falls back to same + // monitor's opposite edge. + Assert::IsTrue(dest.x != cursor.x || dest.y != cursor.y, + L"Wrap destination should differ from source on a self-wrap"); + } + + // ── Two side-by-side monitors ─────────────────────────────────── + + // Product code: MonitorTopology.h — Initialize(), IdentifyOuterEdges(), EdgesAreAdjacent() + // What: [Mon0: 0–1920] [Mon1: 1920–3840] — verifies 6 outer edges (8 total minus 2 shared inner edges) + // Why: Adjacency detection must correctly identify shared edges to prevent wrapping between touching monitors + // Risk: Wrap triggers at monitor seams (cursor teleports when moving between side-by-side monitors) + TEST_METHOD(TwoSideBySide_CorrectOuterEdges) + { + MonitorTopology topo; + std::vector monitors = { + MakeMonitor(0, 0, 0, 1920, 1080, true), + MakeMonitor(1, 1920, 0, 3840, 1080), + }; + topo.Initialize(monitors); + + // 4 edges per monitor = 8 total, minus 2 adjacent (Right of 0, Left of 1) = 6 outer + Assert::AreEqual(6, static_cast(topo.GetOuterEdges().size()), + L"Expected 6 outer edges for two side-by-side monitors"); + } + + // Product code: MonitorTopology.h — IdentifyOuterEdges(), EdgesAreAdjacent() + // What: Confirms Mon0 has 3 outer edges (Left/Top/Bottom) and Mon1 has 3 (Right/Top/Bottom) + // Why: Per-monitor outer-edge count verifies RIGHT of Mon0 and LEFT of Mon1 are correctly inner + // Risk: Wrapping could fire at the wrong edge, sending cursor to unexpected monitor + TEST_METHOD(TwoSideBySide_SharedEdgesAreInner) + { + MonitorTopology topo; + std::vector monitors = { + MakeMonitor(0, 0, 0, 1920, 1080, true), + MakeMonitor(1, 1920, 0, 3840, 1080), + }; + topo.Initialize(monitors); + + // Mon0 should have 3 outer edges (Left, Top, Bottom) but NOT Right. + Assert::AreEqual(3, CountOuterEdgesForMonitor(topo, 0), + L"Mon0 should have 3 outer edges"); + // Mon1 should have 3 outer edges (Right, Top, Bottom) but NOT Left. + Assert::AreEqual(3, CountOuterEdgesForMonitor(topo, 1), + L"Mon1 should have 3 outer edges"); + } + + // ── Two stacked monitors ──────────────────────────────────────── + + // Product code: MonitorTopology.h — Initialize(), IdentifyOuterEdges(), EdgesAreAdjacent() + // What: [Mon0: 0,0–1920,1080] / [Mon1: 0,1080–1920,2160] — verifies 6 outer edges (Mon0-Bottom/Mon1-Top are inner) + // Why: Validates vertical adjacency detection (complements horizontal test above) + // Risk: Cursor wraps vertically between stacked monitors instead of crossing normally + TEST_METHOD(TwoStacked_CorrectOuterEdges) + { + MonitorTopology topo; + std::vector monitors = { + MakeMonitor(0, 0, 0, 1920, 1080, true), + MakeMonitor(1, 0, 1080, 1920, 2160), + }; + topo.Initialize(monitors); + + // Shared: Mon0 Bottom / Mon1 Top → 6 outer. + Assert::AreEqual(6, static_cast(topo.GetOuterEdges().size()), + L"Expected 6 outer edges for stacked monitors"); + } + + // ── L-shaped layout ───────────────────────────────────────────── + + // Product code: MonitorTopology.h — Initialize(), IdentifyOuterEdges(), EdgesAreAdjacent() + // What: L-shaped 3-monitor layout — verifies 8 outer edges (12 total minus 4 adjacent) + // Why: Non-rectangular layouts are common (laptop + 2 externals); adjacency must handle partial-edge overlap + // Risk: Some outer edges misclassified, causing wrap to fail on non-rectangular multi-monitor setups + TEST_METHOD(LShaped_CorrectOuterEdges) + { + MonitorTopology topo; + std::vector monitors = { + MakeMonitor(0, 0, 1080, 1920, 2160), + MakeMonitor(1, 0, 0, 1920, 1080, true), + MakeMonitor(2, 1920, 1080, 3840, 2160), + }; + topo.Initialize(monitors); + + // Mon1 Bottom / Mon0 Top are adjacent. + // Mon0 Right / Mon2 Left are adjacent. + // All other edges are outer. + // Total: 12 - 4 = 8 outer edges. + int outerCount = static_cast(topo.GetOuterEdges().size()); + Assert::AreEqual(8, outerCount, + L"Expected 8 outer edges for L-shaped layout"); + } + + // ── Edge adjacency within tolerance ───────────────────────────── + + // Product code: MonitorTopology.h — EdgesAreAdjacent(tolerance=50) + // What: Two monitors with 10px gap (within 50px tolerance) are still treated as adjacent → 6 outer edges + // Why: Windows display settings often leave small gaps between monitors; tolerance prevents false outer edges + // Risk: Small alignment gaps cause spurious wrapping at monitor seams + TEST_METHOD(EdgeAdjacency_WithinTolerance) + { + MonitorTopology topo; + // 10px gap between monitors (within 50px tolerance). + std::vector monitors = { + MakeMonitor(0, 0, 0, 1920, 1080, true), + MakeMonitor(1, 1930, 0, 3850, 1080), // 10px gap + }; + topo.Initialize(monitors); + + // The gap is within tolerance → inner edge. So 6 outer edges. + Assert::AreEqual(6, static_cast(topo.GetOuterEdges().size()), + L"Small gap within tolerance should still yield 6 outer edges"); + } + + // ── Edge adjacency beyond tolerance ───────────────────────────── + + // Product code: MonitorTopology.h — EdgesAreAdjacent(tolerance=50) + // What: Two monitors with 100px gap (beyond 50px tolerance) are NOT adjacent → 8 outer edges (all independent) + // Why: Truly separated monitors must each have full outer edges so wrapping works independently per monitor + // Risk: Distant monitors incorrectly treated as adjacent, suppressing wrap on their shared sides + TEST_METHOD(EdgeAdjacency_BeyondTolerance_NoMatch) + { + MonitorTopology topo; + // 100px gap — beyond 50px tolerance. + std::vector monitors = { + MakeMonitor(0, 0, 0, 1920, 1080, true), + MakeMonitor(1, 2020, 0, 3940, 1080), // 100px gap + }; + topo.Initialize(monitors); + + // No adjacency → each monitor has all 4 outer edges = 8 total. + Assert::AreEqual(8, static_cast(topo.GetOuterEdges().size()), + L"Large gap beyond tolerance → 8 outer edges"); + } + + // ── Wrap destination: horizontal preserves Y ──────────────────── + + // Product code: MonitorTopology.h — IsOnOuterEdge(), GetWrapDestination() + // What: Cursor at Mon0 left edge (0,540) wraps horizontally; Y coordinate (540) is preserved in destination + // Why: Users expect horizontal wrap to keep vertical position — losing Y makes cursor appear to jump + // Risk: Cursor teleports to wrong vertical position after horizontal wrap + TEST_METHOD(WrapDestination_HorizontalWrapPreservesY) + { + MonitorTopology topo; + std::vector monitors = { + MakeMonitor(0, 0, 0, 1920, 1080, true), + MakeMonitor(1, 1920, 0, 3840, 1080), + }; + topo.Initialize(monitors); + + // Cursor on the left outer edge of Mon0. + POINT cursor = { 0, 540 }; + EdgeType edgeType{}; + bool isOuter = topo.IsOnOuterEdge(HandleForIndex(0), cursor, edgeType, WrapMode::Both); + + Assert::IsTrue(isOuter, L"Left edge of Mon0 should be detected as an outer edge"); + Assert::AreEqual(static_cast(EdgeType::Left), static_cast(edgeType), + L"Edge type should be Left for cursor at x=0"); + + POINT dest = topo.GetWrapDestination(HandleForIndex(0), cursor, edgeType); + Assert::AreEqual(static_cast(540), dest.y, + L"Horizontal wrap should preserve Y coordinate"); + } + + // ── Wrap destination: vertical preserves X ────────────────────── + + // Product code: MonitorTopology.h — IsOnOuterEdge(), GetWrapDestination() + // What: Cursor at Mon0 top edge (960,0) wraps vertically; X coordinate (960) is preserved in destination + // Why: Vertical wrap must preserve horizontal position for a smooth user experience + // Risk: Cursor teleports to wrong horizontal position after vertical wrap + TEST_METHOD(WrapDestination_VerticalWrapPreservesX) + { + MonitorTopology topo; + std::vector monitors = { + MakeMonitor(0, 0, 0, 1920, 1080, true), + MakeMonitor(1, 0, 1080, 1920, 2160), + }; + topo.Initialize(monitors); + + POINT cursor = { 960, 0 }; + EdgeType edgeType{}; + bool isOuter = topo.IsOnOuterEdge(HandleForIndex(0), cursor, edgeType, WrapMode::Both); + + Assert::IsTrue(isOuter, L"Top edge of Mon0 should be detected as an outer edge"); + Assert::AreEqual(static_cast(EdgeType::Top), static_cast(edgeType), + L"Edge type should be Top for cursor at y=0"); + + POINT dest = topo.GetWrapDestination(HandleForIndex(0), cursor, edgeType); + Assert::AreEqual(static_cast(960), dest.x, + L"Vertical wrap should preserve X coordinate"); + } + + // ── WrapMode filtering: HorizontalOnly ───────────────────────── + + // Product code: MonitorTopology.h — IsOnOuterEdge() with WrapMode::HorizontalOnly + // What: Top edge (vertical) is not detected as outer when WrapMode is HorizontalOnly + // Why: Users can restrict wrap to horizontal-only; vertical edges must be filtered out + // Risk: Cursor wraps vertically even when user configured horizontal-only mode + TEST_METHOD(WrapMode_HorizontalOnly_IgnoresVerticalEdges) + { + MonitorTopology topo; + std::vector monitors = { + MakeMonitor(0, 0, 0, 1920, 1080, true), + MakeMonitor(1, 0, 1080, 1920, 2160), + }; + topo.Initialize(monitors); + + // Cursor on top outer edge. + POINT cursor = { 960, 0 }; + EdgeType edgeType{}; + bool isOuter = topo.IsOnOuterEdge(HandleForIndex(0), cursor, edgeType, + WrapMode::HorizontalOnly); + // With HorizontalOnly, a top edge should not be detected. + Assert::IsFalse(isOuter, + L"HorizontalOnly mode should not detect top edge"); + } + + // ── WrapMode filtering: VerticalOnly ──────────────────────────── + + // Product code: MonitorTopology.h — IsOnOuterEdge() with WrapMode::VerticalOnly + // What: Left edge (horizontal) is not detected as outer when WrapMode is VerticalOnly + // Why: Users can restrict wrap to vertical-only; horizontal edges must be filtered out + // Risk: Cursor wraps horizontally even when user configured vertical-only mode + TEST_METHOD(WrapMode_VerticalOnly_IgnoresHorizontalEdges) + { + MonitorTopology topo; + std::vector monitors = { + MakeMonitor(0, 0, 0, 1920, 1080, true), + MakeMonitor(1, 1920, 0, 3840, 1080), + }; + topo.Initialize(monitors); + + // Cursor on left outer edge. + POINT cursor = { 0, 540 }; + EdgeType edgeType{}; + bool isOuter = topo.IsOnOuterEdge(HandleForIndex(0), cursor, edgeType, + WrapMode::VerticalOnly); + Assert::IsFalse(isOuter, + L"VerticalOnly mode should not detect left edge"); + } + + // ── WrapMode filtering: Both ──────────────────────────────────── + + // Product code: MonitorTopology.h — IsOnOuterEdge() with WrapMode::Both + // What: Both left and top edges detected as outer when WrapMode is Both (default) + // Why: Default mode must not filter any direction — ensures full bidirectional wrapping + // Risk: Default wrap mode silently drops an axis, confusing users who expect both directions + TEST_METHOD(WrapMode_Both_DetectsAllEdges) + { + MonitorTopology topo; + std::vector monitors = { + MakeMonitor(0, 0, 0, 1920, 1080, true), + }; + topo.Initialize(monitors); + + // Left edge. + POINT leftPt = { 0, 540 }; + EdgeType edgeType{}; + bool leftOuter = topo.IsOnOuterEdge(HandleForIndex(0), leftPt, edgeType, WrapMode::Both); + Assert::IsTrue(leftOuter, L"Both mode should detect left edge"); + + // Top edge. + POINT topPt = { 960, 0 }; + bool topOuter = topo.IsOnOuterEdge(HandleForIndex(0), topPt, edgeType, WrapMode::Both); + Assert::IsTrue(topOuter, L"Both mode should detect top edge"); + } + + // ── Threshold: prevents rapid oscillation ─────────────────────── + + // Product code: CursorWrapCore.h — WRAP_DISTANCE_THRESHOLD constant + // What: Verifies the anti-oscillation threshold is exactly 50 pixels + // Why: Too small → cursor ping-pongs between edges; too large → legitimate wraps are suppressed + // Risk: Threshold drift causes either jitter (oscillation) or dead zones near edges + TEST_METHOD(Threshold_ConstantIs50Pixels) + { + Assert::AreEqual(50, WRAP_DISTANCE_THRESHOLD, + L"WRAP_DISTANCE_THRESHOLD should be 50px"); + } + + // ── Direction tracking ────────────────────────────────────────── + + // Product code: CursorWrapCore.h — CursorDirection struct (IsMovingLeft/Right/Up/Down, IsPrimarilyHorizontal) + // What: dx=-5,dy=2 → left, down, primarily horizontal (|dx|>=|dy|) + // Why: Edge priority at corners depends on movement direction; wrong classification picks wrong edge + // Risk: Cursor wraps to the wrong monitor at corner junctions + TEST_METHOD(CursorDirection_DxDyTracking) + { + CursorDirection dir{}; + dir.dx = -5; + dir.dy = 2; + + Assert::IsTrue(dir.IsMovingLeft(), + L"Negative dx should mean moving left"); + Assert::IsFalse(dir.IsMovingRight()); + Assert::IsFalse(dir.IsMovingUp()); + Assert::IsTrue(dir.IsMovingDown(), + L"Positive dy should mean moving down"); + Assert::IsTrue(dir.IsPrimarilyHorizontal(), + L"|dx| >= |dy| → primarily horizontal"); + } + + // Product code: CursorWrapCore.h — CursorDirection::IsPrimarilyHorizontal() + // What: dx=1,dy=-10 → primarily vertical (|dx|<|dy|), moving up + // Why: Complements the horizontal case; ensures vertical dominance is correctly detected + // Risk: Vertical movement misclassified as horizontal → wrong edge selected at corners + TEST_METHOD(CursorDirection_PrimarilyVertical) + { + CursorDirection dir{}; + dir.dx = 1; + dir.dy = -10; + + Assert::IsFalse(dir.IsPrimarilyHorizontal(), + L"|dx| < |dy| → primarily vertical"); + Assert::IsTrue(dir.IsMovingUp()); + } + }; +} diff --git a/src/modules/MouseUtils/CursorWrap/UnitTests/UnitTests-CursorWrap.vcxproj b/src/modules/MouseUtils/CursorWrap/UnitTests/UnitTests-CursorWrap.vcxproj new file mode 100644 index 000000000000..848e259a48e6 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/UnitTests/UnitTests-CursorWrap.vcxproj @@ -0,0 +1,70 @@ + + + + + + + 16.0 + {C1F0FE89-A695-472E-9DF5-F793B88EE220} + Win32Proj + UnitTestsCursorWrap + NativeUnitTestProject + CursorWrap.UnitTests + + + DynamicLibrary + false + + $(SolutionDir)$(Platform)\$(Configuration)\tests\UnitTestsCursorWrap\ + + + + + + + + + + + + + ..\;..\..\..\..\;..\..\..\..\common\Telemetry;$(VCInstallDir)UnitTest\include;%(AdditionalIncludeDirectories) + + + $(VCInstallDir)UnitTest\lib;%(AdditionalLibraryDirectories) + RuntimeObject.lib;%(AdditionalDependencies) + + + + + Create + + + + + + + + + + + + + {d9b8fc84-322a-4f9f-bbb9-20915c47ddfd} + + + + + + + + + + + + This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + + + + diff --git a/src/modules/MouseUtils/CursorWrap/UnitTests/UnitTests-CursorWrap.vcxproj.filters b/src/modules/MouseUtils/CursorWrap/UnitTests/UnitTests-CursorWrap.vcxproj.filters new file mode 100644 index 000000000000..5ba3ac0ca838 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/UnitTests/UnitTests-CursorWrap.vcxproj.filters @@ -0,0 +1,41 @@ + + + + + {4FC737F1-C7A5-4376-A066-2A32D752A2FF} + cpp;c;cc;cxx;def;odl;idl;hpj;bat;asm;asmx + + + {93995380-89BD-4b04-88EB-625FBE52EBFB} + h;hh;hpp;hxx;hm;inl;inc;ipp;xsd + + + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + + + Header Files + + + Header Files + + + Header Files + + + + + + diff --git a/src/modules/MouseUtils/CursorWrap/UnitTests/packages.config b/src/modules/MouseUtils/CursorWrap/UnitTests/packages.config new file mode 100644 index 000000000000..97349a856f8f --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/UnitTests/packages.config @@ -0,0 +1,4 @@ + + + + diff --git a/src/modules/MouseUtils/CursorWrap/UnitTests/pch.cpp b/src/modules/MouseUtils/CursorWrap/UnitTests/pch.cpp new file mode 100644 index 000000000000..64b7eef6d6b9 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/UnitTests/pch.cpp @@ -0,0 +1,5 @@ +// pch.cpp: source file corresponding to the pre-compiled header + +#include "pch.h" + +// When you are using pre-compiled headers, this source file is necessary for compilation to succeed. diff --git a/src/modules/MouseUtils/CursorWrap/UnitTests/pch.h b/src/modules/MouseUtils/CursorWrap/UnitTests/pch.h new file mode 100644 index 000000000000..974a27bedfe7 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/UnitTests/pch.h @@ -0,0 +1,20 @@ +#pragma once + +#ifndef PCH_H +#define PCH_H + +#define WIN32_LEAN_AND_MEAN +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#endif // PCH_H