Compare commits

..

16 Commits

Author SHA1 Message Date
Clint Rutkas
ab08e318d8 Fix MathHelper.Modulo for non-positive divisors; improve test coverage
- Add ArgumentOutOfRangeException guard for b<=0 in MathHelper.Modulo
  (previously threw raw DivideByZeroException for b=0, returned wrong
  results for negative divisors)
- Add test for invalid Position enum (only untested code branch)
- Add test for negative Modulo divisor
- Update zero-divisor test to expect ArgumentOutOfRangeException
- Remove redundant AllPositions smoke test (covered by 9 exact-value tests)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 17:20:31 -07:00
Clint Rutkas
ce0439f580 Enhance PowerAccent.Core and Peek.Common tests: fix bugs, strengthen assertions
- Fixed Rect.cs: Added Width/Height zero-division guards (was producing Infinity)
- Fixed Size.cs: Removed duplicate condition in division operator
- Strengthened Currency test with specific symbol assertions (euro, dollar, pound)
- Added cache identity check with Assert.AreSame
- Renamed misleading UNC test method (UncFileUri -> StandardUncWithFile)
- Added 10 new tests: Rect zero-Width/Height/AllZero, int.MinValue overflow,
  Modulo zero divisor, TopRight/BottomLeft exact values, null UNC path,
  Point both-zero, Size both-zero
- Added XML doc comments to all 123 test methods

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-12 19:09:21 -07:00
Clint Rutkas
5b7d3c0a82 Fix deps.json version conflicts in Peek.Common.UnitTests
Remove Common.SelfContained.props (only 3 of 72 test projects use it) and add
UseWindowsForms to bring in the WindowsDesktop shared framework. This ensures
runtime DLLs (System.CodeDom, Microsoft.VisualBasic, WindowsBase, System.Drawing)
resolve to the same versions as all other projects in the solution.

Without UseWindowsForms, Peek.Common (which uses WinUI, not WPF/WinForms) doesn't
transitively provide the WindowsDesktop framework reference, causing NuGet package
versions to appear in deps.json instead of the shared runtime versions.

Verified: All 487 libraries pass verifyDepsJsonLibraryVersions.ps1.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-12 14:04:38 -07:00
Clint Rutkas
5e12d2476d Merge remote-tracking branch 'origin/copilot/upgrade-microsoft-semantic-kernel-core' into copilot/improve-test-coverage 2026-04-11 15:23:42 -07:00
Clint Rutkas
3b6bf9c0ce Fix deps.json version conflicts for System.Diagnostics.EventLog and System.Threading.Channels
Add PackageReference for System.Diagnostics.EventLog and System.Threading.Channels
to all C# projects via Directory.Build.props, ensuring the NuGet 10.x versions
take precedence over the 9.x versions bundled in the .NET 9 runtime. This resolves
the verifyDepsJsonLibraryVersions CI check failure where different projects would
reference different DLL versions of these libraries.

- Add System.Threading.Channels 10.0.4 to Directory.Packages.props
- Add PackageReference for both packages in Directory.Build.props (C# only)
- Remove ExcludeAssets=runtime from System.Diagnostics.EventLog in 6 projects
  (no longer needed since we want the 10.x NuGet version to override the runtime)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 14:56:16 -07:00
Clint Rutkas
9020b18a9f Upgrade transitive dependencies for SemanticKernel 1.74.0
Upgrade Microsoft.Extensions.*, OpenAI, System.Text.Json, and other
transitive dependencies to versions compatible with SemanticKernel 1.74.0:

- Microsoft.Extensions.AI: 9.9.1 -> 10.4.1
- Microsoft.Extensions.AI.OpenAI: 9.9.1-preview -> 10.4.1
- Microsoft.Extensions.Caching.Abstractions: 9.0.10 -> 10.0.5
- Microsoft.Extensions.Caching.Memory: 9.0.10 -> 10.0.5
- Microsoft.Extensions.DependencyInjection: 9.0.10 -> 10.0.5
- Microsoft.Extensions.Logging: 9.0.10 -> 10.0.5
- Microsoft.Extensions.Logging.Abstractions: 9.0.10 -> 10.0.5
- Microsoft.Extensions.Hosting: 9.0.10 -> 10.0.5
- Microsoft.Extensions.Hosting.WindowsServices: 9.0.10 -> 10.0.5
- Microsoft.Bcl.AsyncInterfaces: 9.0.10 -> 10.0.5
- Newtonsoft.Json: 13.0.3 -> 13.0.4
- OpenAI: 2.5.0 -> 2.9.1
- System.ClientModel: 1.7.0 -> 1.9.0
- System.Diagnostics.EventLog: 9.0.10 -> 10.0.5
- System.Numerics.Tensors: 9.0.11 -> 10.0.4
- System.ServiceProcess.ServiceController: 9.0.10 -> 10.0.5
- System.Text.Json: 9.0.10 -> 10.0.5

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 13:04:10 -07:00
Clint Rutkas
f59f67cb08 Merge remote-tracking branch 'origin/main' into copilot/improve-test-coverage 2026-04-11 12:24:44 -07:00
copilot-swe-agent[bot]
890ea40f8a Upgrade Microsoft.SemanticKernel packages from 1.66.0 to 1.74.0
Agent-Logs-Url: https://github.com/microsoft/PowerToys/sessions/39021150-d623-45a0-98e1-6eb8a65d44c0

Co-authored-by: crutkas <1462282+crutkas@users.noreply.github.com>
2026-04-11 04:10:23 +00:00
copilot-swe-agent[bot]
70dd9db67a Fix deps.json version mismatch: add SelfContained props to test projects
Agent-Logs-Url: https://github.com/microsoft/PowerToys/sessions/a7787ea5-42eb-4e09-96c8-c295385faa9c

Co-authored-by: crutkas <1462282+crutkas@users.noreply.github.com>
2026-04-05 23:14:55 +00:00
copilot-swe-agent[bot]
fb7c945a2c Fix SA1508 and CS0246: remove blank lines before closing braces, add missing using System
Agent-Logs-Url: https://github.com/microsoft/PowerToys/sessions/a6ae0e3d-68ed-4a4f-8b91-e039e2f05689

Co-authored-by: crutkas <1462282+crutkas@users.noreply.github.com>
2026-04-05 16:37:15 +00:00
copilot-swe-agent[bot]
c72580c8f2 Fix build error: add missing using System.Linq to LanguagesTests.cs
Agent-Logs-Url: https://github.com/microsoft/PowerToys/sessions/0b83d8fc-328d-47ec-a746-685956604afe

Co-authored-by: crutkas <1462282+crutkas@users.noreply.github.com>
2026-04-04 05:08:11 +00:00
copilot-swe-agent[bot]
ce6debf68b Add traies and udit to spell-check allow list
Agent-Logs-Url: https://github.com/microsoft/PowerToys/sessions/1daa557f-51f2-454e-a7f9-aacc3912d36f

Co-authored-by: crutkas <1462282+crutkas@users.noreply.github.com>
2026-04-03 01:41:54 +00:00
copilot-swe-agent[bot]
1eca1713e1 Remove all #region/#endregion directives to fix SA1124 errors
Agent-Logs-Url: https://github.com/microsoft/PowerToys/sessions/00075017-9397-4cb6-ad53-1afda756d401

Co-authored-by: crutkas <1462282+crutkas@users.noreply.github.com>
2026-04-01 20:20:30 +00:00
copilot-swe-agent[bot]
1254cba088 Add test projects to PowerToys.slnx so CI discovers and runs them
Agent-Logs-Url: https://github.com/microsoft/PowerToys/sessions/9ffea101-795b-4b8c-817a-ab5af7004f72

Co-authored-by: crutkas <1462282+crutkas@users.noreply.github.com>
2026-04-01 04:40:47 +00:00
copilot-swe-agent[bot]
b5373cbb2b Update spell-check: add Ene to allow list, remove unused entries from expect list
Agent-Logs-Url: https://github.com/microsoft/PowerToys/sessions/717795b3-9c5f-484e-9b9c-63de37852f81

Co-authored-by: crutkas <1462282+crutkas@users.noreply.github.com>
2026-03-31 20:39:18 +00:00
copilot-swe-agent[bot]
b82e6c508d Add unit tests for PowerAccent.Core and Peek.Common helpers
Add PowerAccent.Core.UnitTests project with tests for:
- Point, Size, Rect struct constructors and division operators
- Calculation position logic with boundary conditions and DPI
- Languages character mapping for multiple languages

Add Peek.Common.UnitTests project with tests for:
- MathHelper.Modulo with positive/negative values
- MathHelper.NumberOfDigits with edge cases
- PathHelper.IsUncPath with various path formats

Agent-Logs-Url: https://github.com/microsoft/PowerToys/sessions/643cb57b-c352-45a6-83d0-d6b09011290b

Co-authored-by: crutkas <1462282+crutkas@users.noreply.github.com>
2026-03-31 19:10:40 +00:00
34 changed files with 1856 additions and 1129 deletions

View File

@@ -328,6 +328,9 @@ MRUCMPPROC
MRUINFO
REGSTR
# Quick Accent
Ene
# Misc Win32 APIs and PInvokes
INVOKEIDLIST
MEMORYSTATUSEX
@@ -389,5 +392,9 @@ nostdin
engtype
Nonpaged
# Spell-check fragments
traies
udit
# XAML
Untargeted

View File

@@ -791,7 +791,6 @@ lowlevel
LOWORD
lparam
LPBITMAPINFOHEADER
LPCFHOOKPROC
lpch
LPCITEMIDLIST
LPCLSID
@@ -833,7 +832,6 @@ lstrlen
LTEXT
LTRREADING
luid
LUMA
lusrmgr
LVal
LWA
@@ -852,7 +850,6 @@ MAPPEDTOSAMEKEY
MAPTOSAMESHORTCUT
MAPVK
MARKDOWNPREVIEWHANDLERCPP
MAXDWORD
MAXSHORTCUTSIZE
maxversiontested
MBM
@@ -917,7 +914,6 @@ MOUSEINPUT
MOVESIZEEND
MOVESIZESTART
MRM
MRT
mru
msc
mscorlib
@@ -2303,7 +2299,6 @@ virama
vnd
vredraw
VSpeed
VSync
WASDK
WCRAPI
wft

View File

@@ -85,6 +85,14 @@
<ForceImportBeforeCppProps>$(RepoRoot)Cpp.Build.props</ForceImportBeforeCppProps>
</PropertyGroup>
<!-- Force all C# projects to reference these packages from NuGet so their 10.x versions
take precedence over the 9.x versions bundled in the .NET 9 runtime. Without this,
projects that don't transitively depend on these packages would get the runtime 9.x
version, causing deps.json version conflicts with projects that do. -->
<ItemGroup Condition="'$(MSBuildProjectExtension)' == '.csproj'">
<PackageReference Include="System.Diagnostics.EventLog" />
<PackageReference Include="System.Threading.Channels" />
</ItemGroup>
<ItemGroup Condition="'$(MSBuildProjectExtension)' == '.csproj' and '$(_IsSkippedTestProject)' != 'true'">
<PackageReference Include="StyleCop.Analyzers">

View File

@@ -42,26 +42,26 @@
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.10" />
<!-- Including Microsoft.Bcl.AsyncInterfaces to force version, since it's used by Microsoft.SemanticKernel. -->
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="9.0.10" />
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.5" />
<PackageVersion Include="Microsoft.Graphics.Win2D" Version="1.3.2" />
<PackageVersion Include="Microsoft.Windows.CppWinRT" Version="2.0.250303.1" />
<PackageVersion Include="Microsoft.Diagnostics.Tracing.TraceEvent" Version="3.1.16" />
<PackageVersion Include="Microsoft.Extensions.AI" Version="9.9.1" />
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="9.9.1-preview.1.25474.6" />
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.10" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.10" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.10" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.10" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="9.0.10" />
<PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="9.0.10" />
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.4.1" />
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.4.1" />
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.5" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.5" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.5" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.5" />
<PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.5" />
<PackageVersion Include="Microsoft.AI.Foundry.Local" Version="0.3.0" />
<PackageVersion Include="Microsoft.SemanticKernel" Version="1.66.0" />
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.OpenAI" Version="1.66.0" />
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.AzureAIInference" Version="1.66.0-beta" />
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Google" Version="1.66.0-alpha" />
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.MistralAI" Version="1.66.0-alpha" />
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Ollama" Version="1.66.0-alpha" />
<PackageVersion Include="Microsoft.SemanticKernel" Version="1.74.0" />
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.OpenAI" Version="1.74.0" />
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.AzureAIInference" Version="1.74.0-beta" />
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Google" Version="1.74.0-alpha" />
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.MistralAI" Version="1.74.0-alpha" />
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Ollama" Version="1.74.0-alpha" />
<PackageVersion Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.2" />
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.3179.45" />
<!-- Package Microsoft.Win32.SystemEvents added as a hack for being able to exclude the runtime assets so they don't conflict with 8.0.1. This is a dependency of System.Drawing.Common but the 8.0.1 version wasn't published to nuget. -->
@@ -89,11 +89,11 @@
<PackageVersion Include="MSTest" Version="$(MSTestVersion)" />
<PackageVersion Include="MSTest.TestFramework" Version="$(MSTestVersion)" />
<PackageVersion Include="NJsonSchema" Version="11.4.0" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
<PackageVersion Include="NLog" Version="5.2.8" />
<PackageVersion Include="NLog.Extensions.Logging" Version="5.3.8" />
<PackageVersion Include="NLog.Schema" Version="5.2.8" />
<PackageVersion Include="OpenAI" Version="2.5.0" />
<PackageVersion Include="OpenAI" Version="2.9.1" />
<PackageVersion Include="Polly.Core" Version="8.6.5" />
<PackageVersion Include="ReverseMarkdown" Version="4.1.0" />
<PackageVersion Include="RtfPipe" Version="2.0.7677.4303" />
@@ -113,23 +113,27 @@
<PackageVersion Include="System.Data.OleDb" Version="9.0.10" />
<!-- Package System.Data.SqlClient added to force it as a dependency of Microsoft.Windows.Compatibility to the latest version available at this time. -->
<PackageVersion Include="System.Data.SqlClient" Version="4.9.0" />
<!-- Package System.Diagnostics.EventLog added as a hack for being able to exclude the runtime assets so they don't conflict with 8.0.1. This is a dependency of System.Data.OleDb but the 8.0.1 version wasn't published to nuget. -->
<PackageVersion Include="System.Diagnostics.EventLog" Version="9.0.10" />
<!-- Package System.Diagnostics.EventLog force-pinned to 10.x to match other Microsoft.Extensions packages;
referenced by all C# projects (via Directory.Build.props) to override the 9.x version from the .NET runtime. -->
<PackageVersion Include="System.Diagnostics.EventLog" Version="10.0.5" />
<!-- Package System.Diagnostics.PerformanceCounter added as a hack for being able to exclude the runtime assets so they don't conflict with 8.0.11. -->
<PackageVersion Include="System.Diagnostics.PerformanceCounter" Version="9.0.10" />
<PackageVersion Include="System.ClientModel" Version="1.7.0" />
<PackageVersion Include="System.ClientModel" Version="1.9.0" />
<PackageVersion Include="System.Drawing.Common" Version="9.0.10" />
<PackageVersion Include="System.IO.Abstractions" Version="22.0.13" />
<PackageVersion Include="System.IO.Abstractions.TestingHelpers" Version="22.0.13" />
<PackageVersion Include="System.Management" Version="9.0.10" />
<PackageVersion Include="System.Net.Http" Version="4.3.4" />
<PackageVersion Include="System.Numerics.Tensors" Version="9.0.11" />
<PackageVersion Include="System.Numerics.Tensors" Version="10.0.4" />
<!-- Including System.Threading.Channels to force version across all projects;
referenced by all C# projects (via Directory.Build.props) to override the 9.x version from the .NET runtime. -->
<PackageVersion Include="System.Threading.Channels" Version="10.0.4" />
<PackageVersion Include="System.Private.Uri" Version="4.3.2" />
<PackageVersion Include="System.Reactive" Version="6.0.1" />
<PackageVersion Include="System.Runtime.Caching" Version="9.0.10" />
<PackageVersion Include="System.ServiceProcess.ServiceController" Version="9.0.10" />
<PackageVersion Include="System.ServiceProcess.ServiceController" Version="10.0.5" />
<PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.10" />
<PackageVersion Include="System.Text.Json" Version="9.0.10" />
<PackageVersion Include="System.Text.Json" Version="10.0.5" />
<PackageVersion Include="System.Text.RegularExpressions" Version="4.3.1" />
<PackageVersion Include="ToolGood.Words.Pinyin" Version="3.1.0.3" />
<PackageVersion Include="UnicodeInformation" Version="2.6.0" />

View File

@@ -57,7 +57,6 @@
<Project Path="src/common/UnitTests-CommonLib/UnitTests-CommonLib.vcxproj" Id="1a066c63-64b3-45f8-92fe-664e1cce8077" />
<Project Path="src/common/UnitTests-CommonUtils/UnitTests-CommonUtils.vcxproj" Id="8b5cfb38-ccba-40a8-ad7a-89c57b070884" />
<Project Path="src/common/updating/updating.vcxproj" Id="17da04df-e393-4397-9cf0-84dabe11032e" />
<Project Path="src/common/updating/UnitTests/UpdatingUnitTests.vcxproj" Id="a1b2c3d4-e5f6-7890-abcd-ef1234567890" />
<Project Path="src/common/version/version.vcxproj" Id="cc6e41ac-8174-4e8a-8d22-85dd7f4851df" />
</Folder>
<Folder Name="/common/interop/">
@@ -792,6 +791,10 @@
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/peek/peek/peek.vcxproj" Id="a1425b53-3d61-4679-8623-e64a0d3d0a48" />
<Project Path="src/modules/peek/Peek.Common.UnitTests/Peek.Common.UnitTests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
</Folder>
<Folder Name="/modules/PowerAccent/">
<Project Path="src/modules/poweraccent/PowerAccent.Core/PowerAccent.Core.csproj">
@@ -804,6 +807,10 @@
</Project>
<Project Path="src/modules/poweraccent/PowerAccentKeyboardService/PowerAccentKeyboardService.vcxproj" Id="c97d9a5d-206c-454e-997e-009e227d7f02" />
<Project Path="src/modules/poweraccent/PowerAccentModuleInterface/PowerAccentModuleInterface.vcxproj" Id="34a354c5-23c7-4343-916c-c52daf4fc39d" />
<Project Path="src/modules/poweraccent/PowerAccent.Core.UnitTests/PowerAccent.Core.UnitTests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
</Folder>
<Folder Name="/modules/PowerOCR/">
<Project Path="src/modules/PowerOCR/PowerOCR/PowerOCR.csproj">

View File

@@ -14,8 +14,6 @@
#include <common/updating/updating.h>
#include <common/updating/updateState.h>
#include <common/updating/installer.h>
#include <common/updating/configBackup.h>
#include <common/updating/updateLifecycle.h>
#include <common/utils/elevation.h>
#include <common/utils/HttpClient.h>
@@ -23,8 +21,6 @@
#include <common/utils/resources.h>
#include <common/utils/timeutil.h>
#include <wil/resource.h>
#include <common/SettingsAPI/settings_helpers.h>
#include <common/logger/logger.h>
@@ -42,16 +38,15 @@ namespace fs = std::filesystem;
std::optional<fs::path> CopySelfToTempDir()
{
// D5 fix: Use unique temp path with PID to avoid collision on concurrent updates
std::error_code error;
auto dst_path = fs::temp_directory_path() / (L"PowerToys.Update." + std::to_wstring(GetCurrentProcessId()) + L".exe");
auto dst_path = fs::temp_directory_path() / "PowerToys.Update.exe";
fs::copy_file(get_module_filename(), dst_path, fs::copy_options::overwrite_existing, error);
if (error)
{
return std::nullopt;
}
return dst_path;
return std::move(dst_path);
}
std::optional<fs::path> ObtainInstaller(bool& isUpToDate)
@@ -62,9 +57,34 @@ std::optional<fs::path> ObtainInstaller(bool& isUpToDate)
auto state = UpdateState::read();
// Handle readyToInstall first — the installer is already on disk,
// so we don't need a GitHub API call (which may fail if offline).
if (state.state == UpdateState::readyToInstall)
const auto new_version_info = std::move(get_github_version_info_async()).get();
if (std::holds_alternative<version_up_to_date>(*new_version_info))
{
isUpToDate = true;
Logger::error("Invoked with -update_now argument, but no update was available");
return std::nullopt;
}
if (state.state == UpdateState::readyToDownload || state.state == UpdateState::errorDownloading)
{
if (!new_version_info)
{
Logger::error(L"Couldn't obtain github version info: {}", new_version_info.error());
return std::nullopt;
}
// Cleanup old updates before downloading the latest
updating::cleanup_updates();
auto downloaded_installer = std::move(download_new_version_async(std::get<new_version_download_info>(*new_version_info))).get();
if (!downloaded_installer)
{
Logger::error("Couldn't download new installer");
}
return downloaded_installer;
}
else if (state.state == UpdateState::readyToInstall)
{
fs::path installer{ get_pending_updates_path() / state.downloadedInstallerFilename };
if (fs::is_regular_file(installer))
@@ -77,44 +97,12 @@ std::optional<fs::path> ObtainInstaller(bool& isUpToDate)
return std::nullopt;
}
}
if (state.state == UpdateState::upToDate)
else if (state.state == UpdateState::upToDate)
{
isUpToDate = true;
return std::nullopt;
}
const auto new_version_info = std::move(get_github_version_info_async()).get();
// Check for error BEFORE dereferencing — the old code crashed here
// when GitHub API was unreachable (new_version_info held an error string).
if (!new_version_info)
{
Logger::error(L"Couldn't obtain github version info: {}", new_version_info.error());
return std::nullopt;
}
if (std::holds_alternative<version_up_to_date>(*new_version_info))
{
isUpToDate = true;
Logger::error("Invoked with -update_now argument, but no update was available");
return std::nullopt;
}
if (state.state == UpdateState::readyToDownload || state.state == UpdateState::errorDownloading)
{
// Cleanup old updates before downloading the latest
updating::cleanup_updates();
auto downloaded_installer = std::move(download_new_version_async(std::get<new_version_download_info>(*new_version_info))).get();
if (!downloaded_installer)
{
Logger::error("Couldn't download new installer");
}
return downloaded_installer;
}
Logger::error("Invoked with -update_now argument, but update state was invalid");
return std::nullopt;
}
@@ -128,29 +116,13 @@ bool InstallNewVersionStage1(fs::path installer)
if (pt_main_window != nullptr)
{
// Get the process that owns the tray window so we can wait for it to exit
DWORD ptProcessId = 0;
GetWindowThreadProcessId(pt_main_window, &ptProcessId);
SendMessageW(pt_main_window, WM_CLOSE, 0, 0);
// D4 fix: Wait for PT to actually exit before launching installer.
// Without this, the installer may find PT files locked.
if (ptProcessId != 0)
{
wil::unique_handle ptProcess{ OpenProcess(SYNCHRONIZE, FALSE, ptProcessId) };
if (ptProcess)
{
WaitForSingleObject(ptProcess.get(), 10000); // 10 second timeout
}
}
}
// Pass the install directory so Stage 2 can relaunch PowerToys after install
const std::wstring installDir = get_module_folderpath();
std::wstring arguments = updating::BuildStage2Arguments(
UPDATE_NOW_LAUNCH_STAGE2, installer, fs::path(installDir));
std::wstring arguments{ UPDATE_NOW_LAUNCH_STAGE2 };
arguments += L" \"";
arguments += installer.c_str();
arguments += L"\"";
SHELLEXECUTEINFOW sei{ sizeof(sei) };
sei.fMask = { SEE_MASK_FLAG_NO_UI | SEE_MASK_NOASYNC };
sei.lpFile = copy_in_temp->c_str();
@@ -218,16 +190,9 @@ int WINAPI WinMain(HINSTANCE, HINSTANCE, LPSTR, int)
LPWSTR* args = CommandLineToArgvW(GetCommandLineW(), &nArgs);
if (!args || nArgs < 2)
{
if (args)
{
LocalFree(args);
}
return 1;
}
// D3 fix: ensure args is freed on all exit paths
auto freeArgs = wil::scope_exit([&] { LocalFree(args); });
std::wstring_view action{ args[1] };
std::filesystem::path logFilePath(PTSettingsHelper::get_root_save_folder_location());
@@ -236,10 +201,6 @@ int WINAPI WinMain(HINSTANCE, HINSTANCE, LPSTR, int)
if (action == UPDATE_NOW_LAUNCH_STAGE1)
{
// Backup config files before the update to protect against corruption
Logger::info("Backing up config files before update");
updating::BackupConfigFiles(fs::path(PTSettingsHelper::get_root_save_folder_location()));
bool isUpToDate = false;
auto installerPath = ObtainInstaller(isUpToDate);
bool failed = !installerPath.has_value();
@@ -256,12 +217,6 @@ int WINAPI WinMain(HINSTANCE, HINSTANCE, LPSTR, int)
}
else if (action == UPDATE_NOW_LAUNCH_STAGE2)
{
if (nArgs < 3)
{
Logger::error("Stage 2 invoked without installer path argument");
return 1;
}
using namespace std::string_view_literals;
const bool failed = !InstallNewVersionStage2(args[2]);
if (failed)
@@ -272,37 +227,6 @@ int WINAPI WinMain(HINSTANCE, HINSTANCE, LPSTR, int)
state.state = UpdateState::errorDownloading;
});
}
// D7 fix: Always check for corrupted configs after Stage 2, regardless
// of install success/failure. A failed install may still corrupt configs.
Logger::info("Checking for corrupted config files after update");
updating::RestoreCorruptedConfigs(fs::path(PTSettingsHelper::get_root_save_folder_location()));
if (!failed)
{
// Relaunch PowerToys from the install directory
if (updating::CanRelaunchAfterUpdate(nArgs))
{
std::wstring ptExePath = updating::BuildPowerToysExePath(args[3]);
Logger::info(L"Relaunching PowerToys after update: {}", ptExePath);
SHELLEXECUTEINFOW sei{ sizeof(sei) };
sei.fMask = { SEE_MASK_FLAG_NO_UI | SEE_MASK_NOASYNC };
sei.lpFile = ptExePath.c_str();
sei.nShow = SW_SHOWNORMAL;
sei.lpParameters = UPDATE_REPORT_SUCCESS;
if (!ShellExecuteExW(&sei))
{
Logger::error(L"Failed to relaunch PowerToys after update");
}
}
else
{
Logger::warn("Install directory not provided to Stage 2 - cannot relaunch PowerToys");
}
}
return failed;
}

