Create unit tests for Calculator plugin (#6356)

* Refactored logic and made it unit testable

* Changes after code review

* Added to build steps, and modified bracket to new class with unittest. Validates complexer cases now.

Co-authored-by: p-storm <paul.de.man@gmail.com>
This commit is contained in:
P-Storm 2020-09-10 05:01:30 +02:00 committed by GitHub
parent cfda69a120
commit 3137aaa660
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 519 additions and 85 deletions

View file

@ -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

View file

@ -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}

View file

@ -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);
}
}
}

View file

@ -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<ArgumentNullException>(() => CalculateHelper.InputValid(input));
}
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
public void Interpret_ThrowError_WhenCalledNullOrEmpty(string input)
{
// Arrange
var engine = new CalculateEngine();
// Act
Assert.Catch<ArgumentNullException>(() => 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);
}
}
}

View file

@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<IsPackable>false</IsPackable>
<Platforms>x64</Platforms>
<RootNamespace>Microsoft.Plugin.Calculator.UnitTests</RootNamespace>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="nunit" Version="3.12.0" />
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Plugin.Calculator\Microsoft.Plugin.Calculator.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\..\..\..\codeAnalysis\GlobalSuppressions.cs">
<Link>GlobalSuppressions.cs</Link>
</Compile>
<AdditionalFiles Include="..\..\..\..\codeAnalysis\StyleCop.json">
<Link>StyleCop.json</Link>
</AdditionalFiles>
</ItemGroup>
<ItemGroup>
<PackageReference Include="StyleCop.Analyzers">
<Version>1.1.118</Version>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
</Project>

View file

@ -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<TrailType>();
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,
}
}
}

View file

@ -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;
}
}
}

View file

@ -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;
}
}
}

View file

@ -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<CalculateResult>
{
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);
}
}
}

View file

@ -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<Result>();
}
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<Result>();
}
if (result.ToString() == "NaN")
return new List<Result>
{
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<Result>
{
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<Result>();
}
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));

View file

@ -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;
}
}
}