diff --git a/.pipelines/ci/templates/build-powertoys-steps.yml b/.pipelines/ci/templates/build-powertoys-steps.yml index e8f3bd3964..4102ac53c1 100644 --- a/.pipelines/ci/templates/build-powertoys-steps.yml +++ b/.pipelines/ci/templates/build-powertoys-steps.yml @@ -82,6 +82,7 @@ steps: testSelector: 'testAssemblies' testAssemblyVer2: | **\Microsoft.Plugin.Program.UnitTests.dll + **\Microsoft.Plugin.Calculator.UnitTest.dll **\Microsoft.Plugin.Uri.UnitTests.dll **\Wox.Test.dll **\*Microsoft.PowerToys.Settings.UI.UnitTests.dll diff --git a/PowerToys.sln b/PowerToys.sln index 7812aa19dd..e16a3fac32 100644 --- a/PowerToys.sln +++ b/PowerToys.sln @@ -265,6 +265,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Plugin.Uri.UnitTe EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.PowerToys.Settings.UI.UnitTests", "src\core\Microsoft.PowerToys.Settings.UI.UnitTests\Microsoft.PowerToys.Settings.UI.UnitTests.csproj", "{0F85E674-34AE-443D-954C-8321EB8B93B1}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Plugin.Calculator.UnitTest", "src\modules\launcher\Plugins\Microsoft.Plugin.Calculator.UnitTest\Microsoft.Plugin.Calculator.UnitTest.csproj", "{632BBE62-5421-49EA-835A-7FFA4F499BD6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|x64 = Debug|x64 @@ -531,6 +533,10 @@ Global {0F85E674-34AE-443D-954C-8321EB8B93B1}.Debug|x64.Build.0 = Debug|x64 {0F85E674-34AE-443D-954C-8321EB8B93B1}.Release|x64.ActiveCfg = Release|x64 {0F85E674-34AE-443D-954C-8321EB8B93B1}.Release|x64.Build.0 = Release|x64 + {632BBE62-5421-49EA-835A-7FFA4F499BD6}.Debug|x64.ActiveCfg = Debug|x64 + {632BBE62-5421-49EA-835A-7FFA4F499BD6}.Debug|x64.Build.0 = Debug|x64 + {632BBE62-5421-49EA-835A-7FFA4F499BD6}.Release|x64.ActiveCfg = Release|x64 + {632BBE62-5421-49EA-835A-7FFA4F499BD6}.Release|x64.Build.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -607,6 +613,7 @@ Global {03276A39-D4E9-417C-8FFD-200B0EE5E871} = {4AFC9975-2456-4C70-94A4-84073C1CED93} {B81FB7B6-D30E-428F-908A-41422EFC1172} = {4AFC9975-2456-4C70-94A4-84073C1CED93} {0F85E674-34AE-443D-954C-8321EB8B93B1} = {C3081D9A-1586-441A-B5F4-ED815B3719C1} + {632BBE62-5421-49EA-835A-7FFA4F499BD6} = {4AFC9975-2456-4C70-94A4-84073C1CED93} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {C3A2F9D1-7930-4EF4-A6FC-7EE0A99821D0} diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Calculator.UnitTest/BracketHelperTests.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Calculator.UnitTest/BracketHelperTests.cs new file mode 100644 index 0000000000..6b20897ff6 --- /dev/null +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Calculator.UnitTest/BracketHelperTests.cs @@ -0,0 +1,49 @@ +// 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 NUnit.Framework; + +namespace Microsoft.Plugin.Calculator.UnitTests +{ + [TestFixture] + public class BracketHelperTests + { + [TestCase(null)] + [TestCase("")] + [TestCase("\t \r\n")] + [TestCase("none")] + [TestCase("()")] + [TestCase("(())")] + [TestCase("()()")] + [TestCase("(()())")] + [TestCase("([][])")] + [TestCase("([(()[])[](([]()))])")] + public void IsBracketComplete_TestValid_WhenCalled(string input) + { + // Arrange + + // Act + var result = BracketHelper.IsBracketComplete(input); + + // Assert + Assert.IsTrue(result); + } + + [TestCase("((((", "only opening brackets")] + [TestCase("]]]", "only closing brackets")] + [TestCase("([)(])", "inner bracket mismatch")] + [TestCase(")(", "opening and closing reversed")] + [TestCase("(]", "mismatch in bracket type")] + public void IsBracketComplete_TestInvalid_WhenCalled(string input, string invalidReason) + { + // Arrange + + // Act + var result = BracketHelper.IsBracketComplete(input); + + // Assert + Assert.IsFalse(result, invalidReason); + } + } +} diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Calculator.UnitTest/ExtendedCalculatorParserTests.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Calculator.UnitTest/ExtendedCalculatorParserTests.cs new file mode 100644 index 0000000000..79c971d8fd --- /dev/null +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Calculator.UnitTest/ExtendedCalculatorParserTests.cs @@ -0,0 +1,123 @@ +// 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.Globalization; +using NUnit.Framework; + +namespace Microsoft.Plugin.Calculator.UnitTests +{ + [TestFixture] + public class ExtendedCalculatorParserTests + { + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + public void InputValid_ThrowError_WhenCalledNullOrEmpty(string input) + { + // Act + Assert.Catch(() => CalculateHelper.InputValid(input)); + } + + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + public void Interpret_ThrowError_WhenCalledNullOrEmpty(string input) + { + // Arrange + var engine = new CalculateEngine(); + + // Act + Assert.Catch(() => engine.Interpret(input)); + } + + [TestCase("42")] + [TestCase("test")] + public void Interpret_NoResult_WhenCalled(string input) + { + // Arrange + var engine = new CalculateEngine(); + + // Act + var result = engine.Interpret(input); + + // Assert + Assert.AreEqual(default(CalculateResult), result); + } + + [TestCase("2 * 2", 4D)] + [TestCase("-2 ^ 2", 4D)] + [TestCase("-(2 ^ 2)", -4D)] + [TestCase("2 * pi", 6.28318530717959D)] + [TestCase("round(2 * pi)", 6D)] + [TestCase("1 == 2", default(double))] + [TestCase("pi * ( sin ( cos ( 2)))", -1.26995475603563D)] + [TestCase("5.6/2", 2.8D)] + [TestCase("123 * 4.56", 560.88D)] + [TestCase("1 - 9.0 / 10", 0.1D)] + [TestCase("0.5 * ((2*-395.2)+198.2)", -296.1D)] + [TestCase("2+2.11", 4.11D)] + public void Interpret_NoErrors_WhenCalled(string input, decimal expectedResult) + { + // Arrange + var engine = new CalculateEngine(); + + // Act + var result = engine.Interpret(input, CultureInfo.InvariantCulture); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(expectedResult, result.Result); + } + + [TestCase("0.100000000000000000000", 0.00776627963145224D)] // BUG: Because data structure + [TestCase("0.200000000000000000000000", 0.000000400752841041379D)] // BUG: Because data structure + [TestCase("123 456", 56088D)] // BUG: Framework accepts ' ' as multiplication + public void Interpret_QuirkOutput_WhenCalled(string input, decimal expectedResult) + { + // Arrange + var engine = new CalculateEngine(); + + // Act + var result = engine.Interpret(input, CultureInfo.InvariantCulture); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(expectedResult, result.Result); + } + + [TestCase("4.5/3", 1.5D, "nl-NL")] + [TestCase("4.5/3", 1.5D, "en-EN")] + [TestCase("4.5/3", 1.5D, "de-DE")] + public void Interpret_DifferentCulture_WhenCalled(string input, decimal expectedResult, string cultureName) + { + // Arrange + var cultureInfo = CultureInfo.GetCultureInfo(cultureName); + var engine = new CalculateEngine(); + + // Act + var result = engine.Interpret(input, cultureInfo); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(expectedResult, result.Result); + } + + [TestCase("ceil(2 * (pi ^ 2))", true)] + [TestCase("((1 * 2)", false)] + [TestCase("(1 * 2)))", false)] + [TestCase("abcde", false)] + [TestCase("plot( 2 * 3)", true)] + public void InputValid_TestValid_WhenCalled(string input, bool valid) + { + // Arrange + + // Act + var result = CalculateHelper.InputValid(input); + + // Assert + Assert.AreEqual(valid, result); + } + } +} diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Calculator.UnitTest/Microsoft.Plugin.Calculator.UnitTest.csproj b/src/modules/launcher/Plugins/Microsoft.Plugin.Calculator.UnitTest/Microsoft.Plugin.Calculator.UnitTest.csproj new file mode 100644 index 0000000000..d5f1c06b75 --- /dev/null +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Calculator.UnitTest/Microsoft.Plugin.Calculator.UnitTest.csproj @@ -0,0 +1,36 @@ + + + + netcoreapp3.1 + + false + x64 + Microsoft.Plugin.Calculator.UnitTests + true + + + + + + + + + + + + + + GlobalSuppressions.cs + + + StyleCop.json + + + + + 1.1.118 + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + \ No newline at end of file diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Calculator/BracketHelper.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Calculator/BracketHelper.cs new file mode 100644 index 0000000000..caa0e119f3 --- /dev/null +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Calculator/BracketHelper.cs @@ -0,0 +1,87 @@ +// 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.Collections.Generic; +using System.Linq; + +namespace Microsoft.Plugin.Calculator +{ + public static class BracketHelper + { + public static bool IsBracketComplete(string query) + { + if (string.IsNullOrWhiteSpace(query)) + { + return true; + } + + var valueTuples = query + .Select(BracketTrail) + .Where(r => r != default); + + var trailTest = new Stack(); + + foreach (var (direction, type) in valueTuples) + { + switch (direction) + { + case TrailDirection.Open: + trailTest.Push(type); + break; + case TrailDirection.Close: + // Try to get item out of stack + if (!trailTest.TryPop(out var popped)) + { + return false; + } + + if (type != popped) + { + return false; + } + + continue; + default: + { + throw new ArgumentOutOfRangeException(nameof(direction), direction, "Can't process value"); + } + } + } + + return !trailTest.Any(); + } + + private static (TrailDirection direction, TrailType type) BracketTrail(char @char) + { + switch (@char) + { + case '(': + return (TrailDirection.Open, TrailType.Round); + case ')': + return (TrailDirection.Close, TrailType.Round); + case '[': + return (TrailDirection.Open, TrailType.Bracket); + case ']': + return (TrailDirection.Close, TrailType.Bracket); + default: + return default; + } + } + + private enum TrailDirection + { + None, + Open, + Close, + } + + private enum TrailType + { + None, + Bracket, + Round, + } + } +} diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Calculator/CalculateEngine.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Calculator/CalculateEngine.cs new file mode 100644 index 0000000000..a7524fc45a --- /dev/null +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Calculator/CalculateEngine.cs @@ -0,0 +1,68 @@ +// 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.Globalization; +using Mages.Core; + +namespace Microsoft.Plugin.Calculator +{ + public class CalculateEngine + { + private readonly Engine _magesEngine = new Engine(); + public const int RoundingDigits = 10; + + public CalculateResult Interpret(string input) + { + return Interpret(input, CultureInfo.CurrentCulture); + } + + public CalculateResult Interpret(string input, CultureInfo cultureInfo) + { + if (!CalculateHelper.InputValid(input)) + { + return default; + } + + var result = _magesEngine.Interpret(input); + + // This could happen for some incorrect queries, like pi(2) + if (result == null) + { + return default; + } + + result = TransformResult(result); + + if (string.IsNullOrEmpty(result?.ToString())) + { + return default; + } + + var decimalResult = Convert.ToDecimal(result, cultureInfo); + var roundedResult = Math.Round(decimalResult, RoundingDigits, MidpointRounding.AwayFromZero); + + return new CalculateResult() + { + Result = decimalResult, + RoundedResult = roundedResult, + }; + } + + private static object TransformResult(object result) + { + if (result.ToString() == "NaN") + { + return Properties.Resources.wox_plugin_calculator_not_a_number; + } + + if (result is Function) + { + return Properties.Resources.wox_plugin_calculator_expression_not_complete; + } + + return result; + } + } +} diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Calculator/CalculateHelper.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Calculator/CalculateHelper.cs new file mode 100644 index 0000000000..193c2c42c8 --- /dev/null +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Calculator/CalculateHelper.cs @@ -0,0 +1,47 @@ +// 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.Text.RegularExpressions; + +namespace Microsoft.Plugin.Calculator +{ + public static class CalculateHelper + { + private static readonly Regex RegValidExpressChar = new Regex( + @"^(" + + @"ceil|floor|exp|pi|e|max|min|det|abs|log|ln|sqrt|" + + @"sin|cos|tan|arcsin|arccos|arctan|" + + @"eigval|eigvec|eig|sum|polar|plot|round|sort|real|zeta|" + + @"bin2dec|hex2dec|oct2dec|" + + @"==|~=|&&|\|\||" + + @"[ei]|[0-9]|[\+\-\*\/\^\., ""]|[\(\)\|\!\[\]]" + + @")+$", RegexOptions.Compiled); + + public static bool InputValid(string input) + { + if (string.IsNullOrWhiteSpace(input)) + { + throw new ArgumentNullException(paramName: nameof(input)); + } + + if (input.Length <= 2) + { + return false; + } + + if (!RegValidExpressChar.IsMatch(input)) + { + return false; + } + + if (!BracketHelper.IsBracketComplete(input)) + { + return false; + } + + return true; + } + } +} diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Calculator/CalculateResult.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Calculator/CalculateResult.cs new file mode 100644 index 0000000000..daccecaba7 --- /dev/null +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Calculator/CalculateResult.cs @@ -0,0 +1,41 @@ +// 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.Collections.Generic; + +namespace Microsoft.Plugin.Calculator +{ + public struct CalculateResult : IEquatable + { + public decimal Result { get; set; } + + public decimal RoundedResult { get; set; } + + public bool Equals(CalculateResult other) + { + return Result == other.Result && RoundedResult == other.RoundedResult; + } + + public override bool Equals(object obj) + { + return obj is CalculateResult other && Equals(other); + } + + public override int GetHashCode() + { + return HashCode.Combine(Result, RoundedResult); + } + + public static bool operator ==(CalculateResult left, CalculateResult right) + { + return left.Equals(right); + } + + public static bool operator !=(CalculateResult left, CalculateResult right) + { + return !(left == right); + } + } +} diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Calculator/Main.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Calculator/Main.cs index 6454733055..0195c68f66 100644 --- a/src/modules/launcher/Plugins/Microsoft.Plugin.Calculator/Main.cs +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Calculator/Main.cs @@ -5,11 +5,6 @@ using System; using System.Collections.Generic; using System.Globalization; -using System.Runtime.InteropServices; -using System.Text.RegularExpressions; -using System.Threading; -using System.Windows; -using Mages.Core; using Wox.Infrastructure.Logger; using Wox.Plugin; @@ -17,18 +12,7 @@ namespace Microsoft.Plugin.Calculator { public class Main : IPlugin, IPluginI18n, IDisposable { - private static readonly Regex RegValidExpressChar = new Regex( - @"^(" + - @"ceil|floor|exp|pi|e|max|min|det|abs|log|ln|sqrt|" + - @"sin|cos|tan|arcsin|arccos|arctan|" + - @"eigval|eigvec|eig|sum|polar|plot|round|sort|real|zeta|" + - @"bin2dec|hex2dec|oct2dec|" + - @"==|~=|&&|\|\||" + - @"[ei]|[0-9]|[\+\-\*\/\^\., ""]|[\(\)\|\!\[\]]" + - @")+$", RegexOptions.Compiled); - - private static readonly Regex RegBrackets = new Regex(@"[\(\)\[\]]", RegexOptions.Compiled); - private static readonly Engine MagesEngine = new Engine(); + private static readonly CalculateEngine CalculateEngine = new CalculateEngine(); private PluginInitContext Context { get; set; } @@ -43,68 +27,25 @@ namespace Microsoft.Plugin.Calculator throw new ArgumentNullException(paramName: nameof(query)); } - if (query.Search.Length <= 2 // don't affect when user only input "e" or "i" keyword - || !RegValidExpressChar.IsMatch(query.Search) - || !IsBracketComplete(query.Search)) + if (!CalculateHelper.InputValid(query.Search)) { return new List(); } try { - var result = MagesEngine.Interpret(query.Search); + var result = CalculateEngine.Interpret(query.Search, CultureInfo.CurrentUICulture); // This could happen for some incorrect queries, like pi(2) - if (result == null) + if (result.Equals(default(CalculateResult))) { return new List(); } - if (result.ToString() == "NaN") + return new List { - result = Properties.Resources.wox_plugin_calculator_not_a_number; - } - - if (result is Function) - { - result = Properties.Resources.wox_plugin_calculator_expression_not_complete; - } - - if (!string.IsNullOrEmpty(result?.ToString())) - { - var roundedResult = Math.Round(Convert.ToDecimal(result, CultureInfo.CurrentCulture), 10, MidpointRounding.AwayFromZero); - - return new List - { - new Result - { - Title = roundedResult.ToString(CultureInfo.CurrentCulture), - IcoPath = IconPath, - Score = 300, - SubTitle = Properties.Resources.wox_plugin_calculator_copy_number_to_clipboard, - Action = c => - { - var ret = false; - var thread = new Thread(() => - { - try - { - Clipboard.SetText(result.ToString()); - ret = true; - } - catch (ExternalException) - { - MessageBox.Show(Properties.Resources.wox_plugin_calculator_copy_failed); - } - }); - thread.SetApartmentState(ApartmentState.STA); - thread.Start(); - thread.Join(); - return ret; - }, - }, - }; - } + ResultHelper.CreateResult(result.Result, result.RoundedResult, IconPath), + }; } // We want to keep the process alive if any the mages library throws any exceptions. #pragma warning disable CA1031 // Do not catch general exception types catch (Exception e) @@ -116,25 +57,6 @@ namespace Microsoft.Plugin.Calculator return new List(); } - private static bool IsBracketComplete(string query) - { - var matchs = RegBrackets.Matches(query); - var leftBracketCount = 0; - foreach (Match match in matchs) - { - if (match.Value == "(" || match.Value == "[") - { - leftBracketCount++; - } - else - { - leftBracketCount--; - } - } - - return leftBracketCount == 0; - } - public void Init(PluginInitContext context) { Context = context ?? throw new ArgumentNullException(paramName: nameof(context)); diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Calculator/ResultHelper.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Calculator/ResultHelper.cs new file mode 100644 index 0000000000..be217a82c6 --- /dev/null +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Calculator/ResultHelper.cs @@ -0,0 +1,53 @@ +// 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.Globalization; +using System.Runtime.InteropServices; +using System.Threading; +using System.Windows; +using Wox.Plugin; + +namespace Microsoft.Plugin.Calculator +{ + public static class ResultHelper + { + public static Result CreateResult(CalculateResult result, string iconPath) + { + return CreateResult(result.Result, result.RoundedResult, iconPath); + } + + public static Result CreateResult(decimal result, decimal roundedResult, string iconPath) + { + return new Result + { + Title = roundedResult.ToString(CultureInfo.CurrentCulture), + IcoPath = iconPath, + Score = 300, + SubTitle = Properties.Resources.wox_plugin_calculator_copy_number_to_clipboard, + Action = c => Action(result), + }; + } + + public static bool Action(decimal result) + { + var ret = false; + var thread = new Thread(() => + { + try + { + Clipboard.SetText(result.ToString(CultureInfo.CurrentUICulture.NumberFormat)); + ret = true; + } + catch (ExternalException) + { + MessageBox.Show(Properties.Resources.wox_plugin_calculator_copy_failed); + } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + return ret; + } + } +}