View File

@@ -1,679 +0,0 @@
// 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.
#include "pch.h"
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <string>
#include <vector>
#include <common/updating/configBackup.h>
#include <common/updating/updateLifecycle.h>
using namespace Microsoft::VisualStudio::CppUnitTestFramework;
namespace fs = std::filesystem;
namespace UpdatingUnitTests
{
// Helper to create a temp directory for test isolation.
// Each instance gets a unique subdirectory to prevent test interference.
class TempDir
{
public:
TempDir()
{
wchar_t tempPath[MAX_PATH + 1];
GetTempPathW(MAX_PATH, tempPath);
static std::atomic<int> counter{0};
m_path = fs::path(tempPath) / (L"PowerToysUpdateTests_" + std::to_wstring(counter++));
// Ensure clean state
std::error_code ec;
fs::remove_all(m_path, ec);
fs::create_directories(m_path, ec);
}
~TempDir()
{
std::error_code ec;
fs::remove_all(m_path, ec);
}
const fs::path& path() const { return m_path; }
// Write a file with the given content
void WriteFile(const fs::path& relativePath, const std::string& content)
{
auto fullPath = m_path / relativePath;
fs::create_directories(fullPath.parent_path());
std::ofstream file(fullPath, std::ios::binary);
file.write(content.data(), content.size());
}
// Write a file with raw bytes (including null bytes for corruption testing)
void WriteFileBytes(const fs::path& relativePath, const std::vector<char>& bytes)
{
auto fullPath = m_path / relativePath;
fs::create_directories(fullPath.parent_path());
std::ofstream file(fullPath, std::ios::binary);
file.write(bytes.data(), bytes.size());
}
// Read file content as string
std::string ReadFile(const fs::path& relativePath)
{
auto fullPath = m_path / relativePath;
std::ifstream file(fullPath, std::ios::binary);
return std::string(std::istreambuf_iterator<char>(file), std::istreambuf_iterator<char>());
}
bool FileExists(const fs::path& relativePath)
{
return fs::exists(m_path / relativePath);
}
private:
fs::path m_path;
};
TEST_CLASS(IsJsonFileCorruptedTests)
{
public:
// Tests IsJsonFileCorrupted: valid JSON with no null bytes returns false.
// Covers: configBackup.h IsJsonFileCorrupted — happy path, full file scan.
TEST_METHOD(CleanJsonFileIsNotCorrupted)
{
TempDir dir;
dir.WriteFile(L"settings.json", R"({"theme":"dark","startup":true})");
Assert::IsFalse(updating::IsJsonFileCorrupted(dir.path() / L"settings.json"));
}
// Tests IsJsonFileCorrupted: zero-length file returns false (empty is not corrupted).
// Covers: configBackup.h IsJsonFileCorrupted — file.read returns 0 bytes immediately.
TEST_METHOD(EmptyFileIsNotCorrupted)
{
TempDir dir;
dir.WriteFile(L"empty.json", "");
Assert::IsFalse(updating::IsJsonFileCorrupted(dir.path() / L"empty.json"));
}
// Tests IsJsonFileCorrupted: file containing embedded null bytes returns true.
// Covers: configBackup.h IsJsonFileCorrupted — null byte detection within buffer.
TEST_METHOD(FileWithNullBytesIsCorrupted)
{
TempDir dir;
std::vector<char> corrupted = { '{', '"', 'a', '"', ':', '\0', '\0', '\0', '}' };
dir.WriteFileBytes(L"corrupted.json", corrupted);
Assert::IsTrue(updating::IsJsonFileCorrupted(dir.path() / L"corrupted.json"));
}
// Tests IsJsonFileCorrupted: file entirely filled with 0x00 bytes returns true.
// Reproduces the exact bug from #46179 where installer zeroed out JSON files.
// Covers: configBackup.h IsJsonFileCorrupted — first byte is null.
TEST_METHOD(FileFilledWithNullBytesIsCorrupted)
{
TempDir dir;
std::vector<char> allNulls(1024, '\0');
dir.WriteFileBytes(L"workspaces.json", allNulls);
Assert::IsTrue(updating::IsJsonFileCorrupted(dir.path() / L"workspaces.json"));
}
// Tests IsJsonFileCorrupted: path that does not exist returns false.
// Covers: configBackup.h IsJsonFileCorrupted — file.is_open() check.
TEST_METHOD(NonExistentFileIsNotCorrupted)
{
TempDir dir;
Assert::IsFalse(updating::IsJsonFileCorrupted(dir.path() / L"missing.json"));
}
// Tests IsJsonFileCorrupted: file larger than the 4096-byte read chunk
// with no null bytes returns false.
// Covers: configBackup.h IsJsonFileCorrupted — multi-chunk while loop.
TEST_METHOD(LargeCleanFileIsNotCorrupted)
{
TempDir dir;
std::string largeContent(8192, 'x');
dir.WriteFile(L"large.json", largeContent);
Assert::IsFalse(updating::IsJsonFileCorrupted(dir.path() / L"large.json"));
}
// Tests IsJsonFileCorrupted: null byte placed after the first 4096-byte
// chunk boundary is still detected.
// Covers: configBackup.h IsJsonFileCorrupted — second chunk scan.
TEST_METHOD(NullByteAtEndOfLargeFileIsDetected)
{
TempDir dir;
std::string content(5000, 'x');
content[4999] = '\0';
std::vector<char> bytes(content.begin(), content.end());
dir.WriteFileBytes(L"sneaky.json", bytes);
Assert::IsTrue(updating::IsJsonFileCorrupted(dir.path() / L"sneaky.json"));
}
};
TEST_CLASS(BackupConfigFilesTests)
{
public:
// Tests BackupConfigFiles: root-level .json files are copied to ConfigBackup.
// Covers: configBackup.h BackupConfigFiles — root directory_iterator,
// is_regular_file && extension == ".json" branch.
// Setup: Two root-level JSON files.
TEST_METHOD(BackupCopiesRootJsonFiles)
{
TempDir dir;
dir.WriteFile(L"settings.json", R"({"theme":"dark"})");
dir.WriteFile(L"UpdateState.json", R"({"state":0})");
updating::BackupConfigFiles(dir.path());
Assert::IsTrue(dir.FileExists(L"ConfigBackup\\settings.json"));
Assert::IsTrue(dir.FileExists(L"ConfigBackup\\UpdateState.json"));
Assert::AreEqual(std::string(R"({"theme":"dark"})"), dir.ReadFile(L"ConfigBackup\\settings.json"));
}
// Tests BackupConfigFiles: .json files inside module subdirectories are
// copied to ConfigBackup/<module>/.
// Covers: configBackup.h BackupConfigFiles — is_directory branch,
// module directory_iterator with extension filter.
// Setup: Root JSON + two module directories with JSON files.
TEST_METHOD(BackupCopiesModuleJsonFiles)
{
TempDir dir;
dir.WriteFile(L"settings.json", R"({"theme":"dark"})");
dir.WriteFile(L"FancyZones\\settings.json", R"({"zones":[]})");
dir.WriteFile(L"Workspaces\\workspaces.json", R"({"workspaces":[]})");
updating::BackupConfigFiles(dir.path());
Assert::IsTrue(dir.FileExists(L"ConfigBackup\\FancyZones\\settings.json"));
Assert::IsTrue(dir.FileExists(L"ConfigBackup\\Workspaces\\workspaces.json"));
Assert::AreEqual(std::string(R"({"zones":[]})"),
dir.ReadFile(L"ConfigBackup\\FancyZones\\settings.json"));
}
// Tests BackupConfigFiles: non-.json files at root level are not copied.
// Covers: configBackup.h BackupConfigFiles — extension filter excludes .log.
// Setup: One JSON file + one .log file at root.
TEST_METHOD(BackupSkipsNonJsonFiles)
{
TempDir dir;
dir.WriteFile(L"settings.json", R"({"theme":"dark"})");
dir.WriteFile(L"debug.log", "log data");
updating::BackupConfigFiles(dir.path());
Assert::IsTrue(dir.FileExists(L"ConfigBackup\\settings.json"));
Assert::IsFalse(dir.FileExists(L"ConfigBackup\\debug.log"));
}
// Tests BackupConfigFiles: the "Updates" directory is explicitly skipped.
// Covers: configBackup.h BackupConfigFiles — dirName == L"Updates" continue.
// Setup: Root JSON + Updates directory containing a file.
TEST_METHOD(BackupSkipsUpdatesDirectory)
{
TempDir dir;
dir.WriteFile(L"settings.json", R"({"theme":"dark"})");
dir.WriteFile(L"Updates\\installer.exe", "fake exe");
updating::BackupConfigFiles(dir.path());
Assert::IsFalse(dir.FileExists(L"ConfigBackup\\Updates"));
}
// Tests BackupConfigFiles: running backup twice overwrites the previous
// backup with current file content.
// Covers: configBackup.h BackupConfigFiles — fs::remove_all(backupDir) +
// copy_options::overwrite_existing.
// Setup: Backup, modify original, backup again.
TEST_METHOD(BackupOverwritesPreviousBackup)
{
TempDir dir;
dir.WriteFile(L"settings.json", R"({"version":1})");
updating::BackupConfigFiles(dir.path());
// Update the original
dir.WriteFile(L"settings.json", R"({"version":2})");
updating::BackupConfigFiles(dir.path());
Assert::AreEqual(std::string(R"({"version":2})"), dir.ReadFile(L"ConfigBackup\\settings.json"));
}
// Tests BackupConfigFiles: non-.json files inside module subdirectories
// (e.g., FancyZones/zones.dat) should NOT be backed up.
// Covers: configBackup.h BackupConfigFiles — extension filter in module loop.
TEST_METHOD(BackupSkipsNonJsonFilesInModuleDirs)
{
TempDir dir;
dir.WriteFile(L"settings.json", R"({})");
dir.WriteFile(L"FancyZones\\settings.json", R"({"zones":[]})");
dir.WriteFile(L"FancyZones\\zones.dat", "binary data");
updating::BackupConfigFiles(dir.path());
Assert::IsTrue(dir.FileExists(L"ConfigBackup\\FancyZones\\settings.json"));
Assert::IsFalse(dir.FileExists(L"ConfigBackup\\FancyZones\\zones.dat"));
}
// Tests BackupConfigFiles: empty root directory with no files produces
// an empty ConfigBackup dir without errors.
// Covers: configBackup.h BackupConfigFiles — empty directory_iterator.
TEST_METHOD(BackupEmptyRootDirSucceeds)
{
TempDir dir;
// Root dir exists but has no files
updating::BackupConfigFiles(dir.path());
Assert::IsTrue(dir.FileExists(L"ConfigBackup"));
}
};
TEST_CLASS(RestoreCorruptedConfigsTests)
{
public:
// Tests RestoreCorruptedConfigs: corrupted root-level JSON file is restored
// from the good backup copy.
// Covers: configBackup.h RestoreCorruptedConfigs — root file restore branch,
// fs::exists + IsJsonFileCorrupted + backup integrity check.
// Setup: Good file -> backup -> corrupt original -> restore.
TEST_METHOD(RestoreFixesCorruptedRootFile)
{
TempDir dir;
const std::string goodContent = R"({"theme":"dark"})";
dir.WriteFile(L"settings.json", goodContent);
// Backup
updating::BackupConfigFiles(dir.path());
// Corrupt the original
std::vector<char> corrupted(goodContent.size(), '\0');
dir.WriteFileBytes(L"settings.json", corrupted);
Assert::IsTrue(updating::IsJsonFileCorrupted(dir.path() / L"settings.json"));
// Restore
updating::RestoreCorruptedConfigs(dir.path());
Assert::IsFalse(updating::IsJsonFileCorrupted(dir.path() / L"settings.json"));
Assert::AreEqual(goodContent, dir.ReadFile(L"settings.json"));
}
// Tests RestoreCorruptedConfigs: corrupted module-level JSON file is restored
// from the good backup copy.
// Covers: configBackup.h RestoreCorruptedConfigs — module directory branch,
// moduleBackupEntry restore with integrity check.
// Setup: Module file + root file -> backup -> corrupt module file -> restore.
TEST_METHOD(RestoreFixesCorruptedModuleFile)
{
TempDir dir;
const std::string goodContent = R"({"workspaces":[]})";
dir.WriteFile(L"Workspaces\\workspaces.json", goodContent);
dir.WriteFile(L"settings.json", R"({})");
updating::BackupConfigFiles(dir.path());
// Corrupt the module file
std::vector<char> corrupted(goodContent.size(), '\0');
dir.WriteFileBytes(L"Workspaces\\workspaces.json", corrupted);
updating::RestoreCorruptedConfigs(dir.path());
Assert::AreEqual(goodContent, dir.ReadFile(L"Workspaces\\workspaces.json"));
}
// Tests RestoreCorruptedConfigs: clean (non-corrupted) files are NOT
// overwritten by backup — preserves user changes made after backup.
// Covers: configBackup.h RestoreCorruptedConfigs — IsJsonFileCorrupted
// returns false, copy_file is skipped.
// Setup: File -> backup -> modify (but keep valid) -> restore.
TEST_METHOD(RestoreLeavesCleanFilesUntouched)
{
TempDir dir;
dir.WriteFile(L"settings.json", R"({"version":1})");
updating::BackupConfigFiles(dir.path());
// Modify original (but keep it clean JSON)
dir.WriteFile(L"settings.json", R"({"version":2})");
updating::RestoreCorruptedConfigs(dir.path());
// Should NOT have been restored since it's not corrupted
Assert::AreEqual(std::string(R"({"version":2})"), dir.ReadFile(L"settings.json"));
}
// Tests RestoreCorruptedConfigs: when no ConfigBackup directory exists,
// restore silently does nothing (no crash, no data loss).
// Covers: configBackup.h RestoreCorruptedConfigs — !fs::exists(backupDir)
// early return.
// Setup: File with no prior backup.
TEST_METHOD(RestoreHandlesMissingBackupDirectory)
{
TempDir dir;
dir.WriteFile(L"settings.json", R"({"theme":"dark"})");
// No backup was created - restore should silently do nothing
updating::RestoreCorruptedConfigs(dir.path());
Assert::AreEqual(std::string(R"({"theme":"dark"})"), dir.ReadFile(L"settings.json"));
}
// Tests RestoreCorruptedConfigs: end-to-end scenario with multiple modules,
// some corrupted and some clean, verifying selective restore.
// Covers: configBackup.h RestoreCorruptedConfigs — both root and module
// branches, selective restore based on corruption status.
// Setup: 4 modules -> backup -> corrupt 2 -> restore -> verify all 4.
TEST_METHOD(FullBackupAndRestoreRoundTrip)
{
TempDir dir;
// Set up a realistic config structure
dir.WriteFile(L"settings.json", R"({"startup":true,"theme":"dark"})");
dir.WriteFile(L"FancyZones\\settings.json", R"({"zones":[{"id":1}]})");
dir.WriteFile(L"Workspaces\\workspaces.json", R"({"workspaces":[{"name":"dev"}]})");
dir.WriteFile(L"KeyboardManager\\default.json", R"({"remaps":[]})");
// Backup
updating::BackupConfigFiles(dir.path());
// Corrupt some files (simulating #46179 scenario)
dir.WriteFileBytes(L"Workspaces\\workspaces.json", std::vector<char>(100, '\0'));
dir.WriteFileBytes(L"settings.json", std::vector<char>(50, '\0'));
// Leave FancyZones and KBM clean
// Restore
updating::RestoreCorruptedConfigs(dir.path());
// Corrupted files should be restored
Assert::AreEqual(std::string(R"({"startup":true,"theme":"dark"})"), dir.ReadFile(L"settings.json"));
Assert::AreEqual(std::string(R"({"workspaces":[{"name":"dev"}]})"), dir.ReadFile(L"Workspaces\\workspaces.json"));
// Clean files should be unchanged
Assert::AreEqual(std::string(R"({"zones":[{"id":1}]})"), dir.ReadFile(L"FancyZones\\settings.json"));
Assert::AreEqual(std::string(R"({"remaps":[]})"), dir.ReadFile(L"KeyboardManager\\default.json"));
}
// Tests RestoreCorruptedConfigs: when the original file has been deleted
// (not corrupted), restore should NOT recreate it from backup. The installer
// may have intentionally removed obsolete config files.
// Covers: configBackup.h RestoreCorruptedConfigs — fs::exists guard.
TEST_METHOD(RestoreSkipsDeletedOriginals)
{
TempDir dir;
dir.WriteFile(L"obsolete.json", R"({"old":true})");
updating::BackupConfigFiles(dir.path());
// Installer deletes the file
std::error_code ec;
fs::remove(dir.path() / L"obsolete.json", ec);
updating::RestoreCorruptedConfigs(dir.path());
// Should NOT be recreated
Assert::IsFalse(dir.FileExists(L"obsolete.json"));
}
// Tests RestoreCorruptedConfigs: when the backup file itself is corrupted
// (e.g., disk error during backup), restore should NOT copy corrupted
// backup over the original — that would make things worse.
// Covers: configBackup.h RestoreCorruptedConfigs — backup integrity check (B2 fix).
TEST_METHOD(RestoreSkipsCorruptedBackup)
{
TempDir dir;
dir.WriteFile(L"settings.json", R"({"theme":"dark"})");
updating::BackupConfigFiles(dir.path());
// Corrupt BOTH the original AND the backup
std::vector<char> nulls(50, '\0');
dir.WriteFileBytes(L"settings.json", nulls);
dir.WriteFileBytes(L"ConfigBackup\\settings.json", nulls);
updating::RestoreCorruptedConfigs(dir.path());
// Original should still be corrupted — we don't restore from bad backup
Assert::IsTrue(updating::IsJsonFileCorrupted(dir.path() / L"settings.json"));
}
};
// Simulates what actually happens during a PowerToys upgrade:
// 1. User has settings from normal use
// 2. Updater backs up before install (Stage 1)
// 3. Installer runs and corrupts some files (simulated)
// 4. Updater restores corrupted files (Stage 2)
// 5. PT relaunches and finds working configs
TEST_CLASS(UpgradeSimulationTests)
{
public:
// Tests full upgrade simulation: backup -> installer corrupts files -> restore.
// Verifies that corrupted files are restored and clean files are untouched.
// Covers: configBackup.h BackupConfigFiles + RestoreCorruptedConfigs —
// end-to-end with 5 modules, 2 corrupted, 3 clean.
// Setup: Realistic config structure with multiple modules.
TEST_METHOD(SimulateUpgradeWithCorruption)
{
TempDir dir;
// === User's real config state before upgrade ===
dir.WriteFile(L"settings.json",
R"({"startup":true,"theme":"dark","run_elevated":false,"download_updates_automatically":true})");
dir.WriteFile(L"FancyZones\\settings.json",
R"({"zones":[{"id":1,"rect":{"x":0,"y":0,"w":960,"h":1080}}]})");
dir.WriteFile(L"Workspaces\\workspaces.json",
R"({"workspaces":[{"name":"dev","apps":["code","terminal"]}]})");
dir.WriteFile(L"KeyboardManager\\default.json",
R"({"remapKeys":{"inProcess":[{"original":"0x41","new":"0x42"}]}})");
dir.WriteFile(L"MouseWithoutBorders\\settings.json",
R"({"machineKey":"abc123","connectToAll":true})");
// Non-JSON files that should be left alone
dir.WriteFile(L"update.log", "2026-04-11 update started");
// === Stage 1: Backup before killing PT ===
updating::BackupConfigFiles(dir.path());
// Verify backup was created correctly
Assert::IsTrue(dir.FileExists(L"ConfigBackup\\settings.json"));
Assert::IsTrue(dir.FileExists(L"ConfigBackup\\FancyZones\\settings.json"));
Assert::IsTrue(dir.FileExists(L"ConfigBackup\\Workspaces\\workspaces.json"));
Assert::IsTrue(dir.FileExists(L"ConfigBackup\\KeyboardManager\\default.json"));
Assert::IsTrue(dir.FileExists(L"ConfigBackup\\MouseWithoutBorders\\settings.json"));
Assert::IsFalse(dir.FileExists(L"ConfigBackup\\update.log"));
// === Installer runs: some files get corrupted (the #46179 scenario) ===
// Workspaces JSON filled with null bytes
dir.WriteFileBytes(L"Workspaces\\workspaces.json", std::vector<char>(512, '\0'));
// Main settings partially corrupted (null bytes injected)
std::vector<char> partialCorrupt = { '{', '"', 's', '\0', '\0', '\0', '\0', '}' };
dir.WriteFileBytes(L"settings.json", partialCorrupt);
// FancyZones, KBM, and MWB survive the install fine
// (this is realistic - not all files get corrupted)
// === Stage 2: Restore after install completes ===
updating::RestoreCorruptedConfigs(dir.path());
// === Verify: PT relaunches and finds working configs ===
// Corrupted files should be restored from backup
Assert::IsFalse(updating::IsJsonFileCorrupted(dir.path() / L"settings.json"));
Assert::IsFalse(updating::IsJsonFileCorrupted(dir.path() / L"Workspaces\\workspaces.json"));
Assert::AreEqual(
std::string(R"({"startup":true,"theme":"dark","run_elevated":false,"download_updates_automatically":true})"),
dir.ReadFile(L"settings.json"));
Assert::AreEqual(
std::string(R"({"workspaces":[{"name":"dev","apps":["code","terminal"]}]})"),
dir.ReadFile(L"Workspaces\\workspaces.json"));
// Clean files should be untouched (not overwritten with backup)
Assert::AreEqual(
std::string(R"({"zones":[{"id":1,"rect":{"x":0,"y":0,"w":960,"h":1080}}]})"),
dir.ReadFile(L"FancyZones\\settings.json"));
Assert::AreEqual(
std::string(R"({"remapKeys":{"inProcess":[{"original":"0x41","new":"0x42"}]}})"),
dir.ReadFile(L"KeyboardManager\\default.json"));
Assert::AreEqual(
std::string(R"({"machineKey":"abc123","connectToAll":true})"),
dir.ReadFile(L"MouseWithoutBorders\\settings.json"));
}
// Tests upgrade from an old version that has fewer modules than the new version.
// Verifies that new module configs (created by the installer) are not touched
// by restore, while corrupted old configs are restored.
// Covers: configBackup.h RestoreCorruptedConfigs — module dir in root that
// has no corresponding backup entry.
// Setup: Old version with 1 module -> backup -> new installer adds module -> corrupt old -> restore.
TEST_METHOD(SimulateUpgradeFromVeryOldVersion)
{
TempDir dir;
// Old version had fewer modules - only settings.json
dir.WriteFile(L"settings.json", R"({"theme":"dark","powertoys_version":"v0.60.0"})");
// Backup
updating::BackupConfigFiles(dir.path());
// New installer creates new module dirs that didn't exist before
dir.WriteFile(L"NewModule\\settings.json", R"({"enabled":true})");
// Old settings get corrupted during upgrade
dir.WriteFileBytes(L"settings.json", std::vector<char>(100, '\0'));
// Restore
updating::RestoreCorruptedConfigs(dir.path());
// Old settings restored
Assert::AreEqual(
std::string(R"({"theme":"dark","powertoys_version":"v0.60.0"})"),
dir.ReadFile(L"settings.json"));
// New module settings untouched (no backup existed for them)
Assert::AreEqual(
std::string(R"({"enabled":true})"),
dir.ReadFile(L"NewModule\\settings.json"));
}
};
// Tests for the update lifecycle: argument passing between Stage 1 and Stage 2,
// relaunch path construction, and the handoff that was broken in #42004/#43011/#44071.
TEST_CLASS(UpdateLifecycleTests)
{
public:
// Tests BuildStage2Arguments: output contains the stage 2 flag, installer path,
// and install directory — all three components needed for Stage 2.
// Covers: updateLifecycle.h BuildStage2Arguments — concatenation logic.
// Setup: Typical paths with spaces (Program Files).
TEST_METHOD(BuildStage2ArgumentsContainsInstallerAndInstallDir)
{
const auto args = updating::BuildStage2Arguments(
L"-update_now_stage_2",
L"C:\\Users\\test\\AppData\\Local\\PowerToys\\Updates\\powertoyssetup-x64.exe",
L"C:\\Program Files\\PowerToys");
// Must contain the stage 2 flag
Assert::IsTrue(args.find(L"-update_now_stage_2") != std::wstring::npos);
// Must contain the installer path (quoted)
Assert::IsTrue(args.find(L"powertoyssetup-x64.exe") != std::wstring::npos);
// Must contain the install directory (quoted) — this was MISSING before our fix
Assert::IsTrue(args.find(L"C:\\Program Files\\PowerToys") != std::wstring::npos);
}
// Tests BuildStage2Arguments: both paths are wrapped in double quotes to
// survive CommandLineToArgvW parsing when paths contain spaces.
// Covers: updateLifecycle.h BuildStage2Arguments — quote wrapping.
// Setup: Installer path with spaces.
TEST_METHOD(BuildStage2ArgumentsQuotesBothPaths)
{
const auto args = updating::BuildStage2Arguments(
L"-update_now_stage_2",
L"C:\\path with spaces\\installer.exe",
L"C:\\Program Files\\PowerToys");
// Count quotes — should have 4 (open/close for each path)
size_t quoteCount = std::count(args.begin(), args.end(), L'"');
Assert::AreEqual(size_t{ 4 }, quoteCount);
}
// Tests BuildPowerToysExePath: appends "PowerToys.exe" to the install dir.
// Covers: updateLifecycle.h BuildPowerToysExePath — fs::path / operator.
// Setup: Standard install path without trailing backslash.
TEST_METHOD(BuildPowerToysExePathAppendsExeName)
{
const auto path = updating::BuildPowerToysExePath(L"C:\\Program Files\\PowerToys");
Assert::AreEqual(std::wstring(L"C:\\Program Files\\PowerToys\\PowerToys.exe"), path);
}
// Tests BuildPowerToysExePath: trailing backslash does not produce double
// backslash (e.g., "...PowerToys\\PowerToys.exe").
// Covers: updateLifecycle.h BuildPowerToysExePath — fs::path normalizes separators.
// Setup: Install path with trailing backslash.
TEST_METHOD(BuildPowerToysExePathHandlesTrailingBackslash)
{
const auto path = updating::BuildPowerToysExePath(L"C:\\Program Files\\PowerToys\\");
Assert::AreEqual(std::wstring(L"C:\\Program Files\\PowerToys\\PowerToys.exe"), path);
}
// Tests BuildPowerToysExePath: empty string produces just "PowerToys.exe".
// Covers: updateLifecycle.h BuildPowerToysExePath — fs::path with empty input.
// Setup: Empty install directory string.
TEST_METHOD(BuildPowerToysExePathHandlesEmptyString)
{
const auto path = updating::BuildPowerToysExePath(L"");
Assert::AreEqual(std::wstring(L"PowerToys.exe"), path);
}
// Tests CanRelaunchAfterUpdate: returns true when Stage 2 receives
// the install directory (argCount >= 4), false otherwise.
// This is the gate that prevents relaunch when using an old Stage 1
// that didn't pass the install dir (#42004/#43011/#44071).
// Covers: updateLifecycle.h CanRelaunchAfterUpdate.
TEST_METHOD(CanRelaunchReflectsArgCount)
{
// Old Stage 1 (pre-fix): only passed action + installer = 3 args
Assert::IsFalse(updating::CanRelaunchAfterUpdate(0));
Assert::IsFalse(updating::CanRelaunchAfterUpdate(1));
Assert::IsFalse(updating::CanRelaunchAfterUpdate(2));
Assert::IsFalse(updating::CanRelaunchAfterUpdate(3));
// New Stage 1 (post-fix): passes action + installer + installDir = 4 args
Assert::IsTrue(updating::CanRelaunchAfterUpdate(4));
Assert::IsTrue(updating::CanRelaunchAfterUpdate(5));
}
// Tests BuildStage2Arguments + CommandLineToArgvW round-trip: the exact
// scenario where Stage 1 builds args and Windows parses them in Stage 2.
// Verifies quoting is correct so paths with spaces survive the round trip.
// Covers: updateLifecycle.h BuildStage2Arguments — quote correctness.
// Setup: Realistic paths with spaces and version numbers.
TEST_METHOD(Stage2ArgumentsCanBeRoundTrippedThroughCommandLineToArgvW)
{
const std::wstring installerPath = L"C:\\Users\\test user\\AppData\\Local\\PowerToys\\Updates\\powertoyssetup-0.86.0-x64.exe";
const std::wstring installDir = L"C:\\Program Files\\PowerToys";
const auto args = updating::BuildStage2Arguments(L"-update_now_stage_2", installerPath, installDir);
// Simulate what Windows does: prepend a fake exe name and parse
std::wstring commandLine = L"PowerToys.Update.exe " + args;
int argc = 0;
LPWSTR* argv = CommandLineToArgvW(commandLine.c_str(), &argc);
Assert::IsNotNull(argv);
Assert::AreEqual(4, argc);
Assert::AreEqual(std::wstring(L"-update_now_stage_2"), std::wstring(argv[1]));
Assert::AreEqual(installerPath, std::wstring(argv[2]));
Assert::AreEqual(installDir, std::wstring(argv[3]));
LocalFree(argv);
}
};
}

View File

@@ -1,45 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
<PropertyGroup Label="Globals">
<VCProjectVersion>16.0</VCProjectVersion>
<ProjectGuid>{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}</ProjectGuid>
<Keyword>Win32Proj</Keyword>
<RootNamespace>UpdatingUnitTests</RootNamespace>
<ProjectSubType>NativeUnitTestProject</ProjectSubType>
<ProjectName>Updating.UnitTests</ProjectName>
</PropertyGroup>
<PropertyGroup Label="Configuration">
<ConfigurationType>DynamicLibrary</ConfigurationType>
<UseOfMfc>false</UseOfMfc>
<OutDir>$(SolutionDir)$(Platform)\$(Configuration)\tests\UpdatingUnitTests\</OutDir>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
<ImportGroup Label="ExtensionSettings">
</ImportGroup>
<ImportGroup Label="Shared">
</ImportGroup>
<ImportGroup Label="PropertySheets">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<PropertyGroup Label="UserMacros" />
<ItemDefinitionGroup>
<ClCompile>
<AdditionalIncludeDirectories>..\;..\..\;..\..\..\;$(VCInstallDir)UnitTest\include;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
</ClCompile>
<Link>
<AdditionalLibraryDirectories>$(VCInstallDir)UnitTest\lib;%(AdditionalLibraryDirectories)</AdditionalLibraryDirectories>
</Link>
</ItemDefinitionGroup>
<ItemGroup>
<ClCompile Include="UpdatingTests.cpp" />
<ClCompile Include="pch.cpp">
<PrecompiledHeader Condition="'$(UsePrecompiledHeaders)' != 'false'">Create</PrecompiledHeader>
</ClCompile>
</ItemGroup>
<ItemGroup>
<ClInclude Include="pch.h" />
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
</Project>

View File

@@ -1,5 +0,0 @@
// 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.
#include "pch.h"

View File

@@ -1,17 +0,0 @@
// 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.
#ifndef PCH_H
#define PCH_H
#include <atomic>
#include <Windows.h>
// Suppressing 26466 - Don't use static_cast downcasts - in CppUnitTest.h
#pragma warning(push)
#pragma warning(disable : 26466)
#include "CppUnitTest.h"
#pragma warning(pop)
#endif //PCH_H

View File

@@ -1,170 +0,0 @@
// 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.
#pragma once
#include <filesystem>
#include <fstream>
#include <string>
namespace updating
{
namespace fs = std::filesystem;
// Check if a JSON file is corrupted (contains null bytes, as seen in #46179)
inline bool IsJsonFileCorrupted(const fs::path& filePath)
{
try
{
std::ifstream file(filePath, std::ios::binary);
if (!file.is_open())
{
return false;
}
constexpr size_t c_readChunkSize{ 4096 };
char buffer[c_readChunkSize];
while (file.read(buffer, c_readChunkSize) || file.gcount() > 0)
{
const auto bytesRead = file.gcount();
for (std::streamsize i = 0; i < bytesRead; ++i)
{
if (buffer[i] == '\0')
{
return true;
}
}
}
return false;
}
catch (...)
{
return true;
}
}
// Backup all JSON config files before update to protect against corruption (#46179)
inline void BackupConfigFiles(const fs::path& rootPath)
{
try
{
const fs::path backupDir = rootPath / L"ConfigBackup";
std::error_code ec;
fs::remove_all(backupDir, ec);
// Note: remove_all failure means stale backup may persist; continue anyway
// since create_directories will overlay
fs::create_directories(backupDir, ec);
if (ec)
{
return;
}
for (const auto& entry : fs::directory_iterator(rootPath, ec))
{
if (ec)
{
break;
}
if (entry.is_regular_file() && entry.path().extension() == L".json")
{
fs::copy_file(entry.path(), backupDir / entry.path().filename(), fs::copy_options::overwrite_existing, ec);
}
else if (entry.is_directory())
{
const auto dirName = entry.path().filename().wstring();
if (dirName == L"ConfigBackup" || dirName == L"Updates")
{
continue;
}
const auto moduleBackup = backupDir / entry.path().filename();
fs::create_directories(moduleBackup, ec);
std::error_code moduleEc;
for (const auto& moduleEntry : fs::directory_iterator(entry.path(), moduleEc))
{
if (moduleEc)
{
break;
}
if (moduleEntry.is_regular_file() && moduleEntry.path().extension() == L".json")
{
fs::copy_file(moduleEntry.path(), moduleBackup / moduleEntry.path().filename(), fs::copy_options::overwrite_existing, moduleEc);
}
}
}
}
}
catch (...)
{
// Intentionally swallowed — update must not fail due to backup errors.
// Logging would require spdlog dependency which is unavailable in test context.
}
}
// Restore JSON configs from backup if corruption is detected after update
inline void RestoreCorruptedConfigs(const fs::path& rootPath)
{
try
{
const fs::path backupDir = rootPath / L"ConfigBackup";
if (!fs::exists(backupDir))
{
return;
}
std::error_code ec;
for (const auto& backupEntry : fs::directory_iterator(backupDir, ec))
{
if (ec)
{
break;
}
if (backupEntry.is_regular_file() && backupEntry.path().extension() == L".json")
{
const auto originalPath = rootPath / backupEntry.path().filename();
// Only restore if the backup itself is valid
if (fs::exists(originalPath) && IsJsonFileCorrupted(originalPath) && !IsJsonFileCorrupted(backupEntry.path()))
{
fs::copy_file(backupEntry.path(), originalPath, fs::copy_options::overwrite_existing, ec);
}
}
else if (backupEntry.is_directory())
{
const auto moduleDir = rootPath / backupEntry.path().filename();
std::error_code moduleEc;
for (const auto& moduleBackupEntry : fs::directory_iterator(backupEntry.path(), moduleEc))
{
if (moduleEc)
{
break;
}
if (moduleBackupEntry.is_regular_file() && moduleBackupEntry.path().extension() == L".json")
{
const auto originalModulePath = moduleDir / moduleBackupEntry.path().filename();
// Only restore if the backup itself is valid
if (fs::exists(originalModulePath) && IsJsonFileCorrupted(originalModulePath) && !IsJsonFileCorrupted(moduleBackupEntry.path()))
{
fs::copy_file(moduleBackupEntry.path(), originalModulePath, fs::copy_options::overwrite_existing, moduleEc);
}
}
}
}
}
}
catch (...)
{
// Intentionally swallowed — update must not fail due to backup errors.
// Logging would require spdlog dependency which is unavailable in test context.
}
}
}

View File

@@ -1,47 +0,0 @@
// 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.
#pragma once
#include <filesystem>
#include <string>
namespace updating
{
namespace fs = std::filesystem;
// Build the command-line arguments for Stage 2.
// Stage 1 passes the installer path and the PT install directory
// so Stage 2 can run the installer and relaunch PowerToys afterward.
// Note: paths containing embedded double-quote characters are not supported.
// This is safe because install paths come from get_module_folderpath().
inline std::wstring BuildStage2Arguments(
const std::wstring& stage2Flag,
const fs::path& installerPath,
const fs::path& installDir)
{
std::wstring arguments{ stage2Flag };
arguments += L" \"";
arguments += installerPath.c_str();
arguments += L"\" \"";
arguments += installDir.c_str();
arguments += L"\"";
return arguments;
}
// Build the full path to PowerToys.exe from the install directory.
// Used by Stage 2 to relaunch PT after a successful update.
inline std::wstring BuildPowerToysExePath(const std::wstring& installDir)
{
return (std::filesystem::path(installDir) / L"PowerToys.exe").wstring();
}
// Determine whether Stage 2 has enough information to relaunch PT.
// Returns true if the install directory argument was provided.
inline bool CanRelaunchAfterUpdate(int argCount)
{
// args[0]=exe, args[1]=action, args[2]=installer, args[3]=installDir
return argCount >= 4;
}
}

View File

@@ -17,10 +17,7 @@
<PackageReference Include="MSTest" />
<PackageReference Include="System.IO.Abstractions" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" />
<PackageReference Include="System.Diagnostics.EventLog">
<!-- This package is a dependency of Microsoft.Extensions.Logging.EventLog, but we need to set it here so we can exclude the assets, so it doesn't conflict with the 8.0.1 dll coming from .NET SDK. -->
<ExcludeAssets>runtime</ExcludeAssets> <!-- Should already be present on .net sdk runtime, so we avoid the conflicting runtime version from nuget -->
</PackageReference>
<!-- System.Diagnostics.EventLog is now provided to all C# projects via Directory.Build.props -->
<!-- HACK: CmdPal uses CommunityToolkit.Common directly. Align the version. -->
<PackageReference Include="CommunityToolkit.Common" />
</ItemGroup>

View File

@@ -20,9 +20,7 @@
<PackageReference Include="System.Configuration.ConfigurationManager">
<ExcludeAssets>runtime</ExcludeAssets> <!-- Should already be present on .net sdk runtime, so we avoid the conflicting runtime version from nuget -->
</PackageReference>
<PackageReference Include="System.Diagnostics.EventLog">
<ExcludeAssets>runtime</ExcludeAssets> <!-- Should already be present on .net sdk runtime, so we avoid the conflicting runtime version from nuget -->
</PackageReference>
<!-- System.Diagnostics.EventLog is now provided to all C# projects via Directory.Build.props -->
<PackageReference Include="System.Diagnostics.PerformanceCounter">
<ExcludeAssets>runtime</ExcludeAssets> <!-- Should already be present on .net sdk runtime, so we avoid the conflicting runtime version from nuget -->
</PackageReference>

View File

@@ -42,9 +42,7 @@
<PackageReference Include="System.Configuration.ConfigurationManager">
<ExcludeAssets>runtime</ExcludeAssets> <!-- Should already be present on .net sdk runtime, so we avoid the conflicting runtime version from nuget -->
</PackageReference>
<PackageReference Include="System.Diagnostics.EventLog">
<ExcludeAssets>runtime</ExcludeAssets> <!-- Should already be present on .net sdk runtime, so we avoid the conflicting runtime version from nuget -->
</PackageReference>
<!-- System.Diagnostics.EventLog is now provided to all C# projects via Directory.Build.props -->
<PackageReference Include="System.Diagnostics.PerformanceCounter">
<ExcludeAssets>runtime</ExcludeAssets> <!-- Should already be present on .net sdk runtime, so we avoid the conflicting runtime version from nuget -->
</PackageReference>

View File

@@ -20,9 +20,7 @@
<ItemGroup>
<PackageReference Include="System.ServiceProcess.ServiceController" />
<PackageReference Include="System.Diagnostics.EventLog">
<ExcludeAssets>runtime</ExcludeAssets> <!-- Should already be present on .net sdk runtime, so we avoid the conflicting runtime version from nuget -->
</PackageReference>
<!-- System.Diagnostics.EventLog is now provided to all C# projects via Directory.Build.props -->
</ItemGroup>
<ItemGroup>

View File

@@ -35,9 +35,7 @@
<PackageReference Include="System.Configuration.ConfigurationManager">
<ExcludeAssets>runtime</ExcludeAssets> <!-- Should already be present on .net sdk runtime, so we avoid the conflicting runtime version from nuget -->
</PackageReference>
<PackageReference Include="System.Diagnostics.EventLog">
<ExcludeAssets>runtime</ExcludeAssets> <!-- Should already be present on .net sdk runtime, so we avoid the conflicting runtime version from nuget -->
</PackageReference>
<!-- System.Diagnostics.EventLog is now provided to all C# projects via Directory.Build.props -->
<PackageReference Include="System.Diagnostics.PerformanceCounter">
<ExcludeAssets>runtime</ExcludeAssets> <!-- Should already be present on .net sdk runtime, so we avoid the conflicting runtime version from nuget -->
</PackageReference>

View File

@@ -0,0 +1,283 @@
// 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;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Peek.Common.Helpers;
namespace Peek.Common.UnitTests
{
[TestClass]
public class MathHelperTests
{
/// <summary>
/// Product code: MathHelper.Modulo(int, int)
/// What: Verifies standard modulo for positive numbers (7 mod 3 = 1)
/// Why: Baseline correctness — ensures the positive path matches C# % operator
/// </summary>
[TestMethod]
public void Modulo_PositiveNumbers_ShouldReturnStandardModulo()
{
Assert.AreEqual(1, MathHelper.Modulo(7, 3));
}
/// <summary>
/// Product code: MathHelper.Modulo(int, int)
/// What: Verifies that 0 mod n = 0
/// Why: Zero dividend is a common boundary — must not throw or return non-zero
/// </summary>
[TestMethod]
public void Modulo_ZeroDividend_ShouldReturnZero()
{
Assert.AreEqual(0, MathHelper.Modulo(0, 5));
}
/// <summary>
/// Product code: MathHelper.Modulo(int, int)
/// What: Verifies that exact division yields zero remainder
/// Why: Guards against off-by-one in the modulo formula
/// </summary>
[TestMethod]
public void Modulo_ExactDivision_ShouldReturnZero()
{
Assert.AreEqual(0, MathHelper.Modulo(6, 3));
}
/// <summary>
/// Product code: MathHelper.Modulo(int, int) — negative dividend path
/// What: Verifies that -1 mod 3 = 2 (mathematical modulo, not C# remainder)
/// Why: C# % returns -1 for negative dividends; Modulo wraps to positive range
/// </summary>
[TestMethod]
public void Modulo_NegativeDividend_ShouldReturnPositiveResult()
{
Assert.AreEqual(2, MathHelper.Modulo(-1, 3));
}
/// <summary>
/// Product code: MathHelper.Modulo(int, int) — negative dividend with larger magnitude
/// What: Verifies -7 mod 3 = 2 (wraps correctly for larger negative values)
/// Why: Tests the full formula: ((-7 % 3) + 3) % 3 = (-1 + 3) % 3 = 2
/// </summary>
[TestMethod]
public void Modulo_NegativeDividend_LargerMagnitude_ShouldWrapCorrectly()
{
Assert.AreEqual(2, MathHelper.Modulo(-7, 3));
}
/// <summary>
/// Product code: MathHelper.Modulo(int, int)
/// What: Verifies -6 mod 3 = 0 (exact negative multiple)
/// Why: Edge case where negative dividend is an exact multiple of divisor
/// </summary>
[TestMethod]
public void Modulo_NegativeDividend_ExactMultiple_ShouldReturnZero()
{
Assert.AreEqual(0, MathHelper.Modulo(-6, 3));
}
/// <summary>
/// Product code: MathHelper.Modulo(int, int)
/// What: Verifies n mod 1 = 0 for positive n
/// Why: Divisor of 1 always yields 0 — guards against divide-by-zero edge case
/// </summary>
[TestMethod]
public void Modulo_PositiveDividend_DivisorOne_ShouldReturnZero()
{
Assert.AreEqual(0, MathHelper.Modulo(5, 1));
}
/// <summary>
/// Product code: MathHelper.Modulo(int, int)
/// What: Verifies -n mod 1 = 0 for negative n
/// Why: Tests the negative path with divisor=1 (simplest negative case)
/// </summary>
[TestMethod]
public void Modulo_NegativeDividend_DivisorOne_ShouldReturnZero()
{
Assert.AreEqual(0, MathHelper.Modulo(-5, 1));
}
/// <summary>
/// Product code: MathHelper.Modulo(int, int)
/// What: Verifies modulo works with large numbers (1000001 mod 1000000 = 1)
/// Why: Tests that no integer overflow occurs in the formula for large operands
/// </summary>
[TestMethod]
public void Modulo_LargePositiveNumbers_ShouldWork()
{
Assert.AreEqual(1, MathHelper.Modulo(1000001, 1000000));
}
/// <summary>
/// Product code: MathHelper.Modulo(int, int)
/// What: Verifies that dividend less than divisor returns the dividend itself
/// Why: 2 mod 5 = 2 — no division happens, just returns the dividend
/// </summary>
[TestMethod]
public void Modulo_DividendLessThanDivisor_ShouldReturnDividend()
{
Assert.AreEqual(2, MathHelper.Modulo(2, 5));
}
/// <summary>
/// Product code: MathHelper.Modulo(int, int) — negative dividend
/// What: Verifies -2 mod 3 = 1 (mathematical modulo)
/// Why: Tests another negative wrap case: ((-2 % 3) + 3) % 3 = (-2 + 3) % 3 = 1
/// </summary>
[TestMethod]
public void Modulo_NegativeDividend_MinusTwo_ModThree_ShouldReturnOne()
{
Assert.AreEqual(1, MathHelper.Modulo(-2, 3));
}
/// <summary>
/// Product code: MathHelper.Modulo(int, int) — zero divisor
/// What: Documents that dividing by zero throws DivideByZeroException
/// Why: Edge case — callers must handle zero divisor; the method does not guard against it
/// </summary>
[TestMethod]
[ExpectedException(typeof(ArgumentOutOfRangeException))]
public void Modulo_ZeroDivisor_ShouldThrow()
{
MathHelper.Modulo(5, 0);
}
[TestMethod]
[ExpectedException(typeof(ArgumentOutOfRangeException))]
public void Modulo_NegativeDivisor_ShouldThrow()
{
MathHelper.Modulo(-1, -3);
}
/// <summary>
/// Product code: MathHelper.NumberOfDigits(int)
/// What: Verifies that 0 has 1 digit
/// Why: Zero is a special case — Math.Abs(0).ToString() = "0" which has length 1
/// </summary>
[TestMethod]
public void NumberOfDigits_Zero_ShouldReturnOne()
{
Assert.AreEqual(1, MathHelper.NumberOfDigits(0));
}
/// <summary>
/// Product code: MathHelper.NumberOfDigits(int)
/// What: Verifies single-digit number returns 1
/// Why: Baseline case for the simplest positive input
/// </summary>
[TestMethod]
public void NumberOfDigits_SingleDigit_ShouldReturnOne()
{
Assert.AreEqual(1, MathHelper.NumberOfDigits(5));
}
/// <summary>
/// Product code: MathHelper.NumberOfDigits(int)
/// What: Verifies two-digit number returns 2
/// Why: Tests the transition from single to multi-digit
/// </summary>
[TestMethod]
public void NumberOfDigits_TwoDigits_ShouldReturnTwo()
{
Assert.AreEqual(2, MathHelper.NumberOfDigits(42));
}
/// <summary>
/// Product code: MathHelper.NumberOfDigits(int)
/// What: Verifies three-digit number (100) returns 3
/// Why: Tests exact power of 10 boundary (100 vs 99)
/// </summary>
[TestMethod]
public void NumberOfDigits_ThreeDigits_ShouldReturnThree()
{
Assert.AreEqual(3, MathHelper.NumberOfDigits(100));
}
/// <summary>
/// Product code: MathHelper.NumberOfDigits(int) — negative number
/// What: Verifies that sign is ignored via Math.Abs
/// Why: Negative sign is not a digit — only magnitude matters
/// </summary>
[TestMethod]
public void NumberOfDigits_NegativeNumber_ShouldIgnoreSign()
{
Assert.AreEqual(3, MathHelper.NumberOfDigits(-123));
}
/// <summary>
/// Product code: MathHelper.NumberOfDigits(int) — int.MaxValue
/// What: Verifies int.MaxValue (2147483647) returns 10 digits
/// Why: Upper boundary of int range — ensures no overflow in Math.Abs path
/// </summary>
[TestMethod]
public void NumberOfDigits_MaxValue_ShouldReturnTenDigits()
{
Assert.AreEqual(10, MathHelper.NumberOfDigits(int.MaxValue));
}
/// <summary>
/// Product code: MathHelper.NumberOfDigits(int) — near int.MinValue
/// What: Verifies int.MinValue + 1 (-2147483647) returns 10 digits
/// Why: int.MinValue itself overflows Math.Abs — this tests the safe boundary
/// </summary>
[TestMethod]
public void NumberOfDigits_MinValue_ShouldHandleAbsoluteValue()
{
Assert.AreEqual(10, MathHelper.NumberOfDigits(int.MinValue + 1));
}
/// <summary>
/// Product code: MathHelper.NumberOfDigits(int) — int.MinValue
/// What: Documents that int.MinValue causes OverflowException due to Math.Abs
/// Why: Edge case — Math.Abs(-2147483648) has no positive int representation
/// </summary>
[TestMethod]
[ExpectedException(typeof(OverflowException))]
public void NumberOfDigits_MinValue_ShouldThrowOverflow()
{
MathHelper.NumberOfDigits(int.MinValue);
}
/// <summary>
/// Product code: MathHelper.NumberOfDigits(int) — powers of 10
/// What: Verifies digit count at each power-of-10 boundary (1, 10, 100, 1000, 10000)
/// Why: Powers of 10 are the exact transition points — off-by-one bugs surface here
/// </summary>
[TestMethod]
public void NumberOfDigits_PowerOfTen_ShouldReturnCorrectCount()
{
Assert.AreEqual(1, MathHelper.NumberOfDigits(1));
Assert.AreEqual(2, MathHelper.NumberOfDigits(10));
Assert.AreEqual(3, MathHelper.NumberOfDigits(100));
Assert.AreEqual(4, MathHelper.NumberOfDigits(1000));
Assert.AreEqual(5, MathHelper.NumberOfDigits(10000));
}
/// <summary>
/// Product code: MathHelper.NumberOfDigits(int) — 9→10 boundary
/// What: Verifies that 9 returns 1 digit and 10 returns 2 digits
/// Why: Tests the first single-to-double digit transition
/// </summary>
[TestMethod]
public void NumberOfDigits_BoundaryValues_NineAndTen()
{
Assert.AreEqual(1, MathHelper.NumberOfDigits(9));
Assert.AreEqual(2, MathHelper.NumberOfDigits(10));
}
/// <summary>
/// Product code: MathHelper.NumberOfDigits(int) — 99→100 boundary
/// What: Verifies that 99 returns 2 digits and 100 returns 3 digits
/// Why: Tests the double-to-triple digit transition
/// </summary>
[TestMethod]
public void NumberOfDigits_BoundaryValues_NinetyNineAndHundred()
{
Assert.AreEqual(2, MathHelper.NumberOfDigits(99));
Assert.AreEqual(3, MathHelper.NumberOfDigits(100));
}
}
}

View File

@@ -0,0 +1,156 @@
// 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;
using Peek.Common.Helpers;
namespace Peek.Common.UnitTests
{
[TestClass]
public class PathHelperTests
{
/// <summary>
/// Product code: PathHelper.IsUncPath(string)
/// What: Verifies a standard UNC path (\\server\share) is recognized
/// Why: Baseline positive case — the most common UNC format
/// </summary>
[TestMethod]
public void IsUncPath_StandardUncPath_ShouldReturnTrue()
{
Assert.IsTrue(PathHelper.IsUncPath(@"\\server\share"));
}
/// <summary>
/// Product code: PathHelper.IsUncPath(string)
/// What: Verifies a UNC path with nested subfolders and file is recognized
/// Why: Real-world UNC paths include subfolders — must not fail on deeper paths
/// </summary>
[TestMethod]
public void IsUncPath_UncPathWithSubfolder_ShouldReturnTrue()
{
Assert.IsTrue(PathHelper.IsUncPath(@"\\server\share\folder\file.txt"));
}
/// <summary>
/// Product code: PathHelper.IsUncPath(string)
/// What: Verifies a UNC path with dotted server name (FQDN) is recognized
/// Why: Enterprise environments use FQDN server names (e.g., server.corp.com)
/// </summary>
[TestMethod]
public void IsUncPath_UncPathWithDottedServer_ShouldReturnTrue()
{
Assert.IsTrue(PathHelper.IsUncPath(@"\\server.domain.com\share"));
}
/// <summary>
/// Product code: PathHelper.IsUncPath(string)
/// What: Verifies a UNC path with IP address is recognized
/// Why: Some network shares use IP addresses instead of hostnames
/// </summary>
[TestMethod]
public void IsUncPath_UncPathWithIPAddress_ShouldReturnTrue()
{
Assert.IsTrue(PathHelper.IsUncPath(@"\\192.168.1.1\share"));
}
/// <summary>
/// Product code: PathHelper.IsUncPath(string)
/// What: Verifies a local drive path (C:\...) is not classified as UNC
/// Why: Drive-letter paths are local — confusing them with UNC would break file access
/// </summary>
[TestMethod]
public void IsUncPath_LocalDrivePath_ShouldReturnFalse()
{
Assert.IsFalse(PathHelper.IsUncPath(@"C:\Users\test\file.txt"));
}
/// <summary>
/// Product code: PathHelper.IsUncPath(string)
/// What: Verifies a bare drive root (D:\) is not classified as UNC
/// Why: Drive roots are local paths — shortest possible local absolute path
/// </summary>
[TestMethod]
public void IsUncPath_LocalRootPath_ShouldReturnFalse()
{
Assert.IsFalse(PathHelper.IsUncPath(@"D:\"));
}
/// <summary>
/// Product code: PathHelper.IsUncPath(string)
/// What: Verifies a relative path is not classified as UNC
/// Why: Relative paths have no server component — Uri.TryCreate fails for them
/// </summary>
[TestMethod]
public void IsUncPath_RelativePath_ShouldReturnFalse()
{
Assert.IsFalse(PathHelper.IsUncPath(@"folder\file.txt"));
}
/// <summary>
/// Product code: PathHelper.IsUncPath(string)
/// What: Verifies an empty string returns false without throwing
/// Why: Empty input is a common edge case — must not crash
/// </summary>
[TestMethod]
public void IsUncPath_EmptyString_ShouldReturnFalse()
{
Assert.IsFalse(PathHelper.IsUncPath(string.Empty));
}
/// <summary>
/// Product code: PathHelper.IsUncPath(string)
/// What: Verifies an HTTP URL is not classified as UNC
/// Why: HTTP URLs are not file paths — Uri.IsUnc must return false for them
/// </summary>
[TestMethod]
public void IsUncPath_HttpUrl_ShouldReturnFalse()
{
Assert.IsFalse(PathHelper.IsUncPath("http://example.com/path"));
}
/// <summary>
/// Product code: PathHelper.IsUncPath(string)
/// What: Verifies a file:/// URI (local file) is not classified as UNC
/// Why: file:///C:/... is a local file URI, not a network UNC path
/// </summary>
[TestMethod]
public void IsUncPath_FileUri_ShouldReturnFalse()
{
Assert.IsFalse(PathHelper.IsUncPath("file:///C:/Users/test/file.txt"));
}
/// <summary>
/// Product code: PathHelper.IsUncPath(string)
/// What: Verifies a standard UNC path with a file component (\\server\share\file.txt)
/// Why: Tests UNC with a trailing file name — distinct from share-only paths
/// </summary>
[TestMethod]
public void IsUncPath_StandardUncWithFile_ShouldReturnTrue()
{
Assert.IsTrue(PathHelper.IsUncPath(@"\\server\share\file.txt"));
}
/// <summary>
/// Product code: PathHelper.IsUncPath(string)
/// What: Verifies a single-backslash prefix is not classified as UNC
/// Why: UNC requires exactly two leading backslashes — one backslash is not UNC
/// </summary>
[TestMethod]
public void IsUncPath_SingleBackslash_ShouldReturnFalse()
{
Assert.IsFalse(PathHelper.IsUncPath(@"\server\share"));
}
/// <summary>
/// Product code: PathHelper.IsUncPath(string)
/// What: Verifies null input returns false without throwing
/// Why: Null is a common edge case — Uri.TryCreate handles null gracefully
/// </summary>
[TestMethod]
public void IsUncPath_NullInput_ShouldReturnFalse()
{
Assert.IsFalse(PathHelper.IsUncPath(null));
}
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
<PropertyGroup>
<IsPackable>false</IsPackable>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\tests\Peek.Common.Tests\</OutputPath>
<RootNamespace>Peek.Common.UnitTests</RootNamespace>
<AssemblyName>PowerToys.Peek.Common.UnitTests</AssemblyName>
<OutputType>Exe</OutputType>
<!-- Pull in the WindowsDesktop shared framework so that runtime DLLs (System.CodeDom,
Microsoft.VisualBasic, WindowsBase, etc.) resolve to the same version as all other
projects in the solution. Without this, transitive NuGet packages provide older
versions that fail the verifyDepsJsonLibraryVersions CI check. -->
<UseWindowsForms>true</UseWindowsForms>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Peek.Common\Peek.Common.csproj" />
</ItemGroup>
</Project>

View File

@@ -11,6 +11,11 @@ namespace Peek.Common.Helpers
{
public static int Modulo(int a, int b)
{
if (b <= 0)
{
throw new ArgumentOutOfRangeException(nameof(b), b, "Divisor must be positive.");
}
return a < 0 ? ((a % b) + b) % b : a % b;
}

View File

@@ -0,0 +1,369 @@
// 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;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerAccent.Core.Services;
using PowerAccent.Core.Tools;
namespace PowerAccent.Core.UnitTests
{
[TestClass]
public class CalculationTests
{
// Screen representing a standard 1920x1080 monitor at position (0,0)
private static readonly Rect StandardScreen = new Rect(0, 0, 1920, 1080);
// A typical toolbar window size (300x50 in WPF DIPs)
private static readonly Size ToolbarWindow = new Size(300, 50);
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromCaret
/// What: Verifies toolbar is centered horizontally above the caret at screen center
/// Why: The most common placement scenario — regression here affects every user
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromCaret_CenterOfScreen_ShouldCenterToolbar()
{
var caret = new Point(960, 540);
var result = Calculation.GetRawCoordinatesFromCaret(caret, StandardScreen, ToolbarWindow);
// X should be caret.X - window.Width/2 = 960 - 150 = 810
Assert.AreEqual(810.0, result.X);
// Y should be caret.Y - window.Height - 20 = 540 - 50 - 20 = 470
Assert.AreEqual(470.0, result.Y);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromCaret — left edge clamping
/// What: Verifies toolbar is clamped to screen left when caret is near left edge
/// Why: Without clamping, toolbar would extend off-screen and be unusable
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromCaret_NearLeftEdge_ShouldClampToScreenLeft()
{
var caret = new Point(50, 540);
var result = Calculation.GetRawCoordinatesFromCaret(caret, StandardScreen, ToolbarWindow);
// left = 50 - 150 = -100, which is < screen.X (0), so X should be clamped to 0
Assert.AreEqual(0.0, result.X);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromCaret — right edge clamping
/// What: Verifies toolbar is clamped to screen right when caret is near right edge
/// Why: Toolbar extending past screen right would be clipped by the display
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromCaret_NearRightEdge_ShouldClampToScreenRight()
{
var caret = new Point(1900, 540);
var result = Calculation.GetRawCoordinatesFromCaret(caret, StandardScreen, ToolbarWindow);
// left = 1900 - 150 = 1750, left + window.Width = 1750 + 300 = 2050 > 1920
// So X should be clamped to screen.X + screen.Width - window.Width = 1920 - 300 = 1620
Assert.AreEqual(1620.0, result.X);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromCaret — top edge flip
/// What: Verifies toolbar is placed below the caret when there is no room above
/// Why: When caret is near top of screen, toolbar must flip below to remain visible
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromCaret_NearTopEdge_ShouldPlaceBelow()
{
var caret = new Point(960, 30);
var result = Calculation.GetRawCoordinatesFromCaret(caret, StandardScreen, ToolbarWindow);
// top = 30 - 50 - 20 = -40, which is < screen.Y (0)
// So Y should be caret.Y + 20 = 50 (placed below caret)
Assert.AreEqual(50.0, result.Y);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromCaret — normal above placement
/// What: Verifies toolbar is placed above the caret when there is sufficient space
/// Why: Default behavior — toolbar should appear above to avoid occluding text below
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromCaret_SufficientSpaceAbove_ShouldPlaceAbove()
{
var caret = new Point(960, 200);
var result = Calculation.GetRawCoordinatesFromCaret(caret, StandardScreen, ToolbarWindow);
// top = 200 - 50 - 20 = 130, which is >= screen.Y (0)
Assert.AreEqual(130.0, result.Y);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromCaret — multi-monitor offset
/// What: Verifies toolbar respects non-zero screen origin (second monitor at X=1920)
/// Why: Multi-monitor setups have offset screen coordinates — clamping must use screen.X
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromCaret_OffsetScreen_ShouldRespectScreenOrigin()
{
var screen = new Rect(1920, 0, 1920, 1080);
var caret = new Point(1950, 540);
var result = Calculation.GetRawCoordinatesFromCaret(caret, screen, ToolbarWindow);
// left = 1950 - 150 = 1800, which is < screen.X (1920)
// So X should be clamped to 1920
Assert.AreEqual(1920.0, result.X);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromCaret — exact boundary case
/// What: Verifies no clamping occurs when toolbar fits exactly at the boundary
/// Why: Off-by-one errors in boundary checks are common — this catches ≤ vs &lt; bugs
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromCaret_ExactBoundary_ShouldNotClamp()
{
var caret = new Point(150, 100);
var result = Calculation.GetRawCoordinatesFromCaret(caret, StandardScreen, ToolbarWindow);
// left = 150 - 150 = 0, which == screen.X, so no left clamp needed
Assert.AreEqual(0.0, result.X);
// top = 100 - 50 - 20 = 30, which >= 0
Assert.AreEqual(30.0, result.Y);
}
[TestMethod]
[ExpectedException(typeof(NotImplementedException))]
public void GetRawCoordinatesFromPosition_InvalidPosition_ShouldThrow()
{
Calculation.GetRawCoordinatesFromPosition((Position)999, StandardScreen, ToolbarWindow, 1.0);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromPosition with Position.Top
/// What: Verifies toolbar is horizontally centered at the top of the screen with offset
/// Why: Exact value test — catches formula regressions in the Top positioning branch
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromPosition_TopCenter_ShouldBeCenteredAtTop()
{
var result = Calculation.GetRawCoordinatesFromPosition(Position.Top, StandardScreen, ToolbarWindow, 1.0);
// X: screen.X + screen.Width/2 - window.Width*dpi/2 = 0 + 960 - 150 = 810
Assert.AreEqual(810.0, result.X);
// Y: screen.Y + offset = 0 + 24 = 24
Assert.AreEqual(24.0, result.Y);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromPosition with Position.Bottom
/// What: Verifies toolbar is horizontally centered at the bottom of the screen
/// Why: Bottom placement subtracts window height + offset — easy to get wrong
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromPosition_BottomCenter_ShouldBeCenteredAtBottom()
{
var result = Calculation.GetRawCoordinatesFromPosition(Position.Bottom, StandardScreen, ToolbarWindow, 1.0);
// X: centered = 810
Assert.AreEqual(810.0, result.X);
// Y: screen.Y + screen.Height - (window.Height*dpi + offset) = 0 + 1080 - (50 + 24) = 1006
Assert.AreEqual(1006.0, result.Y);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromPosition with Position.Center
/// What: Verifies toolbar is centered both horizontally and vertically
/// Why: Center is the simplest case — validates the baseline formula for both axes
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromPosition_Center_ShouldBeTrulyCentered()
{
var result = Calculation.GetRawCoordinatesFromPosition(Position.Center, StandardScreen, ToolbarWindow, 1.0);
// X: screen.X + screen.Width/2 - window.Width*dpi/2 = 0 + 960 - 150 = 810
Assert.AreEqual(810.0, result.X);
// Y: screen.Y + screen.Height/2 - window.Height*dpi/2 = 0 + 540 - 25 = 515
Assert.AreEqual(515.0, result.Y);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromPosition with Position.TopLeft
/// What: Verifies toolbar is placed at top-left corner with offset margin
/// Why: Corner placements use raw offset — validates the simplest X/Y path
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromPosition_TopLeft_ShouldBeAtTopLeftCorner()
{
var result = Calculation.GetRawCoordinatesFromPosition(Position.TopLeft, StandardScreen, ToolbarWindow, 1.0);
// X: screen.X + offset = 0 + 24 = 24
Assert.AreEqual(24.0, result.X);
// Y: screen.Y + offset = 0 + 24 = 24
Assert.AreEqual(24.0, result.Y);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromPosition with Position.TopRight
/// What: Verifies exact coordinates for top-right corner placement
/// Why: TopRight combines right-aligned X with top Y — validates both formula branches
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromPosition_TopRight_ShouldBeAtTopRightCorner()
{
var result = Calculation.GetRawCoordinatesFromPosition(Position.TopRight, StandardScreen, ToolbarWindow, 1.0);
// X: screen.X + screen.Width - (window.Width*dpi + offset) = 0 + 1920 - (300 + 24) = 1596
Assert.AreEqual(1596.0, result.X);
// Y: screen.Y + offset = 0 + 24 = 24
Assert.AreEqual(24.0, result.Y);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromPosition with Position.BottomLeft
/// What: Verifies exact coordinates for bottom-left corner placement
/// Why: BottomLeft combines left-aligned X with bottom Y — validates both formula branches
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromPosition_BottomLeft_ShouldBeAtBottomLeftCorner()
{
var result = Calculation.GetRawCoordinatesFromPosition(Position.BottomLeft, StandardScreen, ToolbarWindow, 1.0);
// X: screen.X + offset = 0 + 24 = 24
Assert.AreEqual(24.0, result.X);
// Y: screen.Y + screen.Height - (window.Height*dpi + offset) = 0 + 1080 - (50 + 24) = 1006
Assert.AreEqual(1006.0, result.Y);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromPosition with Position.BottomRight
/// What: Verifies toolbar is placed at bottom-right corner with offset margin
/// Why: BottomRight uses the most complex formula (subtracts from both edges)
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromPosition_BottomRight_ShouldBeAtBottomRightCorner()
{
var result = Calculation.GetRawCoordinatesFromPosition(Position.BottomRight, StandardScreen, ToolbarWindow, 1.0);
// X: screen.X + screen.Width - (window.Width*dpi + offset) = 0 + 1920 - (300 + 24) = 1596
Assert.AreEqual(1596.0, result.X);
// Y: screen.Y + screen.Height - (window.Height*dpi + offset) = 0 + 1080 - (50 + 24) = 1006
Assert.AreEqual(1006.0, result.Y);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromPosition with Position.Left
/// What: Verifies toolbar is placed at left edge, vertically centered
/// Why: Left position uses offset for X and centering for Y — tests the mix
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromPosition_Left_ShouldBeAtLeftMiddle()
{
var result = Calculation.GetRawCoordinatesFromPosition(Position.Left, StandardScreen, ToolbarWindow, 1.0);
// X: screen.X + offset = 0 + 24 = 24
Assert.AreEqual(24.0, result.X);
// Y: centered vertically = 0 + 540 - 25 = 515
Assert.AreEqual(515.0, result.Y);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromPosition with Position.Right
/// What: Verifies toolbar is placed at right edge, vertically centered
/// Why: Right position subtracts window width from screen edge — tests the right-align formula
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromPosition_Right_ShouldBeAtRightMiddle()
{
var result = Calculation.GetRawCoordinatesFromPosition(Position.Right, StandardScreen, ToolbarWindow, 1.0);
// X: screen.X + screen.Width - (window.Width*dpi + offset) = 1920 - 324 = 1596
Assert.AreEqual(1596.0, result.X);
// Y: centered vertically = 515
Assert.AreEqual(515.0, result.Y);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromPosition with DPI=1.5
/// What: Verifies that DPI scaling is applied to window size in centering formula
/// Why: High-DPI monitors are common — incorrect scaling makes toolbar appear off-center
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromPosition_WithHighDpi_ShouldScaleWindow()
{
double dpi = 1.5;
var result = Calculation.GetRawCoordinatesFromPosition(Position.Center, StandardScreen, ToolbarWindow, dpi);
// X: screen.X + screen.Width/2 - window.Width*dpi/2 = 0 + 960 - (300*1.5)/2 = 960 - 225 = 735
Assert.AreEqual(735.0, result.X);
// Y: screen.Y + screen.Height/2 - window.Height*dpi/2 = 0 + 540 - (50*1.5)/2 = 540 - 37.5 = 502.5
Assert.AreEqual(502.5, result.Y);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromPosition with DPI=2.0, TopLeft
/// What: Verifies that offset is NOT DPI-scaled for corner positions
/// Why: The 24px offset is a fixed margin — DPI scaling only affects window size
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromPosition_WithDpi2_TopLeft_ShouldUseOffset()
{
double dpi = 2.0;
var result = Calculation.GetRawCoordinatesFromPosition(Position.TopLeft, StandardScreen, ToolbarWindow, dpi);
// X: screen.X + offset = 0 + 24 = 24 (offset is not DPI-scaled)
Assert.AreEqual(24.0, result.X);
// Y: screen.Y + offset = 0 + 24 = 24
Assert.AreEqual(24.0, result.Y);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromPosition with DPI=2.0, BottomRight
/// What: Verifies DPI scaling of window size in bottom-right corner formula
/// Why: At 2x DPI, the window occupies 600×100 pixels — offset from edge must account for this
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromPosition_WithDpi2_BottomRight_ShouldScaleWindowSize()
{
double dpi = 2.0;
var result = Calculation.GetRawCoordinatesFromPosition(Position.BottomRight, StandardScreen, ToolbarWindow, dpi);
// X: screen.X + screen.Width - (window.Width*dpi + offset) = 0 + 1920 - (300*2 + 24) = 1920 - 624 = 1296
Assert.AreEqual(1296.0, result.X);
// Y: screen.Y + screen.Height - (window.Height*dpi + offset) = 0 + 1080 - (50*2 + 24) = 1080 - 124 = 956
Assert.AreEqual(956.0, result.Y);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromPosition with offset screen origin
/// What: Verifies screen.X is added as the base for TopLeft on a secondary monitor
/// Why: Without adding screen origin, toolbar appears on the wrong monitor
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromPosition_OffsetScreen_ShouldRespectScreenOrigin()
{
var screen = new Rect(1920, 0, 1920, 1080);
var result = Calculation.GetRawCoordinatesFromPosition(Position.TopLeft, screen, ToolbarWindow, 1.0);
// X: screen.X + offset = 1920 + 24 = 1944
Assert.AreEqual(1944.0, result.X);
// Y: screen.Y + offset = 0 + 24 = 24
Assert.AreEqual(24.0, result.Y);
}
}
}

View File

@@ -0,0 +1,265 @@
// 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;
using System.Linq;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerToys.PowerAccentKeyboardService;
namespace PowerAccent.Core.UnitTests
{
[TestClass]
public class LanguagesTests
{
/// <summary>
/// Product code: Languages.GetDefaultLetterKey
/// What: Verifies that passing an empty language array returns an empty result
/// Why: Guards against NullReferenceException or incorrect fallback when no languages are configured
/// </summary>
[TestMethod]
public void GetDefaultLetterKey_EmptyLanguageArray_ShouldReturnEmpty()
{
var result = Languages.GetDefaultLetterKey(LetterKey.VK_A, Array.Empty<Language>());
Assert.AreEqual(0, result.Length);
}
/// <summary>
/// Product code: Languages.GetDefaultLetterKey with Language.FR
/// What: Verifies that French returns at least one accented character for the letter A
/// Why: Regression guard — ensures the French language mapping is wired up and non-empty
/// </summary>
[TestMethod]
public void GetDefaultLetterKey_SingleLanguage_ShouldReturnNonEmpty()
{
var result = Languages.GetDefaultLetterKey(LetterKey.VK_A, new[] { Language.FR });
Assert.IsTrue(result.Length > 0, "French should have accented characters for A");
}
/// <summary>
/// Product code: Languages.GetDefaultLetterKey with multiple languages
/// What: Verifies that combining French and Spanish merges characters and removes duplicates
/// Why: Guards against duplicate entries when languages share accent characters (e.g., à)
/// </summary>
[TestMethod]
public void GetDefaultLetterKey_MultipleLanguages_ShouldMergeAndDeduplicate()
{
var frResult = Languages.GetDefaultLetterKey(LetterKey.VK_A, new[] { Language.FR });
var spResult = Languages.GetDefaultLetterKey(LetterKey.VK_A, new[] { Language.SP });
var combinedResult = Languages.GetDefaultLetterKey(LetterKey.VK_A, new[] { Language.FR, Language.SP });
Assert.AreEqual(combinedResult.Length, combinedResult.Distinct().Count(), "Combined result should contain no duplicates");
foreach (var ch in frResult)
{
CollectionAssert.Contains(combinedResult, ch, $"Combined result should contain French character '{ch}'");
}
foreach (var ch in spResult)
{
CollectionAssert.Contains(combinedResult, ch, $"Combined result should contain Spanish character '{ch}'");
}
}
/// <summary>
/// Product code: Languages.GetDefaultLetterKey with Language.FR for VK_A
/// What: Verifies that French returns specific expected accent characters (à, â, æ)
/// Why: Pinpoints exact character expectations — catches silent data regressions in language tables
/// </summary>
[TestMethod]
public void GetDefaultLetterKey_French_A_ShouldContainExpectedAccents()
{
var result = Languages.GetDefaultLetterKey(LetterKey.VK_A, new[] { Language.FR });
CollectionAssert.Contains(result, "à", "French should contain à");
CollectionAssert.Contains(result, "â", "French should contain â");
CollectionAssert.Contains(result, "æ", "French should contain æ");
}
/// <summary>
/// Product code: Languages.GetDefaultLetterKey with Language.DE for VK_O
/// What: Verifies German returns the ö umlaut for the O key
/// Why: Core German character — regression would break German users' workflow
/// </summary>
[TestMethod]
public void GetDefaultLetterKey_German_O_ShouldContainUmlaut()
{
var result = Languages.GetDefaultLetterKey(LetterKey.VK_O, new[] { Language.DE });
CollectionAssert.Contains(result, "ö", "German should contain ö for O");
}
/// <summary>
/// Product code: Languages.GetDefaultLetterKey with Language.DE for VK_U
/// What: Verifies German returns the ü umlaut for the U key
/// Why: Core German character — regression would break German users' workflow
/// </summary>
[TestMethod]
public void GetDefaultLetterKey_German_U_ShouldContainUmlaut()
{
var result = Languages.GetDefaultLetterKey(LetterKey.VK_U, new[] { Language.DE });
CollectionAssert.Contains(result, "ü", "German should contain ü for U");
}
/// <summary>
/// Product code: Languages.GetDefaultLetterKey with Language.DE for VK_S
/// What: Verifies German returns the ß (Eszett) for the S key
/// Why: ß is unique to German — its absence would be a critical language regression
/// </summary>
[TestMethod]
public void GetDefaultLetterKey_German_S_ShouldContainEszett()
{
var result = Languages.GetDefaultLetterKey(LetterKey.VK_S, new[] { Language.DE });
CollectionAssert.Contains(result, "ß", "German should contain ß for S");
}
/// <summary>
/// Product code: Languages.GetDefaultLetterKey with Language.SP for VK_N
/// What: Verifies Spanish returns the ñ character for the N key
/// Why: ñ is the most recognizable Spanish-specific character
/// </summary>
[TestMethod]
public void GetDefaultLetterKey_Spanish_N_ShouldContainEne()
{
var result = Languages.GetDefaultLetterKey(LetterKey.VK_N, new[] { Language.SP });
CollectionAssert.Contains(result, "ñ", "Spanish should contain ñ for N");
}
/// <summary>
/// Product code: Languages.GetDefaultLetterKey with Language.CZ for VK_C
/// What: Verifies Czech returns the č (c-caron) for the C key
/// Why: č is essential for Czech — its absence would break Czech language support
/// </summary>
[TestMethod]
public void GetDefaultLetterKey_Czech_C_ShouldContainCaron()
{
var result = Languages.GetDefaultLetterKey(LetterKey.VK_C, new[] { Language.CZ });
CollectionAssert.Contains(result, "č", "Czech should contain č for C");
}
/// <summary>
/// Product code: Languages.GetDefaultLetterKey with Language.PL for VK_L
/// What: Verifies Polish returns the ł (L with stroke) for the L key
/// Why: ł is fundamental to Polish — its absence would break Polish language support
/// </summary>
[TestMethod]
public void GetDefaultLetterKey_Polish_L_ShouldContainStroke()
{
var result = Languages.GetDefaultLetterKey(LetterKey.VK_L, new[] { Language.PL });
CollectionAssert.Contains(result, "ł", "Polish should contain ł for L");
}
/// <summary>
/// Product code: Languages.GetDefaultLetterKey with Language.VI for VK_A
/// What: Verifies Vietnamese returns a rich set of A variants (≥10 accented forms)
/// Why: Vietnamese has extensive diacritics — guards against truncation of the character table
/// </summary>
[TestMethod]
public void GetDefaultLetterKey_Vietnamese_A_ShouldContainMultipleAccents()
{
var result = Languages.GetDefaultLetterKey(LetterKey.VK_A, new[] { Language.VI });
Assert.IsTrue(result.Length >= 10, "Vietnamese should have many accented A characters");
CollectionAssert.Contains(result, "à", "Vietnamese should contain à");
CollectionAssert.Contains(result, "ă", "Vietnamese should contain ă");
CollectionAssert.Contains(result, "â", "Vietnamese should contain â");
}
/// <summary>
/// Product code: Languages.GetDefaultLetterKey with Language.SPECIAL for VK_0
/// What: Verifies that digit 0 returns superscript (⁰) and subscript (₀) variants
/// Why: Special characters for digits are used in mathematical/scientific input
/// </summary>
[TestMethod]
public void GetDefaultLetterKey_Special_Digits_ShouldReturnSubscriptsSuperscripts()
{
var result = Languages.GetDefaultLetterKey(LetterKey.VK_0, new[] { Language.SPECIAL });
Assert.IsTrue(result.Length > 0, "Special characters for 0 should exist");
CollectionAssert.Contains(result, "⁰", "Special 0 should contain superscript 0");
CollectionAssert.Contains(result, "₀", "Special 0 should contain subscript 0");
}
/// <summary>
/// Product code: Languages.GetDefaultLetterKey with Language.SPECIAL for VK_1
/// What: Verifies that digit 1 returns fraction (½) and superscript (¹) variants
/// Why: Fraction characters are commonly needed for recipes, measurements, etc.
/// </summary>
[TestMethod]
public void GetDefaultLetterKey_Special_1_ShouldContainFractions()
{
var result = Languages.GetDefaultLetterKey(LetterKey.VK_1, new[] { Language.SPECIAL });
CollectionAssert.Contains(result, "½", "Special 1 should contain ½");
CollectionAssert.Contains(result, "¹", "Special 1 should contain ¹");
}
/// <summary>
/// Product code: Languages.GetDefaultLetterKey with Language.CUR
/// What: Verifies that Currency language returns well-known currency symbols (€, $, £)
/// Why: Currency symbols are the primary use case — tests exact symbol presence, not just non-null
/// </summary>
[TestMethod]
public void GetDefaultLetterKey_Currency_ShouldReturnCurrencySymbols()
{
var euroResult = Languages.GetDefaultLetterKey(LetterKey.VK_E, new[] { Language.CUR });
CollectionAssert.Contains(euroResult, "€", "Currency VK_E should contain Euro symbol");
var dollarResult = Languages.GetDefaultLetterKey(LetterKey.VK_S, new[] { Language.CUR });
CollectionAssert.Contains(dollarResult, "$", "Currency VK_S should contain Dollar symbol");
var poundResult = Languages.GetDefaultLetterKey(LetterKey.VK_P, new[] { Language.CUR });
CollectionAssert.Contains(poundResult, "£", "Currency VK_P should contain Pound symbol");
}
/// <summary>
/// Product code: Languages.GetDefaultLetterKey with Language.DE for VK_Q
/// What: Verifies that a key with no accented characters for a language returns empty
/// Why: Guards against spurious characters appearing for keys that should have no accents
/// </summary>
[TestMethod]
public void GetDefaultLetterKey_UnusedKeyForLanguage_ShouldReturnEmpty()
{
var result = Languages.GetDefaultLetterKey(LetterKey.VK_Q, new[] { Language.DE });
Assert.AreEqual(0, result.Length, "German should have no accented characters for Q");
}
/// <summary>
/// Product code: Languages.GetDefaultLetterKeyALL (cache path via ConcurrentDictionary)
/// What: Verifies that calling with all languages twice returns the same cached array instance
/// Why: Cache hit should return the identical object reference, not a recomputed copy
/// </summary>
[TestMethod]
public void GetDefaultLetterKey_AllLanguages_ShouldReturnCachedResults()
{
var allLanguages = Enum.GetValues<Language>();
var result1 = Languages.GetDefaultLetterKey(LetterKey.VK_A, allLanguages);
var result2 = Languages.GetDefaultLetterKey(LetterKey.VK_A, allLanguages);
Assert.IsTrue(result1.Length > 0, "All languages combined should have accented A characters");
CollectionAssert.AreEqual(result1, result2, "Cached results should be identical");
// Verify cache hit returns same object instance, not just equal contents
Assert.AreSame(result1, result2, "Second call should return cached reference");
}
/// <summary>
/// Product code: Languages.GetDefaultLetterKey across all LetterKey × Language combinations
/// What: Smoke-tests every letter key with all languages to ensure no exceptions are thrown
/// Why: Catches missing switch arms or null returns that would crash the toolbar at runtime
/// </summary>
[TestMethod]
public void GetDefaultLetterKey_AllLanguages_EachLetterKey_ShouldNotThrow()
{
var allLanguages = Enum.GetValues<Language>();
var allLetterKeys = Enum.GetValues<LetterKey>();
foreach (var letterKey in allLetterKeys)
{
var result = Languages.GetDefaultLetterKey(letterKey, allLanguages);
Assert.IsNotNull(result, $"Result for {letterKey} should not be null");
Assert.AreEqual(result.Length, result.Distinct().Count(), $"Result for {letterKey} should have no duplicates");
}
}
}
}

View File

@@ -0,0 +1,205 @@
// 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;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace PowerAccent.Core.UnitTests
{
[TestClass]
public class PointTests
{
/// <summary>
/// Product code: Point() default constructor
/// What: Verifies X and Y initialize to zero
/// Why: Default state must be deterministic — uninitialized coordinates cause misplacement
/// </summary>
[TestMethod]
public void DefaultConstructor_ShouldInitializeToZero()
{
var point = new Point();
Assert.AreEqual(0, point.X);
Assert.AreEqual(0, point.Y);
}
/// <summary>
/// Product code: Point(double, double) constructor
/// What: Verifies that fractional coordinates are preserved exactly
/// Why: Sub-pixel precision is needed for DPI-aware positioning
/// </summary>
[TestMethod]
public void DoubleConstructor_ShouldSetCoordinates()
{
var point = new Point(3.5, 7.2);
Assert.AreEqual(3.5, point.X);
Assert.AreEqual(7.2, point.Y);
}
/// <summary>
/// Product code: Point(int, int) constructor
/// What: Verifies integer coordinates are stored as doubles without loss
/// Why: Implicit int→double conversion must be exact for pixel-aligned positioning
/// </summary>
[TestMethod]
public void IntConstructor_ShouldSetCoordinates()
{
var point = new Point(10, 20);
Assert.AreEqual(10.0, point.X);
Assert.AreEqual(20.0, point.Y);
}
/// <summary>
/// Product code: Point(System.Drawing.Point) constructor
/// What: Verifies conversion from System.Drawing.Point preserves coordinates
/// Why: Win32 API interop uses System.Drawing.Point — conversion must be lossless
/// </summary>
[TestMethod]
public void DrawingPointConstructor_ShouldConvertCoordinates()
{
var drawingPoint = new System.Drawing.Point(15, 25);
var point = new Point(drawingPoint);
Assert.AreEqual(15.0, point.X);
Assert.AreEqual(25.0, point.Y);
}
/// <summary>
/// Product code: Point implicit operator from System.Drawing.Point
/// What: Verifies implicit conversion works at assignment sites
/// Why: Enables seamless interop without explicit casts in calling code
/// </summary>
[TestMethod]
public void ImplicitConversion_FromDrawingPoint_ShouldWork()
{
Point point = new System.Drawing.Point(100, 200);
Assert.AreEqual(100.0, point.X);
Assert.AreEqual(200.0, point.Y);
}
/// <summary>
/// Product code: Point operator /(Point, double)
/// What: Verifies scalar division halves both coordinates
/// Why: Core arithmetic for DPI normalization
/// </summary>
[TestMethod]
public void DivisionByScalar_ShouldDivideCoordinates()
{
var point = new Point(10.0, 20.0);
var result = point / 2.0;
Assert.AreEqual(5.0, result.X);
Assert.AreEqual(10.0, result.Y);
}
/// <summary>
/// Product code: Point operator /(Point, double) with negative divisor
/// What: Verifies negative divisor negates both coordinates
/// Why: Ensures correct sign propagation in coordinate math
/// </summary>
[TestMethod]
public void DivisionByScalar_WithNegativeDivider_ShouldNegateCoordinates()
{
var point = new Point(10.0, 20.0);
var result = point / -2.0;
Assert.AreEqual(-5.0, result.X);
Assert.AreEqual(-10.0, result.Y);
}
/// <summary>
/// Product code: Point operator /(Point, double) zero guard
/// What: Verifies that dividing by zero throws DivideByZeroException
/// Why: Prevents Infinity coordinates from corrupting toolbar placement
/// </summary>
[TestMethod]
[ExpectedException(typeof(DivideByZeroException))]
public void DivisionByScalar_WithZero_ShouldThrow()
{
var point = new Point(10.0, 20.0);
_ = point / 0.0;
}
/// <summary>
/// Product code: Point operator /(Point, Point)
/// What: Verifies component-wise division (X/X, Y/Y)
/// Why: Used for coordinate space transformations
/// </summary>
[TestMethod]
public void DivisionByPoint_ShouldDivideComponentwise()
{
var point = new Point(10.0, 20.0);
var divider = new Point(2.0, 5.0);
var result = point / divider;
Assert.AreEqual(5.0, result.X);
Assert.AreEqual(4.0, result.Y);
}
/// <summary>
/// Product code: Point operator /(Point, Point) zero guard for X
/// What: Verifies that a divider with X=0 throws DivideByZeroException
/// Why: X=0 in a divider would produce Infinity for the X coordinate
/// </summary>
[TestMethod]
[ExpectedException(typeof(DivideByZeroException))]
public void DivisionByPoint_WithZeroX_ShouldThrow()
{
var point = new Point(10.0, 20.0);
var divider = new Point(0.0, 5.0);
_ = point / divider;
}
/// <summary>
/// Product code: Point operator /(Point, Point) zero guard for Y
/// What: Verifies that a divider with Y=0 throws DivideByZeroException
/// Why: Y=0 in a divider would produce Infinity for the Y coordinate
/// </summary>
[TestMethod]
[ExpectedException(typeof(DivideByZeroException))]
public void DivisionByPoint_WithZeroY_ShouldThrow()
{
var point = new Point(10.0, 20.0);
var divider = new Point(5.0, 0.0);
_ = point / divider;
}
/// <summary>
/// Product code: Point(double, double) constructor with negatives
/// What: Verifies that negative coordinates are stored correctly
/// Why: Negative coordinates are valid in multi-monitor setups (left/above primary)
/// </summary>
[TestMethod]
public void NegativeCoordinates_ShouldBeAllowed()
{
var point = new Point(-5.5, -10.3);
Assert.AreEqual(-5.5, point.X);
Assert.AreEqual(-10.3, point.Y);
}
/// <summary>
/// Product code: Point operator /(Point, double) with fractional divisor
/// What: Verifies dividing by 0.5 effectively doubles coordinates
/// Why: Fractional DPI values (1.25x, 1.5x) are common in production
/// </summary>
[TestMethod]
public void DivisionByScalar_WithFractionalDivider_ShouldWork()
{
var point = new Point(10.0, 20.0);
var result = point / 0.5;
Assert.AreEqual(20.0, result.X);
Assert.AreEqual(40.0, result.Y);
}
/// <summary>
/// Product code: Point operator /(Point, Point) zero guard for both X and Y
/// What: Verifies that a divider with both X=0 and Y=0 throws
/// Why: Degenerate origin-point divider must be rejected — tests the first guard hit
/// </summary>
[TestMethod]
[ExpectedException(typeof(DivideByZeroException))]
public void DivisionByPoint_WithBothZero_ShouldThrow()
{
var point = new Point(10.0, 20.0);
var divider = new Point(0.0, 0.0);
_ = point / divider;
}
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
<Import Project="$(RepoRoot)src\Common.SelfContained.props" />
<PropertyGroup>
<IsPackable>false</IsPackable>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\tests\PowerAccent.Core.Tests\</OutputPath>
<RootNamespace>PowerAccent.Core.UnitTests</RootNamespace>
<AssemblyName>PowerToys.PowerAccent.Core.UnitTests</AssemblyName>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PowerAccent.Core\PowerAccent.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,223 @@
// 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;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace PowerAccent.Core.UnitTests
{
[TestClass]
public class RectTests
{
/// <summary>
/// Product code: Rect() default constructor
/// What: Verifies all properties initialize to zero
/// Why: Default state must be well-defined to avoid uninitialized geometry bugs
/// </summary>
[TestMethod]
public void DefaultConstructor_ShouldInitializeToZero()
{
var rect = new Rect();
Assert.AreEqual(0, rect.X);
Assert.AreEqual(0, rect.Y);
Assert.AreEqual(0, rect.Width);
Assert.AreEqual(0, rect.Height);
}
/// <summary>
/// Product code: Rect(int, int, int, int) constructor
/// What: Verifies that integer arguments are correctly stored as doubles
/// Why: Implicit int→double conversion must preserve exact values
/// </summary>
[TestMethod]
public void IntConstructor_ShouldSetAllProperties()
{
var rect = new Rect(10, 20, 800, 600);
Assert.AreEqual(10.0, rect.X);
Assert.AreEqual(20.0, rect.Y);
Assert.AreEqual(800.0, rect.Width);
Assert.AreEqual(600.0, rect.Height);
}
/// <summary>
/// Product code: Rect(double, double, double, double) constructor
/// What: Verifies that fractional double values are stored exactly
/// Why: Ensures no rounding or truncation occurs during construction
/// </summary>
[TestMethod]
public void DoubleConstructor_ShouldSetAllProperties()
{
var rect = new Rect(1.5, 2.5, 100.3, 200.7);
Assert.AreEqual(1.5, rect.X);
Assert.AreEqual(2.5, rect.Y);
Assert.AreEqual(100.3, rect.Width);
Assert.AreEqual(200.7, rect.Height);
}
/// <summary>
/// Product code: Rect(Point, Size) constructor
/// What: Verifies that Point and Size components map to correct Rect properties
/// Why: X/Y come from Point, Width/Height from Size — a swap would silently corrupt layout
/// </summary>
[TestMethod]
public void PointSizeConstructor_ShouldSetFromComponents()
{
var point = new Point(50.0, 100.0);
var size = new Size(400.0, 300.0);
var rect = new Rect(point, size);
Assert.AreEqual(50.0, rect.X);
Assert.AreEqual(100.0, rect.Y);
Assert.AreEqual(400.0, rect.Width);
Assert.AreEqual(300.0, rect.Height);
}
/// <summary>
/// Product code: Rect operator /(Rect, double)
/// What: Verifies component-wise division by a scalar
/// Why: Used for DPI scaling — incorrect division would misposition the toolbar
/// </summary>
[TestMethod]
public void DivisionByScalar_ShouldDivideAllComponents()
{
var rect = new Rect(10.0, 20.0, 100.0, 200.0);
var result = rect / 2.0;
Assert.AreEqual(5.0, result.X);
Assert.AreEqual(10.0, result.Y);
Assert.AreEqual(50.0, result.Width);
Assert.AreEqual(100.0, result.Height);
}
/// <summary>
/// Product code: Rect operator /(Rect, double) zero guard
/// What: Verifies that dividing by zero throws DivideByZeroException
/// Why: Prevents Infinity values from propagating through layout calculations
/// </summary>
[TestMethod]
[ExpectedException(typeof(DivideByZeroException))]
public void DivisionByScalar_WithZero_ShouldThrow()
{
var rect = new Rect(10.0, 20.0, 100.0, 200.0);
_ = rect / 0.0;
}
/// <summary>
/// Product code: Rect operator /(Rect, Rect)
/// What: Verifies component-wise division (X/X, Y/Y, Width/Width, Height/Height)
/// Why: Used for coordinate system transformations between screen and window space
/// </summary>
[TestMethod]
public void DivisionByRect_ShouldDivideComponentwise()
{
var rect = new Rect(10.0, 20.0, 100.0, 200.0);
var divider = new Rect(2.0, 5.0, 10.0, 20.0);
var result = rect / divider;
Assert.AreEqual(5.0, result.X);
Assert.AreEqual(4.0, result.Y);
Assert.AreEqual(10.0, result.Width);
Assert.AreEqual(10.0, result.Height);
}
/// <summary>
/// Product code: Rect operator /(Rect, Rect) zero guard for X component
/// What: Verifies that a divider with X=0 throws DivideByZeroException
/// Why: X=0 in a divider Rect would produce Infinity for the X coordinate
/// </summary>
[TestMethod]
[ExpectedException(typeof(DivideByZeroException))]
public void DivisionByRect_WithZeroX_ShouldThrow()
{
var rect = new Rect(10.0, 20.0, 100.0, 200.0);
var divider = new Rect(0.0, 5.0, 10.0, 20.0);
_ = rect / divider;
}
/// <summary>
/// Product code: Rect operator /(Rect, Rect) zero guard for Y component
/// What: Verifies that a divider with Y=0 throws DivideByZeroException
/// Why: Y=0 in a divider Rect would produce Infinity for the Y coordinate
/// </summary>
[TestMethod]
[ExpectedException(typeof(DivideByZeroException))]
public void DivisionByRect_WithZeroY_ShouldThrow()
{
var rect = new Rect(10.0, 20.0, 100.0, 200.0);
var divider = new Rect(5.0, 0.0, 10.0, 20.0);
_ = rect / divider;
}
/// <summary>
/// Product code: Rect operator /(Rect, Rect) zero guard for Width component
/// What: Verifies that a divider with Width=0 throws DivideByZeroException
/// Why: Guards against producing Infinity values that propagate through calculations
/// </summary>
[TestMethod]
[ExpectedException(typeof(DivideByZeroException))]
public void DivisionByRect_WithZeroWidth_ShouldThrow()
{
var rect = new Rect(10, 20, 100, 200);
var divider = new Rect(1, 1, 0, 1);
_ = rect / divider;
}
/// <summary>
/// Product code: Rect operator /(Rect, Rect) zero guard for Height component
/// What: Verifies that a divider with Height=0 throws DivideByZeroException
/// Why: Guards against producing Infinity values that propagate through calculations
/// </summary>
[TestMethod]
[ExpectedException(typeof(DivideByZeroException))]
public void DivisionByRect_WithZeroHeight_ShouldThrow()
{
var rect = new Rect(10, 20, 100, 200);
var divider = new Rect(1, 1, 1, 0);
_ = rect / divider;
}
/// <summary>
/// Product code: Rect operator /(Rect, Rect) zero guard for all components
/// What: Verifies that a divider with all zero components throws DivideByZeroException
/// Why: Degenerate all-zero divider must be caught — tests the first guard hit (X=0)
/// </summary>
[TestMethod]
[ExpectedException(typeof(DivideByZeroException))]
public void DivisionByRect_WithAllZeroComponents_ShouldThrow()
{
var rect = new Rect(10, 20, 100, 200);
var divider = new Rect(0, 0, 0, 0);
_ = rect / divider;
}
/// <summary>
/// Product code: Rect(double, double, double, double) constructor
/// What: Verifies that negative X/Y coordinates are allowed
/// Why: Multi-monitor setups can have negative screen coordinates (left/above primary)
/// </summary>
[TestMethod]
public void NegativeCoordinates_ShouldBeAllowed()
{
var rect = new Rect(-100.0, -200.0, 400.0, 300.0);
Assert.AreEqual(-100.0, rect.X);
Assert.AreEqual(-200.0, rect.Y);
Assert.AreEqual(400.0, rect.Width);
Assert.AreEqual(300.0, rect.Height);
}
/// <summary>
/// Product code: Rect operator /(Rect, double) with negative divisor
/// What: Verifies that negative divisor negates all components
/// Why: Ensures sign propagation is correct — used in coordinate flipping scenarios
/// </summary>
[TestMethod]
public void DivisionByScalar_WithNegativeDivider_ShouldNegateComponents()
{
var rect = new Rect(10.0, 20.0, 100.0, 200.0);
var result = rect / -1.0;
Assert.AreEqual(-10.0, result.X);
Assert.AreEqual(-20.0, result.Y);
Assert.AreEqual(-100.0, result.Width);
Assert.AreEqual(-200.0, result.Height);
}
}
}

View File

@@ -0,0 +1,192 @@
// 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;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace PowerAccent.Core.UnitTests
{
[TestClass]
public class SizeTests
{
/// <summary>
/// Product code: Size() default constructor
/// What: Verifies Width and Height initialize to zero
/// Why: Default state must be deterministic to avoid layout corruption
/// </summary>
[TestMethod]
public void DefaultConstructor_ShouldInitializeToZero()
{
var size = new Size();
Assert.AreEqual(0, size.Width);
Assert.AreEqual(0, size.Height);
}
/// <summary>
/// Product code: Size(double, double) constructor
/// What: Verifies fractional values are preserved exactly
/// Why: Ensures no rounding during construction for sub-pixel precision
/// </summary>
[TestMethod]
public void DoubleConstructor_ShouldSetDimensions()
{
var size = new Size(100.5, 200.3);
Assert.AreEqual(100.5, size.Width);
Assert.AreEqual(200.3, size.Height);
}
/// <summary>
/// Product code: Size(int, int) constructor
/// What: Verifies integer dimensions are stored as doubles without loss
/// Why: Implicit int→double conversion must be exact
/// </summary>
[TestMethod]
public void IntConstructor_ShouldSetDimensions()
{
var size = new Size(800, 600);
Assert.AreEqual(800.0, size.Width);
Assert.AreEqual(600.0, size.Height);
}
/// <summary>
/// Product code: Size implicit operator from System.Drawing.Size
/// What: Verifies implicit conversion from System.Drawing.Size works
/// Why: Interop boundary — System.Drawing types are used by Win32 APIs
/// </summary>
[TestMethod]
public void ImplicitConversion_FromDrawingSize_ShouldWork()
{
Size size = new System.Drawing.Size(1920, 1080);
Assert.AreEqual(1920.0, size.Width);
Assert.AreEqual(1080.0, size.Height);
}
/// <summary>
/// Product code: Size operator /(Size, double)
/// What: Verifies scalar division halves both dimensions
/// Why: Core arithmetic used for DPI scaling — must produce exact results
/// </summary>
[TestMethod]
public void DivisionByScalar_ShouldDivideDimensions()
{
var size = new Size(100.0, 200.0);
var result = size / 2.0;
Assert.AreEqual(50.0, result.Width);
Assert.AreEqual(100.0, result.Height);
}
/// <summary>
/// Product code: Size operator /(Size, double) with fractional divisor
/// What: Verifies that dividing by 0.5 effectively doubles dimensions
/// Why: Fractional DPI values are real (e.g., 1.25x, 1.5x scaling)
/// </summary>
[TestMethod]
public void DivisionByScalar_WithFractionalDivider_ShouldWork()
{
var size = new Size(100.0, 200.0);
var result = size / 0.5;
Assert.AreEqual(200.0, result.Width);
Assert.AreEqual(400.0, result.Height);
}
/// <summary>
/// Product code: Size operator /(Size, double) zero guard
/// What: Verifies that dividing by zero throws DivideByZeroException
/// Why: Prevents Infinity dimensions that would corrupt window layout
/// </summary>
[TestMethod]
[ExpectedException(typeof(DivideByZeroException))]
public void DivisionByScalar_WithZero_ShouldThrow()
{
var size = new Size(100.0, 200.0);
_ = size / 0.0;
}
/// <summary>
/// Product code: Size operator /(Size, Size)
/// What: Verifies component-wise division (Width/Width, Height/Height)
/// Why: Used for computing scaling ratios between two rectangles
/// </summary>
[TestMethod]
public void DivisionBySize_ShouldDivideComponentwise()
{
var size = new Size(100.0, 200.0);
var divider = new Size(10.0, 20.0);
var result = size / divider;
Assert.AreEqual(10.0, result.Width);
Assert.AreEqual(10.0, result.Height);
}
/// <summary>
/// Product code: Size operator /(Size, Size) zero Width guard
/// What: Verifies that a divider with Width=0 throws DivideByZeroException
/// Why: Width=0 divider would produce Infinity — must be caught early
/// </summary>
[TestMethod]
[ExpectedException(typeof(DivideByZeroException))]
public void DivisionBySize_WithZeroWidth_ShouldThrow()
{
var size = new Size(100.0, 200.0);
var divider = new Size(0.0, 20.0);
_ = size / divider;
}
/// <summary>
/// Product code: Size operator /(Size, Size) zero Height guard
/// What: Verifies that a divider with Height=0 throws DivideByZeroException
/// Why: Height=0 divider would produce Infinity — must be caught early
/// </summary>
[TestMethod]
[ExpectedException(typeof(DivideByZeroException))]
public void DivisionBySize_WithZeroHeight_ShouldThrow()
{
var size = new Size(100.0, 200.0);
var divider = new Size(10.0, 0.0);
_ = size / divider;
}
/// <summary>
/// Product code: Size operator /(Size, double) with negative divisor
/// What: Verifies that negative divisor correctly negates both dimensions
/// Why: Ensures sign propagation is correct in arithmetic
/// </summary>
[TestMethod]
public void DivisionByScalar_WithNegativeDivider_ShouldWork()
{
var size = new Size(100.0, 200.0);
var result = size / -2.0;
Assert.AreEqual(-50.0, result.Width);
Assert.AreEqual(-100.0, result.Height);
}
/// <summary>
/// Product code: Size operator /(Size, double) with very small divisor
/// What: Verifies division by a small number produces expected large result
/// Why: Near-zero divisors (e.g., very low DPI) must not crash or produce NaN
/// </summary>
[TestMethod]
public void DivisionByScalar_WithVerySmallDivider_ShouldYieldLargeResult()
{
var size = new Size(1.0, 1.0);
var result = size / 0.001;
Assert.AreEqual(1000.0, result.Width, 0.01);
Assert.AreEqual(1000.0, result.Height, 0.01);
}
/// <summary>
/// Product code: Size operator /(Size, Size) zero guard for both dimensions
/// What: Verifies that a divider with both Width=0 and Height=0 throws
/// Why: Degenerate zero-size divider must be rejected — tests the first guard hit
/// </summary>
[TestMethod]
[ExpectedException(typeof(DivideByZeroException))]
public void DivisionBySize_WithBothZeroDimensions_ShouldThrow()
{
var size = new Size(100.0, 200.0);
var divider = new Size(0.0, 0.0);
_ = size / divider;
}
}
}

View File

@@ -58,7 +58,7 @@ public struct Rect
public static Rect operator /(Rect rect, Rect divider)
{
if (divider.X == 0 || divider.Y == 0)
if (divider.X == 0 || divider.Y == 0 || divider.Width == 0 || divider.Height == 0)
{
throw new DivideByZeroException();
}

View File

@@ -42,7 +42,7 @@ public struct Size
public static Size operator /(Size size, Size divider)
{
if (divider.Width == 0 || divider.Height == 0 || divider.Width == 0 || divider.Height == 0)
if (divider.Width == 0 || divider.Height == 0)
{
throw new DivideByZeroException();
}

View File

@@ -33,4 +33,10 @@
<ProjectReference Include="..\..\..\settings-ui\Settings.UI.Library\Settings.UI.Library.csproj" />
<ProjectReference Include="..\PowerAccentKeyboardService\PowerAccentKeyboardService.vcxproj" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>PowerToys.PowerAccent.Core.UnitTests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>

View File

@@ -28,11 +28,7 @@
so it doesn't conflict with the dll coming from .NET SDK. -->
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="System.Diagnostics.EventLog">
<!-- This package is a transitive dependency, but we need to set it here so we can exclude the assets,
so it doesn't conflict with the dll coming from .NET SDK. -->
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<!-- System.Diagnostics.EventLog is now provided to all C# projects via Directory.Build.props -->
</ItemGroup>
<ItemGroup>

View File

@@ -11,7 +11,10 @@ namespace cmdArg
// restarting it from there, so it doesn't interfere with the installation process.
const inline wchar_t* UPDATE_NOW_LAUNCH_STAGE1 = L"-update_now";
// Stage 2 consists of starting the installer and optionally launching newly installed PowerToys binary.
// That's indicated by the following 2 flags.
const inline wchar_t* UPDATE_NOW_LAUNCH_STAGE2 = L"-update_now_stage_2";
const inline wchar_t* UPDATE_STAGE2_RESTART_PT = L"restart";
const inline wchar_t* UPDATE_STAGE2_DONT_START_PT = L"dont_start";
const inline wchar_t* UPDATE_REPORT_SUCCESS = L"-report_update_success";
}

View File

@@ -111,7 +111,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library
QuickAccessShortcut = new HotkeySettings();
IsElevated = false;
ShowNewUpdatesToastNotification = true;
AutoDownloadUpdates = true;
AutoDownloadUpdates = false;
EnableExperimentation = true;
DashboardSortOrder = DashboardSortOrder.Alphabetical;
Theme = "system";

View File

@@ -56,7 +56,7 @@ public class SetSettingCommandTests
}
[DataRow(typeof(GeneralSettings), "Enabled.MouseWithoutBorders", "true")]
[DataRow(typeof(GeneralSettings), nameof(GeneralSettings.AutoDownloadUpdates), "false")]
[DataRow(typeof(GeneralSettings), nameof(GeneralSettings.AutoDownloadUpdates), "true")]
[TestMethod]
public void SetGeneralSetting(Type moduleSettingsType, string settingName, string newValueStr)
{