diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..81b71c5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,112 @@ +name: CI + +on: + pull_request: + push: + branches: + - master + - main + - "codex/**" + workflow_dispatch: + +permissions: + contents: read + +env: + CONFIGURATION: Release + CORE_PROJECT: Evaluant.Calculator/NCalc.csproj + TEST_PROJECT: Evaluant.Calculator.Tests/NCalc.Tests.csproj + DOTNET_NOLOGO: true + NCALC_EXPECTED_PUBLIC_KEY_TOKEN: f59bbff0fd43f598 + +jobs: + quality-gates: + name: Local quality gates and compatibility + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Install Mono tooling + run: | + sudo apt-get update + sudo apt-get install --yes mono-devel mono-utils + + - name: Prepare strong-name key + shell: bash + env: + NCALC_STRONG_NAME_KEY_BASE64: ${{ secrets.NCALC_STRONG_NAME_KEY_BASE64 }} + run: | + set -euo pipefail + + key_path="$RUNNER_TEMP/NCalc.snk" + + if [ -n "${NCALC_STRONG_NAME_KEY_BASE64:-}" ]; then + printf '%s' "$NCALC_STRONG_NAME_KEY_BASE64" | base64 --decode > "$key_path" + else + sn -k "$key_path" + sn -p "$key_path" "$RUNNER_TEMP/NCalc.public.snk" >/dev/null + token="$(sn -tp "$RUNNER_TEMP/NCalc.public.snk" | awk '/Public Key Token/ { print $4 }')" + echo "NCALC_EXPECTED_PUBLIC_KEY_TOKEN=$token" >> "$GITHUB_ENV" + echo "Using an ephemeral strong-name key for non-release CI validation." + fi + + echo "NCalcStrongNameKeyFile=$key_path" >> "$GITHUB_ENV" + + - name: Run local validation gates + run: scripts/validate.sh + + build-test: + name: Restore, build, and test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + - windows-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Show .NET information + run: dotnet --info + + - name: Restore + run: | + dotnet restore ${{ env.CORE_PROJECT }} + dotnet restore ${{ env.TEST_PROJECT }} + + - name: Build + run: | + dotnet build ${{ env.CORE_PROJECT }} --configuration ${{ env.CONFIGURATION }} --no-restore + dotnet build ${{ env.TEST_PROJECT }} --configuration ${{ env.CONFIGURATION }} --no-restore + + - name: Test + run: > + dotnet test ${{ env.TEST_PROJECT }} + --configuration ${{ env.CONFIGURATION }} + --no-build + --logger "trx;LogFileName=test-results.trx" + --results-directory TestResults + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ matrix.os }} + path: TestResults + if-no-files-found: ignore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5a6ea37 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,192 @@ +name: Release Package + +on: + release: + types: + - published + workflow_dispatch: + inputs: + package_version: + description: NuGet package version to build, without a leading v. + required: true + type: string + publish: + description: Publish the package to NuGet.org instead of running a dry pack. + required: true + default: false + type: boolean + +permissions: + contents: read + +env: + CONFIGURATION: Release + CORE_PROJECT: Evaluant.Calculator/NCalc.csproj + TEST_PROJECT: Evaluant.Calculator.Tests/NCalc.Tests.csproj + DOTNET_NOLOGO: true + NCALC_EXPECTED_PUBLIC_KEY_TOKEN: f59bbff0fd43f598 + +jobs: + package: + name: Build, test, and pack + runs-on: ubuntu-latest + outputs: + package-version: ${{ steps.resolve-package-version.outputs.package-version }} + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Resolve package version + id: resolve-package-version + shell: bash + run: | + if [ "$GITHUB_EVENT_NAME" = "release" ]; then + package_version="${GITHUB_REF_NAME#v}" + else + package_version="${{ inputs.package_version }}" + fi + + if [ -z "$package_version" ]; then + echo "Package version could not be resolved." >&2 + exit 1 + fi + + if [[ ! "$package_version" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)([-+][0-9A-Za-z.-]+)?$ ]]; then + echo "Unsupported package version format: $package_version" >&2 + exit 2 + fi + + package_major_version="${BASH_REMATCH[1]}" + if [ "$package_major_version" -lt 2 ]; then + echo "NCalc uses a new strong-name key, so release package versions must be 2.0.0 or later." >&2 + exit 1 + fi + + echo "PACKAGE_VERSION=$package_version" >> "$GITHUB_ENV" + echo "package-version=$package_version" >> "$GITHUB_OUTPUT" + + - name: Install Mono tooling + run: | + sudo apt-get update + sudo apt-get install --yes mono-devel mono-utils + + - name: Prepare release strong-name key + shell: bash + env: + NCALC_STRONG_NAME_KEY_BASE64: ${{ secrets.NCALC_STRONG_NAME_KEY_BASE64 }} + run: | + set -euo pipefail + + if [ -z "${NCALC_STRONG_NAME_KEY_BASE64:-}" ]; then + echo "NCALC_STRONG_NAME_KEY_BASE64 is required for release packaging." >&2 + exit 2 + fi + + key_path="$RUNNER_TEMP/NCalc.snk" + public_key_path="$RUNNER_TEMP/NCalc.public.snk" + printf '%s' "$NCALC_STRONG_NAME_KEY_BASE64" | base64 --decode > "$key_path" + sn -p "$key_path" "$public_key_path" >/dev/null + + actual_token="$(sn -tp "$public_key_path" | awk '/Public Key Token/ { print $4 }')" + if [ "$actual_token" != "$NCALC_EXPECTED_PUBLIC_KEY_TOKEN" ]; then + echo "Release signing key token mismatch: expected $NCALC_EXPECTED_PUBLIC_KEY_TOKEN, got $actual_token." >&2 + exit 1 + fi + + echo "NCalcStrongNameKeyFile=$key_path" >> "$GITHUB_ENV" + + - name: Run release validation gates + run: scripts/validate.sh + + - name: Upload NuGet package + uses: actions/upload-artifact@v4 + with: + name: ncalc-${{ steps.resolve-package-version.outputs.package-version }} + path: | + artifacts/packages/*.nupkg + artifacts/packages/*.snupkg + if-no-files-found: error + + windows-framework-smoke: + name: Windows .NET Framework package smoke + needs: package + runs-on: windows-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Download NuGet package + uses: actions/download-artifact@v4 + with: + name: ncalc-${{ needs.package.outputs.package-version }} + path: artifacts/packages + + - name: Run .NET Framework consumer smoke + shell: pwsh + run: > + scripts/windows-framework-consumer-smoke.ps1 + -PackageSource artifacts/packages + -PackageVersion "${{ needs.package.outputs.package-version }}" + + publish: + name: Publish to NuGet.org + needs: + - package + - windows-framework-smoke + runs-on: ubuntu-latest + + steps: + - name: Set up .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Download NuGet package + uses: actions/download-artifact@v4 + with: + name: ncalc-${{ needs.package.outputs.package-version }} + path: artifacts/packages + + - name: Publish to NuGet.org + shell: bash + run: | + should_publish=false + + if [ "$GITHUB_EVENT_NAME" = "release" ]; then + should_publish=true + elif [ "${{ inputs.publish }}" = "true" ]; then + should_publish=true + fi + + if [ "$should_publish" != "true" ]; then + echo "Dry run complete. Package was built but not published." + exit 0 + fi + + for package in artifacts/packages/*.nupkg; do + dotnet nuget push "$package" \ + --api-key "${{ secrets.NUGET_API_KEY }}" \ + --source "https://api.nuget.org/v3/index.json" \ + --skip-duplicate + done + + for symbols_package in artifacts/packages/*.snupkg; do + if [ -e "$symbols_package" ]; then + dotnet nuget push "$symbols_package" \ + --api-key "${{ secrets.NUGET_API_KEY }}" \ + --source "https://api.nuget.org/v3/index.json" \ + --skip-duplicate + fi + done diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2498ff2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +# Build outputs +[Bb]in/ +[Oo]bj/ +[Bb]uild/ +!build/ +!build/NCalc.BuildTools/ +!build/NCalc.BuildTools/*.cs +!build/NCalc.BuildTools/*.csproj +build/NCalc.BuildTools/[Bb]in/ +build/NCalc.BuildTools/[Oo]bj/ +artifacts/ +TestResults/ +_Package/ + +# User and IDE state +.vs/ +.idea/ +*.user +*.suo +*.userosscache +*.sln.docstates +*.sln.cache +*.rsuser +*ReSharper*/ + +# Test and coverage output +*.trx +*.coverage +*.coveragexml +*.dotCover +*.itrace.csdef + +# NuGet outputs +*.nupkg +*.snupkg +packages/ + +# Private signing material +*.snk +*.pfx +*.p12 +*.pem + +# Patch and merge leftovers +*.patch +*.orig +*.rej + +# OS files +.DS_Store +desktop.ini diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..d53f9c4 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,29 @@ + + + true + true + + + + NCalc + NCalc + Sebastien Ros + NCalc + Mathematical expressions evaluator for .NET. + ncalc;expression;expressions;calculator;formula;evaluator + https://github.com/sheetsync/NCalc + MIT + https://github.com/sheetsync/NCalc.git + git + README.md + false + true + snupkg + true + true + + + + + + diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..5f112c9 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,5 @@ + + + + + diff --git a/Evaluant.Calculator.FrameworkCompatibility.Tests/NCalc.FrameworkCompatibility.Tests.csproj b/Evaluant.Calculator.FrameworkCompatibility.Tests/NCalc.FrameworkCompatibility.Tests.csproj new file mode 100644 index 0000000..4e7e651 --- /dev/null +++ b/Evaluant.Calculator.FrameworkCompatibility.Tests/NCalc.FrameworkCompatibility.Tests.csproj @@ -0,0 +1,19 @@ + + + Exe + net35;net10.0 + NCalc.FrameworkCompatibility.Tests + NCalc.FrameworkCompatibility.Tests + disable + disable + latest + + + + + + + + + + diff --git a/Evaluant.Calculator.FrameworkCompatibility.Tests/Program.cs b/Evaluant.Calculator.FrameworkCompatibility.Tests/Program.cs new file mode 100644 index 0000000..0af1621 --- /dev/null +++ b/Evaluant.Calculator.FrameworkCompatibility.Tests/Program.cs @@ -0,0 +1,310 @@ +using System; +using System.Globalization; +using System.Threading; +using Antlr.Runtime; +using Antlr.Runtime.Tree; +using NCalc.Domain; +using DomainValueType = NCalc.Domain.ValueType; + +namespace NCalc.FrameworkCompatibility.Tests +{ + internal delegate object BinaryOperation(object left, object right); + + internal static class Program + { + private static int _passed; + private static int _failed; + + private static int Main() + { + CultureInfo originalCulture = Thread.CurrentThread.CurrentCulture; + CultureInfo originalUserInterfaceCulture = Thread.CurrentThread.CurrentUICulture; + + try + { + Thread.CurrentThread.CurrentCulture = new CultureInfo("fr-FR"); + Thread.CurrentThread.CurrentUICulture = new CultureInfo("fr-FR"); + + RunExpressionCases(); + RunParserSurfaceCases(); + RunNumbersCases(); + RunDomainCases(); + RunCallbackCases(); + } + finally + { + Thread.CurrentThread.CurrentCulture = originalCulture; + Thread.CurrentThread.CurrentUICulture = originalUserInterfaceCulture; + } + + if (_failed > 0) + { + Console.Error.WriteLine("NCalc framework compatibility failed: " + _failed + " failed, " + _passed + " passed."); + return 1; + } + + Console.WriteLine("NCalc framework compatibility passed: " + _passed + " assertions."); + return 0; + } + + private static void RunExpressionCases() + { + Run("ExpressionEvaluate_WithInvariantFloatLiteralUnderFrenchCulture_DoesReturnDouble", delegate + { + AssertExpression("1.5 + 2.25", 3.75d, typeof(double)); + }); + + Run("ExpressionEvaluate_WithLargeIntegerLiteral_DoesPreserveSingleOverflowPath", delegate + { + AssertExpression("40000000000", 40000000000f, typeof(float)); + }); + + Run("ExpressionEvaluate_WithDateLiteralUnderFrenchCulture_DoesReturnDateTime", delegate + { + AssertExpression("#01/01/2001#", new DateTime(2001, 1, 1), typeof(DateTime)); + }); + + Run("ExpressionEvaluate_WithUnicodeAndNewlineEscapes_DoesUnescapeString", delegate + { + AssertExpression("'\\u0041\\n'", "A\n", typeof(string)); + }); + + Run("ExpressionEvaluate_WithIgnoreCaseBuiltInFunction_DoesResolveFunctionName", delegate + { + AssertObject(1m, typeof(decimal), new Expression("aBs(-1)", EvaluateOptions.IgnoreCase).Evaluate()); + }); + + Run("ExpressionEvaluate_WithDecimalDoubleMix_DoesReturnDecimal", delegate + { + Expression expression = new Expression("1.8 + Abs([var1])"); + expression.Parameters["var1"] = 9.2d; + + AssertObject(11.0m, typeof(decimal), expression.Evaluate()); + }); + } + + private static void RunParserSurfaceCases() + { + Run("ParserNcalcExpression_WithLegacyReturnScopeVariable_DoesExposeAstTree", delegate + { + NCalcLexer lexer = new NCalcLexer(new ANTLRStringStream("1 + 2")); + CommonTokenStream tokens = new CommonTokenStream(lexer); + NCalcParser parser = new NCalcParser(tokens); + + ParserRuleReturnScope parserResult = parser.ncalcExpression(); + IAstRuleReturnScope astResult = (IAstRuleReturnScope)parserResult; + CommonTree tree = astResult.Tree as CommonTree; + + if (tree == null) + { + throw new InvalidOperationException("Expected legacy parser return scope to expose a CommonTree."); + } + + if (parserResult.Start == null || parserResult.Stop == null) + { + throw new InvalidOperationException("Expected legacy parser return scope to expose token boundaries."); + } + }); + } + + private static void RunNumbersCases() + { + Run("NumbersAdd_WithStringCommaDecimalUnderFrenchCulture_DoesParseUsingCurrentCulture", delegate + { + AssertOperation(Numbers.Add, "4,5", 1, 5.5m, typeof(decimal)); + }); + + Run("NumbersAdd_WithDoubleAndDecimal_DoesReturnDecimal", delegate + { + AssertOperation(Numbers.Add, 2.5d, 3.5m, 6.0m, typeof(decimal)); + }); + + Run("NumbersSoustract_WithByteAndByte_DoesPreserveNullFallthrough", delegate + { + AssertNull(Numbers.Soustract((byte)2, (byte)2)); + }); + + Run("NumbersSoustract_WithUnsignedOperands_DoesPreserveUncheckedWraparound", delegate + { + AssertOperation(Numbers.Soustract, (uint)6, (ulong)8, UInt64.MaxValue - 1, typeof(ulong)); + }); + + Run("NumbersDivide_WithUlongAndBoolean_DoesPreserveWrongOperatorMessage", delegate + { + AssertThrows( + delegate { Numbers.Divide((ulong)8, true); }, + typeof(InvalidOperationException), + "Operator '-'"); + }); + + Run("NumbersModulo_WithDecimalAndDouble_DoesPreserveDecimalMessageTypo", delegate + { + AssertThrows( + delegate { Numbers.Modulo(3.5m, 2.5d); }, + typeof(InvalidOperationException), + "decimal' and 'decimal"); + }); + + Run("NumbersMax_WithByteAndDecimal_DoesUseLeftOperandConversion", delegate + { + AssertOperation(Numbers.Max, (byte)2, 3.5m, (byte)4, typeof(byte)); + }); + + Run("NumbersMin_WithUshortAndDouble_DoesUseLeftOperandConversion", delegate + { + AssertOperation(Numbers.Min, (ushort)4, 2.5d, (ushort)2, typeof(ushort)); + }); + } + + private static void RunDomainCases() + { + Run("ValueExpression_WithFloatUnderFrenchCulture_DoesSerializeWithInvariantDecimalSeparator", delegate + { + AssertEqual("1.5", new ValueExpression(1.5f).ToString()); + }); + + Run("ValueExpression_WithUnsupportedObject_DoesThrowEvaluationException", delegate + { + AssertThrows( + delegate { new ValueExpression(new object()); }, + typeof(EvaluationException), + "This value could not be handled"); + }); + + Run("ExpressionEvaluate_WithManualAst_DoesUseSameArithmeticPipeline", delegate + { + Expression expression = new Expression( + new BinaryExpression( + BinaryExpressionType.Plus, + new ValueExpression(2), + new ValueExpression(3))); + + AssertObject(5, typeof(int), expression.Evaluate()); + }); + + Run("ValueExpression_WithDateTime_DoesInferDateTimeType", delegate + { + DateTime dateTime = new DateTime(2026, 6, 20); + ValueExpression expression = new ValueExpression(dateTime); + + AssertEqual(DomainValueType.DateTime, expression.Type); + AssertEqual(dateTime, expression.Value); + }); + } + + private static void RunCallbackCases() + { + Run("ExpressionEvaluate_WithLazyTernary_DoesNotResolveUnusedBranch", delegate + { + Expression expression = new Expression("if([flag], [value], [missing])"); + expression.Parameters["flag"] = true; + expression.Parameters["value"] = 42; + expression.EvaluateParameter += delegate(string name, ParameterArgs args) + { + if (name == "missing") + { + throw new InvalidOperationException("Unused branch was evaluated."); + } + }; + + AssertObject(42, typeof(int), expression.Evaluate()); + }); + + Run("ExpressionEvaluate_WithCustomFunction_DoesPreserveLazyParameterEvaluation", delegate + { + Expression expression = new Expression("twice([value] + 1)"); + expression.Parameters["value"] = 20; + expression.EvaluateFunction += delegate(string name, FunctionArgs args) + { + if (name == "twice") + { + args.Result = Convert.ToInt32(args.Parameters[0].Evaluate()) * 2; + } + }; + + AssertObject(42, typeof(int), expression.Evaluate()); + }); + } + + private static void AssertExpression(string expression, object expectedValue, Type expectedType) + { + AssertObject(expectedValue, expectedType, new Expression(expression).Evaluate()); + } + + private static void AssertOperation(BinaryOperation operation, object left, object right, object expectedValue, Type expectedType) + { + AssertObject(expectedValue, expectedType, operation(left, right)); + } + + private static void AssertObject(object expectedValue, Type expectedType, object actualValue) + { + if (actualValue == null) + { + throw new InvalidOperationException("Expected value <" + expectedValue + "> but got null."); + } + + if (actualValue.GetType() != expectedType) + { + throw new InvalidOperationException("Expected type <" + expectedType.FullName + "> but got <" + actualValue.GetType().FullName + ">."); + } + + AssertEqual(expectedValue, actualValue); + } + + private static void AssertNull(object actualValue) + { + if (actualValue != null) + { + throw new InvalidOperationException("Expected null but got <" + actualValue + ">."); + } + } + + private static void AssertEqual(object expectedValue, object actualValue) + { + if (!object.Equals(expectedValue, actualValue)) + { + throw new InvalidOperationException("Expected <" + expectedValue + "> but got <" + actualValue + ">."); + } + } + + private static void AssertThrows(Action action, Type expectedExceptionType, string expectedMessageFragment) + { + try + { + action(); + } + catch (Exception exception) + { + if (exception.GetType() != expectedExceptionType) + { + throw new InvalidOperationException("Expected exception <" + expectedExceptionType.FullName + "> but got <" + exception.GetType().FullName + ">.", exception); + } + + if (exception.Message.IndexOf(expectedMessageFragment, StringComparison.Ordinal) < 0) + { + throw new InvalidOperationException("Expected message <" + exception.Message + "> to contain <" + expectedMessageFragment + ">."); + } + + return; + } + + throw new InvalidOperationException("Expected exception <" + expectedExceptionType.FullName + ">."); + } + + private static void Run(string name, Action action) + { + try + { + action(); + _passed++; + Console.WriteLine("PASS " + name); + } + catch (Exception exception) + { + _failed++; + Console.Error.WriteLine("FAIL " + name); + Console.Error.WriteLine(exception); + } + } + } +} diff --git a/Evaluant.Calculator.Play/NCalc.Play.csproj b/Evaluant.Calculator.Play/NCalc.Play.csproj index 4823ad6..b623a3b 100644 --- a/Evaluant.Calculator.Play/NCalc.Play.csproj +++ b/Evaluant.Calculator.Play/NCalc.Play.csproj @@ -1,95 +1,15 @@ - - - - Debug - AnyCPU - 9.0.30729 - 2.0 - {4183699B-5B7E-4294-9807-9709BAD86477} - Exe - Properties - NCalc.Play - Play - - - 3.5 - - - v3.5 - publish\ - true - Disk - false - Foreground - 7 - Days - false - false - true - 0 - 1.0.0.%2a - false - false - true - - - - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - AllRules.ruleset - - - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - AllRules.ruleset - - - - - - - - - - - - - {5F014003-50D8-49E0-8AFE-91D38DCCC97C} - NCalc - - - - - False - .NET Framework 3.5 SP1 Client Profile - false - - - False - .NET Framework 3.5 SP1 - true - - - False - Windows Installer 3.1 - true - - - - - \ No newline at end of file + + + Exe + net10.0 + NCalc.Play + NCalc.Play + disable + disable + latest + + + + + + diff --git a/Evaluant.Calculator.Play/Program.cs b/Evaluant.Calculator.Play/Program.cs index a4a847f..4441fd4 100644 --- a/Evaluant.Calculator.Play/Program.cs +++ b/Evaluant.Calculator.Play/Program.cs @@ -1,33 +1,29 @@ -using System; - -namespace NCalc.Play -{ - /// - /// Summary description for Program. - /// - public class Program - { - public static void Main(string[] args) - { - - - var expressions = new[] - { - "2 * (3 + 5)", - "2 * (2*(2*(2+1)))", - "10 % 3", - "false || not (false and true)", - "3 > 2 and 1 <= (3-2)", - "3 % 2 != 10 % 3", - "if( [age] >= 18, 'majeur', 'mineur')", - "CalculateBenefits([user]) * [Taxes]" - }; - - foreach (string expression in expressions) - Console.WriteLine("{0} = {1}", - expression, - new Expression(expression).Evaluate()); - - } - } -} +using System; + +namespace NCalc.Play +{ + public static class Program + { + public static void Main() + { + string[] expressions = + { + "2 * (3 + 5)", + "2 * (2*(2*(2+1)))", + "10 % 3", + "false || not (false and true)", + "3 > 2 and 1 <= (3-2)", + "3 % 2 != 10 % 3", + "if([age] >= 18, 'adult', 'minor')", + }; + + foreach (string expressionText in expressions) + { + Expression expression = new Expression(expressionText); + expression.Parameters["age"] = 42; + + Console.WriteLine("{0} = {1}", expressionText, expression.Evaluate()); + } + } + } +} diff --git a/Evaluant.Calculator.Play/Properties/AssemblyInfo.cs b/Evaluant.Calculator.Play/Properties/AssemblyInfo.cs deleted file mode 100644 index b00a2e2..0000000 --- a/Evaluant.Calculator.Play/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("Evaluant.Calculator.Play")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("Evaluant.Calculator.Play")] -[assembly: AssemblyCopyright("Copyright © 2007")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("a49c6a87-4568-4ec3-a162-374db1fccd80")] - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/Evaluant.Calculator.Tests/AntlrCompatibilityTests.cs b/Evaluant.Calculator.Tests/AntlrCompatibilityTests.cs new file mode 100644 index 0000000..82bd143 --- /dev/null +++ b/Evaluant.Calculator.Tests/AntlrCompatibilityTests.cs @@ -0,0 +1,268 @@ +using System; +using System.Collections; +using System.IO; +using System.Text; +using Antlr.Runtime; +using Antlr.Runtime.Tree; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NCalc.Tests +{ + [TestClass] + public class AntlrCompatibilityTests + { + [TestMethod] + public void ParserReturnScope_WithLegacyObjectProperties_DoesStoreTokensAndTree() + { + CommonToken start = new CommonToken(1, "start"); + CommonToken stop = new CommonToken(2, "stop"); + CommonTree tree = new CommonTree(start); + RuleReturnScope ruleReturnScope = new RuleReturnScope(); + ParserRuleReturnScope parserRuleReturnScope = new ParserRuleReturnScope(); + TreeRuleReturnScope treeRuleReturnScope = new TreeRuleReturnScope(); + + ruleReturnScope.Start = start; + ruleReturnScope.Stop = stop; + ruleReturnScope.Tree = tree; + parserRuleReturnScope.Start = start; + treeRuleReturnScope.Start = tree; + + Assert.AreSame(start, ruleReturnScope.Start); + Assert.AreSame(stop, ruleReturnScope.Stop); + Assert.AreSame(tree, ruleReturnScope.Tree); + Assert.IsNull(ruleReturnScope.Template); + Assert.AreSame(start, parserRuleReturnScope.Start); + Assert.AreSame(tree, treeRuleReturnScope.Start); + } + + [TestMethod] + public void Token_WithLegacyConstants_DoesExposeCurrentRuntimeValues() + { + AssertLegacyTokenValue(-1, Token.EOF); + AssertLegacyTokenValue(0, Token.INVALID_TOKEN_TYPE); + AssertLegacyTokenValue(1, Token.EOR_TOKEN_TYPE); + AssertLegacyTokenValue(2, Token.DOWN); + AssertLegacyTokenValue(3, Token.UP); + AssertLegacyTokenValue(0, Token.DEFAULT_CHANNEL); + AssertLegacyTokenValue(99, Token.HIDDEN_CHANNEL); + AssertLegacyTokenValue(4, Token.MIN_TOKEN_TYPE); + Assert.IsNotNull(Token.EOF_TOKEN); + Assert.IsNotNull(Token.INVALID_TOKEN); + Assert.IsNotNull(Token.SKIP_TOKEN); + } + + [TestMethod] + public void AntlrFileStream_WithUtf8File_DoesLoadTextAndSourceName() + { + string filePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".txt"); + + try + { + File.WriteAllText(filePath, "abc", Encoding.UTF8); + + ANTLRFileStream stream = new ANTLRFileStream(filePath, Encoding.UTF8); + + Assert.AreEqual(filePath, stream.SourceName); + Assert.AreEqual(3, stream.Count); + Assert.AreEqual("abc", stream.Substring(0, 3)); + LegacyAntlrFileStream derivedStream = new LegacyAntlrFileStream(); + + derivedStream.Load(filePath, Encoding.UTF8); + Assert.IsNull(derivedStream.SourceName); + Assert.IsNull(derivedStream.FileName); + Assert.AreEqual(3, derivedStream.Count); + } + finally + { + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + } + } + + [TestMethod] + public void CollectionUtils_WithLegacyInputs_DoesFormatCollections() + { + Hashtable dictionary = new Hashtable(); + dictionary["one"] = 1; + ArrayList list = new ArrayList(); + list.Add("a"); + list.Add("b"); + + string dictionaryText = Antlr.Runtime.Collections.CollectionUtils.DictionaryToString(dictionary); + string listText = Antlr.Runtime.Collections.CollectionUtils.ListToString(list); + + StringAssert.Contains(dictionaryText, "one=1"); + Assert.AreEqual("[a, b]", listText); + Assert.AreEqual("null", Antlr.Runtime.Collections.CollectionUtils.DictionaryToString(null)); + Assert.AreEqual("null", Antlr.Runtime.Collections.CollectionUtils.ListToString(null)); + } + + [TestMethod] + public void HashList_WithDictionaryOperations_DoesPreserveInsertionAndLookup() + { + Antlr.Runtime.Collections.HashList hashList = new Antlr.Runtime.Collections.HashList(2); + hashList.Add("first", 1); + hashList["second"] = 2; + + Assert.AreEqual(2, hashList.Count); + Assert.IsTrue(hashList.Contains("first")); + Assert.AreEqual(2, hashList["second"]); + Assert.IsFalse(hashList.IsReadOnly); + Assert.IsFalse(hashList.IsFixedSize); + Assert.IsFalse(hashList.IsSynchronized); + Assert.IsNotNull(hashList.SyncRoot); + CollectionAssert.Contains((ICollection)hashList.Keys, "first"); + CollectionAssert.Contains((ICollection)hashList.Values, 2); + + DictionaryEntry[] entries = new DictionaryEntry[2]; + hashList.CopyTo(entries, 0); + CollectionAssert.Contains(new[] { entries[0].Key, entries[1].Key }, "first"); + CollectionAssert.Contains(new[] { entries[0].Key, entries[1].Key }, "second"); + + IDictionaryEnumerator enumerator = hashList.GetEnumerator(); + Assert.IsTrue(enumerator.MoveNext()); + + hashList.Remove("first"); + Assert.IsFalse(hashList.Contains("first")); + hashList.Clear(); + Assert.AreEqual(0, hashList.Count); + } + + [TestMethod] + public void StackList_WithPushPopOperations_DoesBehaveLikeStack() + { + Antlr.Runtime.Collections.StackList stackList = new Antlr.Runtime.Collections.StackList(); + stackList.Push("first"); + stackList.Push("second"); + + Assert.AreEqual("second", stackList.Peek()); + Assert.AreEqual("second", stackList.Pop()); + Assert.AreEqual("first", stackList.Pop()); + Assert.Throws(delegate { stackList.Peek(); }); + } + + [TestMethod] + public void Stats_WithLegacyHelpers_DoesCalculateValuesAndWriteReport() + { + int[] values = { 1, 2, 3 }; + string reportPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".txt"); + + try + { + Assert.AreEqual(2d, Antlr.Runtime.Misc.Stats.Avg(values)); + Assert.AreEqual(3, Antlr.Runtime.Misc.Stats.Max(values)); + Assert.AreEqual(1, Antlr.Runtime.Misc.Stats.Min(values)); + Assert.AreEqual(6, Antlr.Runtime.Misc.Stats.Sum(values)); + Assert.AreEqual(1d, Antlr.Runtime.Misc.Stats.Stddev(values)); + Assert.AreEqual(0d, Antlr.Runtime.Misc.Stats.Avg(new int[0])); + Assert.AreEqual(0, Antlr.Runtime.Misc.Stats.Max(new int[0])); + Assert.AreEqual(0, Antlr.Runtime.Misc.Stats.Min(new int[0])); + Assert.AreEqual(0d, Antlr.Runtime.Misc.Stats.Stddev(new[] { 1 })); + + Antlr.Runtime.Misc.Stats.WriteReport(reportPath, "report"); + Assert.AreEqual("report", File.ReadAllText(Antlr.Runtime.Misc.Stats.GetAbsoluteFileName(reportPath))); + } + finally + { + if (File.Exists(reportPath)) + { + File.Delete(reportPath); + } + } + } + + [TestMethod] + public void ErrorManager_WithLegacyErrors_DoesThrowInvalidOperationException() + { + Assert.Throws(delegate { Antlr.Runtime.Misc.ErrorManager.Error("error"); }); + Assert.Throws(delegate { Antlr.Runtime.Misc.ErrorManager.InternalError("error"); }); + + InvalidOperationException exception = Assert.Throws( + delegate { Antlr.Runtime.Misc.ErrorManager.InternalError("error", new ApplicationException("inner")); }); + + Assert.IsInstanceOfType(exception.InnerException); + } + + [TestMethod] + public void TreeCompatibility_WithLegacyTypes_DoesExposeTreeHelpers() + { + CommonTree tree = new CommonTree(new CommonToken(1, "root")); + CommonTokenStream tokens = new CommonTokenStream(new NCalcLexer(new ANTLRStringStream("1 + 2"))); + IToken start = new CommonToken(1, "start"); + IToken stop = new CommonToken(2, "stop"); + Antlr.Runtime.CommonErrorNode errorNode = new Antlr.Runtime.CommonErrorNode( + tokens, + start, + stop, + new RecognitionException(tokens)); + Antlr.Runtime.Tree.UnBufferedTreeNodeStream stream = new Antlr.Runtime.Tree.UnBufferedTreeNodeStream(tree); + + stream.HasUniqueNavigationNodes = true; + + Assert.IsNotNull(Antlr.Runtime.Tree.Tree.INVALID_NODE); + Assert.IsNotNull(errorNode); + Assert.IsTrue(stream.HasUniqueNavigationNodes); + Assert.IsGreaterThanOrEqualTo(0, stream.Size()); + Assert.AreEqual(-1, stream.Index()); + Assert.Throws(delegate { stream.Get(0); }); + Assert.IsNull(stream.Current); + Assert.IsTrue(stream.MoveNext()); + Assert.AreSame(tree, stream.Current); + Assert.IsFalse(stream.MoveNext()); + Assert.IsNull(stream.Current); + stream.Reset(); + Assert.IsNull(stream.Current); + } + + [TestMethod] + public void RewriteRuleElementStream_WithLegacyGenericType_DoesExposeStreamMembers() + { + LegacyRewriteRuleElementStream stream = new LegacyRewriteRuleElementStream(new CommonTreeAdaptor()); + + stream.Add("value"); + + Assert.IsTrue(stream.HasNext()); + Assert.AreEqual(1, stream.Size()); + Assert.AreEqual("value", stream.ReadNext()); + } + + [TestMethod] + public void SynPredPointer_WithLegacyDelegate_DoesInvoke() + { + bool invoked = false; + SynPredPointer synPredPointer = delegate { invoked = true; }; + + synPredPointer(); + + Assert.IsTrue(invoked); + } + + private sealed class LegacyRewriteRuleElementStream : RewriteRuleElementStream + { + public LegacyRewriteRuleElementStream(ITreeAdaptor adaptor) + : base(adaptor, "legacy") + { + } + + public object ReadNext() + { + return _Next(); + } + } + + private sealed class LegacyAntlrFileStream : ANTLRFileStream + { + public string FileName + { + get { return fileName; } + } + } + + private static void AssertLegacyTokenValue(int expectedValue, int actualValue) + { + Assert.AreEqual(expectedValue, actualValue); + } + } +} diff --git a/Evaluant.Calculator.Tests/AssemblyInfo.cs b/Evaluant.Calculator.Tests/AssemblyInfo.cs deleted file mode 100644 index 177a4f0..0000000 --- a/Evaluant.Calculator.Tests/AssemblyInfo.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; - -// -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -// -[assembly: AssemblyTitle("")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("")] -[assembly: AssemblyCopyright("")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Revision and Build Numbers -// by using the '*' as shown below: - -[assembly: AssemblyVersion("1.0.*")] - -// -// In order to sign your assembly you must specify a key to use. Refer to the -// Microsoft .NET Framework documentation for more information on assembly signing. -// -// Use the attributes below to control which key is used for signing. -// -// Notes: -// (*) If no key is specified, the assembly is not signed. -// (*) KeyName refers to a key that has been installed in the Crypto Service -// Provider (CSP) on your machine. KeyFile refers to a file which contains -// a key. -// (*) If the KeyFile and the KeyName values are both specified, the -// following processing occurs: -// (1) If the KeyName can be found in the CSP, that key is used. -// (2) If the KeyName does not exist and the KeyFile does exist, the key -// in the KeyFile is installed into the CSP and used. -// (*) In order to create a KeyFile, you can use the sn.exe (Strong Name) utility. -// When specifying the KeyFile, the location of the KeyFile should be -// relative to the project output directory which is -// %Project Directory%\obj\. For example, if your KeyFile is -// located in the project directory, you would specify the AssemblyKeyFile -// attribute as [assembly: AssemblyKeyFile("..\\..\\mykey.snk")] -// (*) Delay Signing is an advanced option - see the Microsoft .NET Framework -// documentation for more information on this. -// -[assembly: AssemblyDelaySign(false)] -[assembly: AssemblyKeyFile("")] -[assembly: AssemblyKeyName("")] diff --git a/Evaluant.Calculator.Tests/BuiltInFunctionTests.cs b/Evaluant.Calculator.Tests/BuiltInFunctionTests.cs new file mode 100644 index 0000000..61cae6f --- /dev/null +++ b/Evaluant.Calculator.Tests/BuiltInFunctionTests.cs @@ -0,0 +1,130 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NCalc.Tests +{ + /// + /// Characterizes built-in functions, including current arity validation. + /// + [TestClass] + public class BuiltInFunctionTests + { + [TestMethod] + public void MathFunctions_WithRepresentativeInputs_ReturnExpectedResults() + { + object[,] cases = + { + { "Abs(-12)", 12M }, + { "Ceiling(1.1)", 2D }, + { "Floor(1.9)", 1D }, + { "Max(10, 20)", 20 }, + { "Min(10, 20)", 10 }, + { "Max(1.5, 2.5)", 2.5D }, + { "Min(1.5, 2.5)", 1.5D }, + { "Pow(2, 8)", 256D }, + { "Round(3.456, 2)", 3.46D }, + { "Sign(-12)", -1 }, + { "Truncate(3.9)", 3D } + }; + + for (int i = 0; i < cases.GetLength(0); i++) + { + Assert.AreEqual(cases[i, 1], TestSupport.Evaluate((string)cases[i, 0]), (string)cases[i, 0]); + } + } + + [TestMethod] + public void TrigonometricFunctions_WithRepresentativeInputs_ReturnExpectedResults() + { + object[,] cases = + { + { "Acos(1)", 0D }, + { "Asin(0)", 0D }, + { "Atan(0)", 0D }, + { "Cos(0)", 1D }, + { "Exp(0)", 1D }, + { "IEEERemainder(3, 2)", -1D }, + { "Log(1, 10)", 0D }, + { "Log10(1)", 0D }, + { "Sin(0)", 0D }, + { "Sqrt(9)", 3D }, + { "Tan(0)", 0D } + }; + + for (int i = 0; i < cases.GetLength(0); i++) + { + Assert.AreEqual(cases[i, 1], TestSupport.Evaluate((string)cases[i, 0]), (string)cases[i, 0]); + } + } + + [TestMethod] + public void BuiltInFunctions_WithWrongArity_ThrowArgumentException() + { + string[,] cases = + { + { "Abs()", "Abs() takes exactly 1 argument" }, + { "Acos()", "Acos() takes exactly 1 argument" }, + { "Asin()", "Asin() takes exactly 1 argument" }, + { "Atan()", "Atan() takes exactly 1 argument" }, + { "Ceiling()", "Ceiling() takes exactly 1 argument" }, + { "Cos()", "Cos() takes exactly 1 argument" }, + { "Exp()", "Exp() takes exactly 1 argument" }, + { "Floor()", "Floor() takes exactly 1 argument" }, + { "IEEERemainder(1)", "IEEERemainder() takes exactly 2 arguments" }, + { "Log(1)", "Log() takes exactly 2 arguments" }, + { "Log10()", "Log10() takes exactly 1 argument" }, + { "Pow(1)", "Pow() takes exactly 2 arguments" }, + { "Round(1)", "Round() takes exactly 2 arguments" }, + { "Sign()", "Sign() takes exactly 1 argument" }, + { "Sin()", "Sin() takes exactly 1 argument" }, + { "Sqrt()", "Sqrt() takes exactly 1 argument" }, + { "Tan()", "Tan() takes exactly 1 argument" }, + { "Truncate()", "Truncate() takes exactly 1 argument" }, + { "Max(1)", "Max() takes exactly 2 arguments" }, + { "Min(1)", "Min() takes exactly 2 arguments" }, + { "if(true, 1)", "if() takes exactly 3 arguments" }, + { "in(1)", "in() takes at least 2 arguments" } + }; + + for (int i = 0; i < cases.GetLength(0); i++) + { + ArgumentException exception = TestSupport.Throws( + delegate { new Expression(cases[i, 0]).Evaluate(); }); + + TestSupport.StringContains(exception.Message, cases[i, 1]); + } + } + + [TestMethod] + public void BuiltInFunctions_WithWrongCaseWhenCaseSensitive_ThrowHelpfulArgumentException() + { + ArgumentException exception = TestSupport.Throws( + delegate { new Expression("max(1, 2)").Evaluate(); }); + + TestSupport.StringContains(exception.Message, "Function not found max. Try Max instead."); + } + + [TestMethod] + public void UnknownFunction_WithNoCustomHandler_ThrowsArgumentException() + { + ArgumentException exception = TestSupport.Throws( + delegate { new Expression("DoesNotExist(1)").Evaluate(); }); + + TestSupport.StringContains(exception.Message, "Function not found"); + Assert.AreEqual("DoesNotExist", exception.ParamName); + } + + [TestMethod] + public void IfFunction_WithUnusedBranchContainingError_DoesNotEvaluateUnusedBranch() + { + Assert.AreEqual(42, TestSupport.Evaluate("if(true, 42, 1 / 0)")); + Assert.AreEqual(42, TestSupport.Evaluate("if(false, 1 / 0, 42)")); + } + + [TestMethod] + public void InFunction_WithMatchBeforeInvalidExpression_DoesNotEvaluateRemainingArguments() + { + Assert.IsTrue((bool)TestSupport.Evaluate("in(4, 1, 2, 4, 1 / 0)")); + } + } +} diff --git a/Evaluant.Calculator.Tests/CustomFunctionAndParameterTests.cs b/Evaluant.Calculator.Tests/CustomFunctionAndParameterTests.cs new file mode 100644 index 0000000..31f3b1f --- /dev/null +++ b/Evaluant.Calculator.Tests/CustomFunctionAndParameterTests.cs @@ -0,0 +1,137 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NCalc.Tests +{ + /// + /// Characterizes custom callback behavior and event propagation. + /// + [TestClass] + public class CustomFunctionAndParameterTests + { + [TestMethod] + public void FunctionArgsEvaluateParameters_WithCustomFunction_EvaluatesAllArgumentsInOrder() + { + var expression = new Expression("SumAll([a], 2, [b])"); + expression.Parameters["a"] = 3; + expression.Parameters["b"] = 4; + + expression.EvaluateFunction += delegate(string name, FunctionArgs args) + { + if (name == "SumAll") + { + object[] values = args.EvaluateParameters(); + args.Result = (int)values[0] + (int)values[1] + (int)values[2]; + } + }; + + Assert.AreEqual(9, expression.Evaluate()); + } + + [TestMethod] + public void CustomFunction_WithNullResult_SetsHasResultAndReturnsNull() + { + var expression = new Expression("NullFunction(1)"); + + expression.EvaluateFunction += delegate(string name, FunctionArgs args) + { + Assert.IsFalse(args.HasResult); + args.Result = null; + Assert.IsTrue(args.HasResult); + }; + + Assert.IsNull(expression.Evaluate()); + } + + [TestMethod] + public void CustomParameter_WithNullResult_SetsHasResultAndReturnsNull() + { + var expression = new Expression("[MaybeNull]"); + + expression.EvaluateParameter += delegate(string name, ParameterArgs args) + { + Assert.IsFalse(args.HasResult); + args.Result = null; + Assert.IsTrue(args.HasResult); + }; + + Assert.IsNull(expression.Evaluate()); + } + + [TestMethod] + public void CustomFunction_WithUnusedArgument_DoesNotEvaluateUnusedArgument() + { + var expression = new Expression("First(10, 1 / 0)"); + + expression.EvaluateFunction += delegate(string name, FunctionArgs args) + { + args.Result = args.Parameters[0].Evaluate(); + }; + + Assert.AreEqual(10, expression.Evaluate()); + } + + [TestMethod] + public void NestedCustomFunctions_WithParameters_PropagateCallbacksToChildExpressions() + { + var expression = new Expression("Double(Add([x], 3))"); + expression.Parameters["x"] = 4; + + expression.EvaluateFunction += delegate(string name, FunctionArgs args) + { + if (name == "Add") + { + object[] values = args.EvaluateParameters(); + args.Result = Convert.ToInt32(values[0]) + Convert.ToInt32(values[1]); + } + + if (name == "Double") + { + args.Result = Convert.ToInt32(args.Parameters[0].Evaluate()) * 2; + } + }; + + Assert.AreEqual(14, expression.Evaluate()); + } + + [TestMethod] + public void CustomParameterCallback_WithNoDictionaryValue_ProvidesParameterValue() + { + var expression = new Expression("[Dynamic] + 2"); + + expression.EvaluateParameter += delegate(string name, ParameterArgs args) + { + if (name == "Dynamic") + { + args.Result = 40; + } + }; + + Assert.AreEqual(42, expression.Evaluate()); + } + + [TestMethod] + public void CustomParameterCallback_WithNoResult_ThrowsArgumentException() + { + var expression = new Expression("[Missing]"); + + ArgumentException exception = TestSupport.Throws( + delegate { expression.Evaluate(); }); + + TestSupport.StringContains(exception.Message, "Parameter was not defined"); + Assert.AreEqual("Missing", exception.ParamName); + } + + [TestMethod] + public void ParameterExpression_WithOuterParameters_UsesOuterParameterValues() + { + var expression = new Expression("[total] + [tax]"); + expression.Parameters["total"] = new Expression("[price] * [quantity]"); + expression.Parameters["price"] = 10; + expression.Parameters["quantity"] = 3; + expression.Parameters["tax"] = 2; + + Assert.AreEqual(32, expression.Evaluate()); + } + } +} diff --git a/Evaluant.Calculator.Tests/DomainPublicSurfaceTests.cs b/Evaluant.Calculator.Tests/DomainPublicSurfaceTests.cs new file mode 100644 index 0000000..3277458 --- /dev/null +++ b/Evaluant.Calculator.Tests/DomainPublicSurfaceTests.cs @@ -0,0 +1,458 @@ +using System; +using System.Collections.Generic; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NCalc.Domain; +using DomainValueType = NCalc.Domain.ValueType; + +namespace NCalc.Tests +{ + /// + /// Characterizes the public AST/domain model shared by parser, serializer, and evaluator. + /// + [TestClass] + public class DomainPublicSurfaceTests + { + [TestMethod] + public void BinaryExpression_WithConstructorArguments_ExposesMutableProperties() + { + LogicalExpression leftExpression = new ValueExpression(1); + LogicalExpression rightExpression = new ValueExpression(2); + var expression = new BinaryExpression(BinaryExpressionType.Plus, leftExpression, rightExpression); + + Assert.AreEqual(BinaryExpressionType.Plus, expression.Type); + Assert.AreSame(leftExpression, expression.LeftExpression); + Assert.AreSame(rightExpression, expression.RightExpression); + + LogicalExpression replacementLeftExpression = new Identifier("left"); + LogicalExpression replacementRightExpression = new Identifier("right"); + expression.Type = BinaryExpressionType.Minus; + expression.LeftExpression = replacementLeftExpression; + expression.RightExpression = replacementRightExpression; + + Assert.AreEqual(BinaryExpressionType.Minus, expression.Type); + Assert.AreSame(replacementLeftExpression, expression.LeftExpression); + Assert.AreSame(replacementRightExpression, expression.RightExpression); + } + + [TestMethod] + public void UnaryExpression_WithConstructorArguments_ExposesMutableProperties() + { + LogicalExpression innerExpression = new ValueExpression(1); + var expression = new UnaryExpression(UnaryExpressionType.Negate, innerExpression); + + Assert.AreEqual(UnaryExpressionType.Negate, expression.Type); + Assert.AreSame(innerExpression, expression.Expression); + + LogicalExpression replacementExpression = new Identifier("flag"); + expression.Type = UnaryExpressionType.Not; + expression.Expression = replacementExpression; + + Assert.AreEqual(UnaryExpressionType.Not, expression.Type); + Assert.AreSame(replacementExpression, expression.Expression); + } + + [TestMethod] + public void TernaryExpression_WithConstructorArguments_ExposesMutableProperties() + { + LogicalExpression conditionExpression = new Identifier("condition"); + LogicalExpression trueExpression = new ValueExpression(1); + LogicalExpression falseExpression = new ValueExpression(0); + var expression = new TernaryExpression(conditionExpression, trueExpression, falseExpression); + + Assert.AreSame(conditionExpression, expression.LeftExpression); + Assert.AreSame(trueExpression, expression.MiddleExpression); + Assert.AreSame(falseExpression, expression.RightExpression); + + LogicalExpression replacementConditionExpression = new ValueExpression(true); + LogicalExpression replacementTrueExpression = new Identifier("trueValue"); + LogicalExpression replacementFalseExpression = new Identifier("falseValue"); + expression.LeftExpression = replacementConditionExpression; + expression.MiddleExpression = replacementTrueExpression; + expression.RightExpression = replacementFalseExpression; + + Assert.AreSame(replacementConditionExpression, expression.LeftExpression); + Assert.AreSame(replacementTrueExpression, expression.MiddleExpression); + Assert.AreSame(replacementFalseExpression, expression.RightExpression); + } + + [TestMethod] + public void Function_WithConstructorArguments_ExposesMutableProperties() + { + var identifier = new Identifier("Sum"); + var arguments = new LogicalExpression[] + { + new ValueExpression(1), + new ValueExpression(2) + }; + var function = new Function(identifier, arguments); + + Assert.AreSame(identifier, function.Identifier); + Assert.AreSame(arguments, function.Expressions); + + var replacementIdentifier = new Identifier("Max"); + var replacementArguments = new LogicalExpression[] + { + new Identifier("amount"), + new ValueExpression(10) + }; + function.Identifier = replacementIdentifier; + function.Expressions = replacementArguments; + + Assert.AreSame(replacementIdentifier, function.Identifier); + Assert.AreSame(replacementArguments, function.Expressions); + } + + [TestMethod] + public void Identifier_WithConstructorArguments_ExposesMutableProperties() + { + var identifier = new Identifier("original"); + + Assert.AreEqual("original", identifier.Name); + + identifier.Name = "replacement"; + + Assert.AreEqual("replacement", identifier.Name); + } + + [TestMethod] + public void ValueExpression_WithExplicitTypeConstructor_ExposesMutableProperties() + { + var expression = new ValueExpression("123", DomainValueType.Integer); + + Assert.AreEqual("123", expression.Value); + Assert.AreEqual(DomainValueType.Integer, expression.Type); + + expression.Value = "abc"; + expression.Type = DomainValueType.String; + + Assert.AreEqual("abc", expression.Value); + Assert.AreEqual(DomainValueType.String, expression.Type); + } + + [TestMethod] + public void ValueExpression_WithStronglyTypedConstructors_InferExpectedTypes() + { + DateTime dateTime = new DateTime(2026, 6, 20); + + AssertValueExpression(new ValueExpression("hello"), "hello", DomainValueType.String); + AssertValueExpression(new ValueExpression(42), 42, DomainValueType.Integer); + AssertValueExpression(new ValueExpression(1.5F), 1.5F, DomainValueType.Float); + AssertValueExpression(new ValueExpression(dateTime), dateTime, DomainValueType.DateTime); + AssertValueExpression(new ValueExpression(true), true, DomainValueType.Boolean); + } + + [TestMethod] + public void ValueExpression_WithObjectConstructor_InferExpectedTypes() + { + object[,] cases = + { + { true, DomainValueType.Boolean }, + { new DateTime(2026, 6, 20), DomainValueType.DateTime }, + { 1.5M, DomainValueType.Float }, + { 1.5D, DomainValueType.Float }, + { 1.5F, DomainValueType.Float }, + { (byte)1, DomainValueType.Integer }, + { (sbyte)1, DomainValueType.Integer }, + { (short)1, DomainValueType.Integer }, + { 1, DomainValueType.Integer }, + { 1L, DomainValueType.Integer }, + { (ushort)1, DomainValueType.Integer }, + { 1U, DomainValueType.Integer }, + { 1UL, DomainValueType.Integer }, + { "hello", DomainValueType.String } + }; + + for (int index = 0; index < cases.GetLength(0); index++) + { + object value = cases[index, 0]; + var expression = new ValueExpression(value); + + Assert.AreSame(value, expression.Value); + Assert.AreEqual(cases[index, 1], expression.Type); + } + } + + [TestMethod] + public void ValueExpression_WithUnsupportedObject_ThrowsEvaluationException() + { + var unsupportedValue = new object(); + + EvaluationException exception = TestSupport.Throws( + delegate { new ValueExpression(unsupportedValue); }); + + Assert.AreEqual("This value could not be handled: " + unsupportedValue, exception.Message); + } + + [TestMethod] + public void Accept_WithConcreteExpressions_DispatchesToSpecificVisitorOverload() + { + AssertVisit( + new BinaryExpression(BinaryExpressionType.Plus, new ValueExpression(1), new ValueExpression(2)), + typeof(BinaryExpression)); + AssertVisit( + new UnaryExpression(UnaryExpressionType.Negate, new ValueExpression(1)), + typeof(UnaryExpression)); + AssertVisit( + new TernaryExpression(new ValueExpression(true), new ValueExpression(1), new ValueExpression(0)), + typeof(TernaryExpression)); + AssertVisit(new ValueExpression(1), typeof(ValueExpression)); + AssertVisit( + new Function(new Identifier("Sum"), new LogicalExpression[] { new ValueExpression(1) }), + typeof(Function)); + AssertVisit(new Identifier("amount"), typeof(Identifier)); + } + + [TestMethod] + public void Accept_WithBareLogicalExpression_DispatchesToBaseVisitorOverload() + { + LogicalExpression expression = new BareLogicalExpression(); + + AssertVisit(expression, typeof(LogicalExpression)); + } + + [TestMethod] + public void FluentMethods_WithLogicalExpressionOperands_CreateBinaryExpressionsWithExpectedShape() + { + foreach (FluentBinaryExpressionCase expressionCase in GetFluentBinaryExpressionCases()) + { + LogicalExpression leftExpression = new Identifier("left"); + LogicalExpression rightExpression = new Identifier("right"); + + BinaryExpression expression = expressionCase.CreateWithLogicalOperand(leftExpression, rightExpression); + + Assert.AreEqual(expressionCase.ExpectedType, expression.Type); + Assert.AreSame(leftExpression, expression.LeftExpression); + Assert.AreSame(rightExpression, expression.RightExpression); + } + } + + [TestMethod] + public void FluentMethods_WithObjectOperands_WrapRightOperandAsValueExpression() + { + foreach (FluentBinaryExpressionCase expressionCase in GetFluentBinaryExpressionCases()) + { + LogicalExpression leftExpression = new Identifier("left"); + + BinaryExpression expression = expressionCase.CreateWithValueOperand(leftExpression, 2); + + Assert.AreEqual(expressionCase.ExpectedType, expression.Type); + Assert.AreSame(leftExpression, expression.LeftExpression); + Assert.IsInstanceOfType(expression.RightExpression, typeof(ValueExpression)); + + var rightExpression = (ValueExpression)expression.RightExpression; + Assert.AreEqual(2, rightExpression.Value); + Assert.AreEqual(DomainValueType.Integer, rightExpression.Type); + } + } + + [TestMethod] + public void ExpressionEvaluation_WithManualDomainTree_EvaluatesParametersFunctionsAndBranches() + { + LogicalExpression conditionExpression = new Identifier("amount").GreaterThan(100); + LogicalExpression trueExpression = new Function( + new Identifier("Max"), + new LogicalExpression[] + { + new Identifier("amount"), + new ValueExpression(250) + }); + LogicalExpression falseExpression = new ValueExpression(0); + var expression = new Expression(new TernaryExpression(conditionExpression, trueExpression, falseExpression)); + + expression.Parameters["amount"] = 125; + Assert.AreEqual(250, expression.Evaluate()); + + expression.Parameters["amount"] = 75; + Assert.AreEqual(0, expression.Evaluate()); + } + + private static void AssertValueExpression(ValueExpression expression, object expectedValue, DomainValueType expectedType) + { + Assert.AreEqual(expectedValue, expression.Value); + Assert.AreEqual(expectedType, expression.Type); + } + + private static void AssertVisit(LogicalExpression expression, Type expectedVisitType) + { + var visitor = new RecordingLogicalExpressionVisitor(); + + expression.Accept(visitor); + + Assert.AreEqual(1, visitor.VisitCount); + Assert.AreSame(expression, visitor.LastExpression); + Assert.AreEqual(expectedVisitType, visitor.LastVisitType); + } + + private static IEnumerable GetFluentBinaryExpressionCases() + { + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.And, + (left, right) => left.And(right), + (left, value) => left.And(value)); + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.Or, + (left, right) => left.Or(right), + (left, value) => left.Or(value)); + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.Equal, + (left, right) => left.EqualsTo(right), + (left, value) => left.EqualsTo(value)); + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.NotEqual, + (left, right) => left.NotEqual(right), + (left, value) => left.NotEqual(value)); + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.Greater, + (left, right) => left.GreaterThan(right), + (left, value) => left.GreaterThan(value)); + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.GreaterOrEqual, + (left, right) => left.GreaterOrEqualThan(right), + (left, value) => left.GreaterOrEqualThan(value)); + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.Lesser, + (left, right) => left.LesserThan(right), + (left, value) => left.LesserThan(value)); + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.LesserOrEqual, + (left, right) => left.LesserOrEqualThan(right), + (left, value) => left.LesserOrEqualThan(value)); + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.Minus, + (left, right) => left.Minus(right), + (left, value) => left.Minus(value)); + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.Plus, + (left, right) => left.Plus(right), + (left, value) => left.Plus(value)); + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.Times, + (left, right) => left.Mult(right), + (left, value) => left.Mult(value)); + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.Div, + (left, right) => left.DividedBy(right), + (left, value) => left.DividedBy(value)); + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.Modulo, + (left, right) => left.Modulo(right), + (left, value) => left.Modulo(value)); + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.BitwiseOr, + (left, right) => left.BitwiseOr(right), + (left, value) => left.BitwiseOr(value)); + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.BitwiseAnd, + (left, right) => left.BitwiseAnd(right), + (left, value) => left.BitwiseAnd(value)); + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.BitwiseXOr, + (left, right) => left.BitwiseXOr(right), + (left, value) => left.BitwiseXOr(value)); + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.LeftShift, + (left, right) => left.LeftShift(right), + (left, value) => left.LeftShift(value)); + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.RightShift, + (left, right) => left.RightShift(right), + (left, value) => left.RightShift(value)); + } + + private static FluentBinaryExpressionCase CreateFluentBinaryExpressionCase( + BinaryExpressionType expectedType, + LogicalOperandFactory logicalOperandFactory, + ValueOperandFactory valueOperandFactory) + { + return new FluentBinaryExpressionCase(expectedType, logicalOperandFactory, valueOperandFactory); + } + + private delegate BinaryExpression LogicalOperandFactory(LogicalExpression leftExpression, LogicalExpression rightExpression); + + private delegate BinaryExpression ValueOperandFactory(LogicalExpression leftExpression, object rightValue); + + private sealed class FluentBinaryExpressionCase + { + public FluentBinaryExpressionCase( + BinaryExpressionType expectedType, + LogicalOperandFactory logicalOperandFactory, + ValueOperandFactory valueOperandFactory) + { + ExpectedType = expectedType; + LogicalOperandFactory = logicalOperandFactory; + ValueOperandFactory = valueOperandFactory; + } + + public BinaryExpressionType ExpectedType { get; private set; } + + private LogicalOperandFactory LogicalOperandFactory { get; set; } + + private ValueOperandFactory ValueOperandFactory { get; set; } + + public BinaryExpression CreateWithLogicalOperand(LogicalExpression leftExpression, LogicalExpression rightExpression) + { + return LogicalOperandFactory(leftExpression, rightExpression); + } + + public BinaryExpression CreateWithValueOperand(LogicalExpression leftExpression, object rightValue) + { + return ValueOperandFactory(leftExpression, rightValue); + } + } + + private sealed class RecordingLogicalExpressionVisitor : LogicalExpressionVisitor + { + public int VisitCount { get; private set; } + + public Type LastVisitType { get; private set; } + + public LogicalExpression LastExpression { get; private set; } + + public override void Visit(LogicalExpression expression) + { + RecordVisit(typeof(LogicalExpression), expression); + } + + public override void Visit(TernaryExpression expression) + { + RecordVisit(typeof(TernaryExpression), expression); + } + + public override void Visit(BinaryExpression expression) + { + RecordVisit(typeof(BinaryExpression), expression); + } + + public override void Visit(UnaryExpression expression) + { + RecordVisit(typeof(UnaryExpression), expression); + } + + public override void Visit(ValueExpression expression) + { + RecordVisit(typeof(ValueExpression), expression); + } + + public override void Visit(Function function) + { + RecordVisit(typeof(Function), function); + } + + public override void Visit(Identifier function) + { + RecordVisit(typeof(Identifier), function); + } + + private void RecordVisit(Type visitType, LogicalExpression expression) + { + VisitCount++; + LastVisitType = visitType; + LastExpression = expression; + } + } + + private sealed class BareLogicalExpression : LogicalExpression + { + } + } +} diff --git a/Evaluant.Calculator.Tests/EvaluationExceptionTests.cs b/Evaluant.Calculator.Tests/EvaluationExceptionTests.cs new file mode 100644 index 0000000..8578c00 --- /dev/null +++ b/Evaluant.Calculator.Tests/EvaluationExceptionTests.cs @@ -0,0 +1,34 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NCalc.Tests +{ + /// + /// Characterizes the public EvaluationException constructors. + /// + [TestClass] + public class EvaluationExceptionTests + { + [TestMethod] + public void Constructor_WithMessage_SetsMessageAndLeavesInnerExceptionNull() + { + var exception = new EvaluationException("evaluation failed"); + + Assert.IsInstanceOfType(exception, typeof(ApplicationException)); + Assert.AreEqual("evaluation failed", exception.Message); + Assert.IsNull(exception.InnerException); + } + + [TestMethod] + public void Constructor_WithMessageAndInnerException_SetsMessageAndInnerException() + { + var innerException = new InvalidOperationException("inner failure"); + + var exception = new EvaluationException("evaluation failed", innerException); + + Assert.IsInstanceOfType(exception, typeof(ApplicationException)); + Assert.AreEqual("evaluation failed", exception.Message); + Assert.AreSame(innerException, exception.InnerException); + } + } +} diff --git a/Evaluant.Calculator.Tests/EvaluationOptionTests.cs b/Evaluant.Calculator.Tests/EvaluationOptionTests.cs new file mode 100644 index 0000000..81ed10a --- /dev/null +++ b/Evaluant.Calculator.Tests/EvaluationOptionTests.cs @@ -0,0 +1,119 @@ +using System.Collections; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NCalc.Tests +{ + /// + /// Characterizes EvaluateOptions behavior, especially iteration edge cases. + /// + [TestClass] + public class EvaluationOptionTests + { + [TestMethod] + public void IgnoreCase_WithDifferentFunctionCasing_AllowsBuiltInFunctionLookup() + { + Assert.AreEqual(1M, new Expression("aBs(-1)", EvaluateOptions.IgnoreCase).Evaluate()); + } + + [TestMethod] + public void IgnoreCase_WithCustomFunction_LowersCallbackFunctionName() + { + var expression = new Expression("MiXeD(1)", EvaluateOptions.IgnoreCase); + string callbackName = null; + + expression.EvaluateFunction += delegate(string name, FunctionArgs args) + { + callbackName = name; + args.Result = args.Parameters[0].Evaluate(); + }; + + Assert.AreEqual(1, expression.Evaluate()); + Assert.AreEqual("mixed", callbackName); + } + + [TestMethod] + public void RoundAwayFromZero_WithMidpointValue_ChangesRoundBehavior() + { + Assert.AreEqual(22D, new Expression("Round(22.5, 0)").Evaluate()); + Assert.AreEqual(23D, new Expression("Round(22.5, 0)", EvaluateOptions.RoundAwayFromZero).Evaluate()); + } + + [TestMethod] + public void IterateParameters_WithMatchingEnumerableParameters_ReturnsResultPerIndex() + { + var expression = new Expression("[x] + [y]", EvaluateOptions.IterateParameters); + expression.Parameters["x"] = new[] { 1, 2, 3 }; + expression.Parameters["y"] = new[] { 10, 20, 30 }; + + var result = (IList)expression.Evaluate(); + + Assert.HasCount(3, result); + Assert.AreEqual(11, result[0]); + Assert.AreEqual(22, result[1]); + Assert.AreEqual(33, result[2]); + } + + [TestMethod] + public void IterateParameters_WithMismatchedEnumerableLengths_ThrowsEvaluationException() + { + var expression = new Expression("[x] + [y]", EvaluateOptions.IterateParameters); + expression.Parameters["x"] = new[] { 1, 2 }; + expression.Parameters["y"] = new[] { 10, 20, 30 }; + + EvaluationException exception = TestSupport.Throws( + delegate { expression.Evaluate(); }); + + TestSupport.StringContains(exception.Message, "IEnumerable parameters must have the same number of items"); + } + + [TestMethod] + public void IterateParameters_WithMixedScalarAndEnumerableParameters_ReusesScalarForEveryIndex() + { + var expression = new Expression("[x] + [offset]", EvaluateOptions.IterateParameters); + expression.Parameters["x"] = new[] { 1, 2, 3 }; + expression.Parameters["offset"] = 10; + + var result = (IList)expression.Evaluate(); + + Assert.HasCount(3, result); + Assert.AreEqual(11, result[0]); + Assert.AreEqual(12, result[1]); + Assert.AreEqual(13, result[2]); + } + + [TestMethod] + public void IterateParameters_WithEmptyEnumerable_ReturnsEmptyResultList() + { + var expression = new Expression("[x] + 1", EvaluateOptions.IterateParameters); + expression.Parameters["x"] = new int[0]; + + var result = (IList)expression.Evaluate(); + + Assert.IsEmpty(result); + } + + [TestMethod] + public void IterateParameters_WithOnlyScalarParameters_ReturnsEmptyResultList() + { + var expression = new Expression("[x] + 1", EvaluateOptions.IterateParameters); + expression.Parameters["x"] = 1; + + var result = (IList)expression.Evaluate(); + + Assert.IsEmpty(result); + } + + [TestMethod] + public void IterateParameters_AfterSuccessfulEvaluation_LeavesEnumerableParameterAtLastItem() + { + var expression = new Expression("[x] + [offset]", EvaluateOptions.IterateParameters); + expression.Parameters["x"] = new[] { 1, 2, 3 }; + expression.Parameters["offset"] = 10; + + expression.Evaluate(); + + Assert.AreEqual(3, expression.Parameters["x"]); + Assert.AreEqual(10, expression.Parameters["offset"]); + } + } +} diff --git a/Evaluant.Calculator.Tests/ExpressionNumericBehaviorTests.cs b/Evaluant.Calculator.Tests/ExpressionNumericBehaviorTests.cs new file mode 100644 index 0000000..ea09bfc --- /dev/null +++ b/Evaluant.Calculator.Tests/ExpressionNumericBehaviorTests.cs @@ -0,0 +1,67 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NCalc.Tests +{ + [TestClass] + public class ExpressionNumericBehaviorTests + { + [TestMethod] + public void ExpressionEvaluate_WithNumericLiterals_ReturnsCharacterizedRuntimeTypes() + { + VerifyAll( + new ExpressionResultCase("integer literal", "5", 5, typeof(int)), + new ExpressionResultCase("large integer literal overflow path", "40000000000", 40000000000f, typeof(float)), + new ExpressionResultCase("floating literal", "1.5", 1.5d, typeof(double)), + new ExpressionResultCase("scientific notation", "1.22e1", 12.2d, typeof(double))); + } + + [TestMethod] + public void ExpressionEvaluate_WithNumericArithmetic_ReturnsCharacterizedRuntimeTypes() + { + VerifyAll( + new ExpressionResultCase("integer division is promoted by evaluation visitor", "3/6", 0.5d, typeof(double)), + new ExpressionResultCase("integer modulo remains int", "10 % 4", 2, typeof(int)), + new ExpressionResultCase("unary negation remains int", "-5", -5, typeof(int)), + new ExpressionResultCase("large integer addition uses single", "40000000000+1", 40000000000 + 1f, typeof(float)), + new ExpressionResultCase("string numeric addition uses decimal conversion", "1 + '2'", 3m, typeof(decimal))); + } + + [TestMethod] + public void ExpressionEvaluate_WithDecimalDoubleMixing_ReturnsCharacterizedRuntimeTypes() + { + Expression expression = new Expression("1.8 + Abs([var1])"); + expression.Parameters["var1"] = 9.2; + + object actualValue = expression.Evaluate(); + + Assert.AreEqual(typeof(decimal), actualValue.GetType()); + Assert.AreEqual(11.0m, actualValue); + } + + [TestMethod] + public void ExpressionEvaluate_WithParameterizedRealTypes_PreservesCurrentRealResultTypes() + { + Expression floatExpression = new Expression("x/2"); + floatExpression.Parameters["x"] = 2f; + + Expression doubleExpression = new Expression("x/2"); + doubleExpression.Parameters["x"] = 2d; + + Expression decimalExpression = new Expression("x/2"); + decimalExpression.Parameters["x"] = 2m; + + Assert.AreEqual(typeof(float), floatExpression.Evaluate().GetType()); + Assert.AreEqual(typeof(double), doubleExpression.Evaluate().GetType()); + Assert.AreEqual(typeof(decimal), decimalExpression.Evaluate().GetType()); + } + + private static void VerifyAll(params ExpressionResultCase[] cases) + { + foreach (ExpressionResultCase testCase in cases) + { + testCase.Verify(); + } + } + } +} diff --git a/Evaluant.Calculator.Tests/ExpressionPublicApiTests.cs b/Evaluant.Calculator.Tests/ExpressionPublicApiTests.cs new file mode 100644 index 0000000..7057cfb --- /dev/null +++ b/Evaluant.Calculator.Tests/ExpressionPublicApiTests.cs @@ -0,0 +1,176 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NCalc.Domain; + +namespace NCalc.Tests +{ + /// + /// Characterizes the public Expression API, including cache-observable behavior. + /// + [TestClass] + public class ExpressionPublicApiTests + { + [TestMethod] + public void Constructor_WithEmptyString_ThrowsArgumentException() + { + ArgumentException exception = TestSupport.Throws( + delegate { new Expression(String.Empty); }); + + Assert.AreEqual("expression", exception.ParamName); + TestSupport.StringContains(exception.Message, "Expression can't be empty"); + } + + [TestMethod] + public void Constructor_WithNullString_ThrowsArgumentException() + { + ArgumentException exception = TestSupport.Throws( + delegate { new Expression((string)null); }); + + Assert.AreEqual("expression", exception.ParamName); + TestSupport.StringContains(exception.Message, "Expression can't be empty"); + } + + [TestMethod] + public void Constructor_WithNullLogicalExpression_ThrowsArgumentException() + { + ArgumentException exception = TestSupport.Throws( + delegate { new Expression((LogicalExpression)null); }); + + Assert.AreEqual("expression", exception.ParamName); + TestSupport.StringContains(exception.Message, "Expression can't be null"); + } + + [TestMethod] + public void Constructor_WithLogicalExpression_UsesProvidedExpression() + { + var expression = new Expression( + new BinaryExpression( + BinaryExpressionType.Plus, + new ValueExpression(2), + new ValueExpression(3))); + + Assert.AreEqual(5, expression.Evaluate()); + Assert.IsNotNull(expression.ParsedExpression); + } + + [TestMethod] + public void HasErrors_WithValidExpression_CompilesAndLeavesErrorNull() + { + var expression = new Expression("1 + 2"); + + Assert.IsFalse(expression.HasErrors()); + Assert.IsNotNull(expression.ParsedExpression); + Assert.IsNull(expression.Error); + } + + [TestMethod] + public void HasErrors_WithInvalidExpression_ReturnsTrueAndStoresError() + { + var expression = new Expression("1 +"); + + Assert.IsTrue(expression.HasErrors()); + Assert.IsNotNull(expression.Error); + TestSupport.StringContains(expression.Error, "line"); + } + + [TestMethod] + public void Evaluate_WithInvalidExpression_ThrowsEvaluationExceptionContainingStoredError() + { + var expression = new Expression("(1 + 2"); + + EvaluationException exception = TestSupport.Throws( + delegate { expression.Evaluate(); }); + + Assert.IsNotNull(expression.Error); + Assert.AreEqual(expression.Error, exception.Message); + } + + [TestMethod] + public void Compile_WithCacheEnabled_CompilesRepeatedExpressionsSuccessfully() + { + bool originalCacheEnabled = Expression.CacheEnabled; + + try + { + Expression.CacheEnabled = true; + LogicalExpression first = Expression.Compile("100 + 23", false); + LogicalExpression second = Expression.Compile("100 + 23", false); + + Assert.IsNotNull(first); + Assert.IsNotNull(second); + } + finally + { + Expression.CacheEnabled = originalCacheEnabled; + } + } + + [TestMethod] + public void CacheEnabled_WhenAssigned_RoundTripsAssignedValue() + { + bool originalCacheEnabled = Expression.CacheEnabled; + + try + { + Expression.CacheEnabled = false; + Assert.IsFalse(Expression.CacheEnabled); + + Expression.CacheEnabled = true; + Assert.IsTrue(Expression.CacheEnabled); + } + finally + { + Expression.CacheEnabled = originalCacheEnabled; + } + } + + [TestMethod] + public void Compile_WithNoCacheArgument_DoesNotReuseCompiledExpression() + { + bool originalCacheEnabled = Expression.CacheEnabled; + + try + { + Expression.CacheEnabled = true; + LogicalExpression first = Expression.Compile("200 + 23", true); + LogicalExpression second = Expression.Compile("200 + 23", true); + + Assert.IsFalse(Object.ReferenceEquals(first, second)); + } + finally + { + Expression.CacheEnabled = originalCacheEnabled; + } + } + + [TestMethod] + public void Compile_WithCacheDisabled_DoesNotReuseCompiledExpression() + { + bool originalCacheEnabled = Expression.CacheEnabled; + + try + { + Expression.CacheEnabled = false; + LogicalExpression first = Expression.Compile("300 + 23", false); + LogicalExpression second = Expression.Compile("300 + 23", false); + + Assert.IsFalse(Object.ReferenceEquals(first, second)); + } + finally + { + Expression.CacheEnabled = originalCacheEnabled; + } + } + + [TestMethod] + public void Evaluate_WithNoCacheOption_EvaluatesSuccessfullyWithoutReusingCompileCache() + { + var first = new Expression("400 + 23", EvaluateOptions.NoCache); + var second = new Expression("400 + 23", EvaluateOptions.NoCache); + + Assert.AreEqual(423, first.Evaluate()); + Assert.AreEqual(423, second.Evaluate()); + Assert.IsFalse(Object.ReferenceEquals(first.ParsedExpression, second.ParsedExpression)); + } + } +} diff --git a/Evaluant.Calculator.Tests/Fixtures.cs b/Evaluant.Calculator.Tests/Fixtures.cs index 774a8b4..b96eb3a 100644 --- a/Evaluant.Calculator.Tests/Fixtures.cs +++ b/Evaluant.Calculator.Tests/Fixtures.cs @@ -45,7 +45,7 @@ public void ShouldParseValues() Assert.AreEqual(123456, new Expression("123456").Evaluate()); Assert.AreEqual(new DateTime(2001, 01, 01), new Expression("#01/01/2001#").Evaluate()); Assert.AreEqual(123.456d, new Expression("123.456").Evaluate()); - Assert.AreEqual(true, new Expression("true").Evaluate()); + Assert.IsTrue((bool)new Expression("true").Evaluate()); Assert.AreEqual("true", new Expression("'true'").Evaluate()); Assert.AreEqual("azerty", new Expression("'azerty'").Evaluate()); } @@ -190,18 +190,18 @@ public void ShouldEvaluateInOperator() ein.Parameters["1"] = 2; ein.Parameters["2"] = 5; - Assert.AreEqual(true, ein.Evaluate()); + Assert.IsTrue((bool)ein.Evaluate()); var eout = new Expression("in((2 + 2), [1], [2], 1 + 2, 3)"); eout.Parameters["1"] = 2; eout.Parameters["2"] = 5; - Assert.AreEqual(false, eout.Evaluate()); + Assert.IsFalse((bool)eout.Evaluate()); // Should work with strings var estring = new Expression("in('to' + 'to', 'titi', 'toto')"); - Assert.AreEqual(true, estring.Evaluate()); + Assert.IsTrue((bool)estring.Evaluate()); } @@ -283,7 +283,7 @@ public void ShouldThrowAnExpcetionWhenInvalidNumber() [TestMethod] public void ShouldNotRoundDecimalValues() { - Assert.AreEqual(false, new Expression("0 <= -0.6").Evaluate()); + Assert.IsFalse((bool)new Expression("0 <= -0.6").Evaluate()); } [TestMethod] @@ -518,7 +518,7 @@ public void CustomFunctionShouldReturnNull() Assert.IsTrue(args.HasResult); }; - Assert.AreEqual(null, e.Evaluate()); + Assert.IsNull(e.Evaluate()); } [TestMethod] @@ -534,14 +534,14 @@ public void CustomParametersShouldReturnNull() Assert.IsTrue(args.HasResult); }; - Assert.AreEqual(null, e.Evaluate()); + Assert.IsNull(e.Evaluate()); } [TestMethod] public void ShouldCompareDates() { - Assert.AreEqual(true, new Expression("#1/1/2009#==#1/1/2009#").Evaluate()); - Assert.AreEqual(false, new Expression("#2/1/2009#==#1/1/2009#").Evaluate()); + Assert.IsTrue((bool)new Expression("#1/1/2009#==#1/1/2009#").Evaluate()); + Assert.IsFalse((bool)new Expression("#2/1/2009#==#1/1/2009#").Evaluate()); } [TestMethod] @@ -573,16 +573,19 @@ public void ShouldHandleLongValues() [TestMethod] public void ShouldCompareLongValues() { - Assert.AreEqual(false, new Expression("(0=1500000)||(((0+2200000000)-1500000)<0)").Evaluate()); + Assert.IsFalse((bool)new Expression("(0=1500000)||(((0+2200000000)-1500000)<0)").Evaluate()); } - [TestMethod, ExpectedException(typeof(InvalidOperationException))] + [TestMethod] public void ShouldDisplayErrorIfUncompatibleTypes() { - var e = new Expression("(a > b) + 10"); - e.Parameters["a"] = 1; - e.Parameters["b"] = 2; - e.Evaluate(); + Assert.Throws(() => + { + var e = new Expression("(a > b) + 10"); + e.Parameters["a"] = 1; + e.Parameters["b"] = 2; + e.Evaluate(); + }); } [TestMethod] @@ -613,7 +616,7 @@ public void ShouldShortCircuitBooleanExpressions() var e = new Expression("([a] != 0) && ([b]/[a]>2)"); e.Parameters["a"] = 0; - Assert.AreEqual(false, e.Evaluate()); + Assert.IsFalse((bool)e.Evaluate()); } [TestMethod] diff --git a/Evaluant.Calculator.Tests/LegacyNumbersMatrix.cs b/Evaluant.Calculator.Tests/LegacyNumbersMatrix.cs new file mode 100644 index 0000000..14a8bd6 --- /dev/null +++ b/Evaluant.Calculator.Tests/LegacyNumbersMatrix.cs @@ -0,0 +1,388 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NCalc.Tests +{ + // Defines the legacy numeric dispatch contract exercised by the matrix tests. + internal enum LegacyNumbersOperator + { + Add, + Soustract, + Multiply, + Divide, + Modulo, + Max, + Min + } + + internal sealed class LegacyNumericOperand + { + public LegacyNumericOperand(string name, object value, TypeCode typeCode, string legacyTypeName) + { + Name = name; + Value = value; + TypeCode = typeCode; + LegacyTypeName = legacyTypeName; + } + + public string Name { get; } + public object Value { get; } + public TypeCode TypeCode { get; } + public string LegacyTypeName { get; } + } + + internal sealed class LegacyNumericOutcome + { + private LegacyNumericOutcome(object expectedValue, Type expectedExceptionType, string expectedExceptionMessage, bool returnsNull) + { + ExpectedValue = expectedValue; + ExpectedExceptionType = expectedExceptionType; + ExpectedExceptionMessage = expectedExceptionMessage; + ReturnsNull = returnsNull; + } + + public object ExpectedValue { get; } + public Type ExpectedExceptionType { get; } + public string ExpectedExceptionMessage { get; } + public bool ReturnsNull { get; } + + public bool ThrowsException => ExpectedExceptionType != null; + + public static LegacyNumericOutcome Value(object expectedValue) + { + return new LegacyNumericOutcome(expectedValue, null, null, false); + } + + public static LegacyNumericOutcome Null() + { + return new LegacyNumericOutcome(null, null, null, true); + } + + public static LegacyNumericOutcome Exception(Type expectedExceptionType, string expectedExceptionMessage) + { + return new LegacyNumericOutcome(null, expectedExceptionType, expectedExceptionMessage, false); + } + } + + internal static class LegacyNumbersMatrix + { + private static readonly LegacyNumericOperand[] PrimitiveOperands = + { + new LegacyNumericOperand("bool true", true, TypeCode.Boolean, "bool"), + new LegacyNumericOperand("byte 8", (byte)8, TypeCode.Byte, "byte"), + new LegacyNumericOperand("sbyte -2", (sbyte)-2, TypeCode.SByte, "sbyte"), + new LegacyNumericOperand("short -3", (short)-3, TypeCode.Int16, "short"), + new LegacyNumericOperand("ushort 4", (ushort)4, TypeCode.UInt16, "ushort"), + new LegacyNumericOperand("int 5", 5, TypeCode.Int32, "int"), + new LegacyNumericOperand("uint 6", (uint)6, TypeCode.UInt32, "uint"), + new LegacyNumericOperand("long -7", (long)-7, TypeCode.Int64, "long"), + new LegacyNumericOperand("ulong 8", (ulong)8, TypeCode.UInt64, "ulong"), + new LegacyNumericOperand("float 1.5", 1.5f, TypeCode.Single, "float"), + new LegacyNumericOperand("double 2.5", 2.5d, TypeCode.Double, "double"), + new LegacyNumericOperand("decimal 3.5", 3.5m, TypeCode.Decimal, "decimal") + }; + + public static void VerifyPrimitiveArithmeticMatrix(string operationName, NumericOperation operation, LegacyNumbersOperator numbersOperator) + { + foreach (LegacyNumericOperand left in PrimitiveOperands) + { + foreach (LegacyNumericOperand right in PrimitiveOperands) + { + string caseName = operationName + " with " + left.Name + " and " + right.Name; + LegacyNumericOutcome expectedOutcome = ExpectPrimitiveArithmetic(numbersOperator, left, right); + + VerifyOutcome(caseName, operation, left.Value, right.Value, expectedOutcome); + } + } + } + + public static void VerifyPrimitiveExtremaMatrix(string operationName, NumericOperation operation, LegacyNumbersOperator numbersOperator) + { + foreach (LegacyNumericOperand left in PrimitiveOperands) + { + foreach (LegacyNumericOperand right in PrimitiveOperands) + { + string caseName = operationName + " with " + left.Name + " and " + right.Name; + LegacyNumericOutcome expectedOutcome = ExpectPrimitiveExtrema(numbersOperator, left, right); + + VerifyOutcome(caseName, operation, left.Value, right.Value, expectedOutcome); + } + } + } + + private static LegacyNumericOutcome ExpectPrimitiveArithmetic(LegacyNumbersOperator numbersOperator, LegacyNumericOperand left, LegacyNumericOperand right) + { + switch (numbersOperator) + { + case LegacyNumbersOperator.Add: + return ExpectAdd(left, right); + case LegacyNumbersOperator.Soustract: + return ExpectSoustract(left, right); + case LegacyNumbersOperator.Multiply: + case LegacyNumbersOperator.Divide: + case LegacyNumbersOperator.Modulo: + return ExpectMultiplyDivideOrModulo(numbersOperator, left, right); + default: + throw new InvalidOperationException("Unsupported arithmetic operator: " + numbersOperator); + } + } + + private static LegacyNumericOutcome ExpectAdd(LegacyNumericOperand left, LegacyNumericOperand right) + { + if (left.TypeCode == TypeCode.Boolean) + { + return InvalidOperation(LegacyNumbersOperator.Add, "bool", right.TypeCode == TypeCode.Boolean ? "bool" : "byte"); + } + + if (right.TypeCode == TypeCode.Boolean) + { + return InvalidOperation(LegacyNumbersOperator.Add, left.TypeCode == TypeCode.UInt32 ? "unit" : left.LegacyTypeName, "bool"); + } + + if (IsSignedIntegral(left) && right.TypeCode == TypeCode.UInt64) + { + return InvalidOperation(LegacyNumbersOperator.Add, left.LegacyTypeName, "ulong"); + } + + if (left.TypeCode == TypeCode.UInt64 && IsSignedIntegral(right)) + { + string rightTypeName = right.TypeCode == TypeCode.Int64 ? "ulong" : right.LegacyTypeName; + return InvalidOperation(LegacyNumbersOperator.Add, "ulong", rightTypeName); + } + + if (IsDecimalRealMix(left, right)) + { + return LegacyNumericOutcome.Value(AddDecimalRealMix(left.Value, right.Value)); + } + + return LegacyNumericOutcome.Value(ApplyArithmetic(LegacyNumbersOperator.Add, left.Value, right.Value)); + } + + private static LegacyNumericOutcome ExpectSoustract(LegacyNumericOperand left, LegacyNumericOperand right) + { + if (left.TypeCode == TypeCode.Boolean) + { + return InvalidOperation(LegacyNumbersOperator.Soustract, "bool", right.TypeCode == TypeCode.Boolean ? "bool" : "byte"); + } + + if (right.TypeCode == TypeCode.Boolean) + { + return InvalidOperation(LegacyNumbersOperator.Soustract, left.LegacyTypeName, "bool"); + } + + if (right.TypeCode == TypeCode.Byte) + { + return LegacyNumericOutcome.Null(); + } + + if (IsSignedIntegral(left) && right.TypeCode == TypeCode.UInt64) + { + return InvalidOperation(LegacyNumbersOperator.Soustract, left.LegacyTypeName, "ulong"); + } + + if (left.TypeCode == TypeCode.UInt64 && IsSignedIntegral(right)) + { + string rightTypeName = right.TypeCode == TypeCode.SByte ? "double" : right.LegacyTypeName; + return InvalidOperation(LegacyNumbersOperator.Soustract, "ulong", rightTypeName); + } + + if (IsDecimalRealMix(left, right)) + { + return InvalidOperation(LegacyNumbersOperator.Soustract, left.LegacyTypeName, right.LegacyTypeName); + } + + return LegacyNumericOutcome.Value(ApplyArithmetic(LegacyNumbersOperator.Soustract, left.Value, right.Value)); + } + + private static LegacyNumericOutcome ExpectMultiplyDivideOrModulo(LegacyNumbersOperator numbersOperator, LegacyNumericOperand left, LegacyNumericOperand right) + { + if (left.TypeCode == TypeCode.Boolean) + { + return LegacyNumericOutcome.Null(); + } + + if (right.TypeCode == TypeCode.Boolean) + { + return left.TypeCode == TypeCode.UInt64 && numbersOperator == LegacyNumbersOperator.Divide + ? InvalidOperationWithSymbol("-", "ulong", "bool") + : InvalidOperation(numbersOperator, left.LegacyTypeName, "bool"); + } + + if (right.TypeCode == TypeCode.Byte) + { + return LegacyNumericOutcome.Null(); + } + + if (IsSignedIntegral(left) && right.TypeCode == TypeCode.UInt64) + { + return InvalidOperation(numbersOperator, left.LegacyTypeName, "ulong"); + } + + if (left.TypeCode == TypeCode.UInt64 && IsSignedIntegral(right)) + { + return InvalidOperation(numbersOperator, "ulong", right.LegacyTypeName); + } + + if (IsDecimalRealMix(left, right)) + { + string rightTypeName = numbersOperator == LegacyNumbersOperator.Modulo && left.TypeCode == TypeCode.Decimal && right.TypeCode == TypeCode.Double + ? "decimal" + : right.LegacyTypeName; + + return InvalidOperation(numbersOperator, left.LegacyTypeName, rightTypeName); + } + + return LegacyNumericOutcome.Value(ApplyArithmetic(numbersOperator, left.Value, right.Value)); + } + + private static LegacyNumericOutcome ExpectPrimitiveExtrema(LegacyNumbersOperator numbersOperator, LegacyNumericOperand left, LegacyNumericOperand right) + { + if (left.TypeCode == TypeCode.Boolean) + { + return LegacyNumericOutcome.Null(); + } + + try + { + object convertedRight = Convert.ChangeType(right.Value, left.Value.GetType()); + object expectedValue = SelectExtrema(numbersOperator, left.Value, convertedRight); + + return LegacyNumericOutcome.Value(expectedValue); + } + catch (Exception exception) + { + return LegacyNumericOutcome.Exception(exception.GetType(), exception.Message); + } + } + + private static void VerifyOutcome(string caseName, NumericOperation operation, object left, object right, LegacyNumericOutcome expectedOutcome) + { + try + { + object actualValue = operation(left, right); + + if (expectedOutcome.ThrowsException) + { + Assert.Fail(caseName + " should throw " + expectedOutcome.ExpectedExceptionType.Name); + } + + if (expectedOutcome.ReturnsNull) + { + Assert.IsNull(actualValue, caseName + " should return null"); + return; + } + + Assert.IsNotNull(actualValue, caseName + " should not return null"); + Assert.AreEqual(expectedOutcome.ExpectedValue.GetType(), actualValue.GetType(), caseName + " result type"); + Assert.AreEqual(expectedOutcome.ExpectedValue, actualValue, caseName + " result value"); + } + catch (Exception exception) + { + if (!expectedOutcome.ThrowsException) + { + Assert.Fail(caseName + " should not throw but threw " + exception.GetType().Name + ": " + exception.Message); + } + + Assert.AreEqual(expectedOutcome.ExpectedExceptionType, exception.GetType(), caseName + " exception type"); + Assert.AreEqual(expectedOutcome.ExpectedExceptionMessage, exception.Message, caseName + " exception message"); + } + } + + private static LegacyNumericOutcome InvalidOperation(LegacyNumbersOperator numbersOperator, string leftTypeName, string rightTypeName) + { + return InvalidOperationWithSymbol(SymbolFor(numbersOperator), leftTypeName, rightTypeName); + } + + private static LegacyNumericOutcome InvalidOperationWithSymbol(string operatorSymbol, string leftTypeName, string rightTypeName) + { + return LegacyNumericOutcome.Exception( + typeof(InvalidOperationException), + "Operator '" + operatorSymbol + "' can't be applied to operands of types '" + leftTypeName + "' and '" + rightTypeName + "'"); + } + + private static string SymbolFor(LegacyNumbersOperator numbersOperator) + { + switch (numbersOperator) + { + case LegacyNumbersOperator.Add: + return "+"; + case LegacyNumbersOperator.Soustract: + return "-"; + case LegacyNumbersOperator.Multiply: + return "*"; + case LegacyNumbersOperator.Divide: + return "/"; + case LegacyNumbersOperator.Modulo: + return "%"; + default: + throw new InvalidOperationException("No arithmetic symbol for " + numbersOperator); + } + } + + private static bool IsSignedIntegral(LegacyNumericOperand operand) + { + return operand.TypeCode == TypeCode.SByte + || operand.TypeCode == TypeCode.Int16 + || operand.TypeCode == TypeCode.Int32 + || operand.TypeCode == TypeCode.Int64; + } + + private static bool IsDecimalRealMix(LegacyNumericOperand left, LegacyNumericOperand right) + { + return (left.TypeCode == TypeCode.Decimal && IsReal(right)) + || (right.TypeCode == TypeCode.Decimal && IsReal(left)); + } + + private static bool IsReal(LegacyNumericOperand operand) + { + return operand.TypeCode == TypeCode.Single || operand.TypeCode == TypeCode.Double; + } + + private static object AddDecimalRealMix(object leftValue, object rightValue) + { + if (leftValue is decimal leftDecimal) + { + return leftDecimal + Convert.ToDecimal(rightValue); + } + + return Convert.ToDecimal(leftValue) + (decimal)rightValue; + } + + private static object ApplyArithmetic(LegacyNumbersOperator numbersOperator, object leftValue, object rightValue) + { + dynamic left = leftValue; + dynamic right = rightValue; + + switch (numbersOperator) + { + case LegacyNumbersOperator.Add: + return left + right; + case LegacyNumbersOperator.Soustract: + return left - right; + case LegacyNumbersOperator.Multiply: + return left * right; + case LegacyNumbersOperator.Divide: + return left / right; + case LegacyNumbersOperator.Modulo: + return left % right; + default: + throw new InvalidOperationException("Unsupported arithmetic operator: " + numbersOperator); + } + } + + private static object SelectExtrema(LegacyNumbersOperator numbersOperator, object leftValue, object convertedRight) + { + int comparison = ((IComparable)leftValue).CompareTo(convertedRight); + + switch (numbersOperator) + { + case LegacyNumbersOperator.Max: + return comparison >= 0 ? leftValue : convertedRight; + case LegacyNumbersOperator.Min: + return comparison <= 0 ? leftValue : convertedRight; + default: + throw new InvalidOperationException("Unsupported extrema operator: " + numbersOperator); + } + } + } +} diff --git a/Evaluant.Calculator.Tests/MSTestSettings.cs b/Evaluant.Calculator.Tests/MSTestSettings.cs new file mode 100644 index 0000000..6c59e26 --- /dev/null +++ b/Evaluant.Calculator.Tests/MSTestSettings.cs @@ -0,0 +1,4 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +// Keeps the legacy behavioral suite serial after the MSTest migration. +[assembly: DoNotParallelize] diff --git a/Evaluant.Calculator.Tests/NCalc.Tests.csproj b/Evaluant.Calculator.Tests/NCalc.Tests.csproj index 0dccdbd..54c30c3 100644 --- a/Evaluant.Calculator.Tests/NCalc.Tests.csproj +++ b/Evaluant.Calculator.Tests/NCalc.Tests.csproj @@ -1,104 +1,18 @@ - - - - Debug - AnyCPU - 9.0.30729 - 2.0 - {F8E5E800-F842-4F84-A0C6-B3E1E5DB2BB4} - Library - Properties - NCalc.Tests - NCalc.Tests - v3.5 - 512 - {3AC096D0-A1C2-E12C-1390-A8335801FDAB};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} - - - 3.5 - - publish\ - true - Disk - false - Foreground - 7 - Days - false - false - true - 0 - 1.0.0.%2a - false - false - true - - - - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - AllRules.ruleset - - - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - AllRules.ruleset - - - - False - ..\Binaries\Antlr3.Runtime.dll - - - - - - - - - - - {5F014003-50D8-49E0-8AFE-91D38DCCC97C} - NCalc - - - - - False - Microsoft .NET Framework 4 %28x86 and x64%29 - true - - - False - .NET Framework 3.5 SP1 Client Profile - false - - - False - .NET Framework 3.5 SP1 - false - - - False - Windows Installer 3.1 - true - - - - - \ No newline at end of file + + + net10.0 + NCalc.Tests + NCalc.Tests + latest + disable + + + + + + + + + + + diff --git a/Evaluant.Calculator.Tests/NumbersArithmeticTests.cs b/Evaluant.Calculator.Tests/NumbersArithmeticTests.cs new file mode 100644 index 0000000..27c5c20 --- /dev/null +++ b/Evaluant.Calculator.Tests/NumbersArithmeticTests.cs @@ -0,0 +1,84 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NCalc.Tests +{ + [TestClass] + public class NumbersArithmeticTests + { + [TestMethod] + public void NumbersAdd_WithMixedNumericInputs_ReturnsCharacterizedValues() + { + VerifyAll( + new NumericResultCase("byte plus byte", Numbers.Add, (byte)2, (byte)2, 4, typeof(int)), + new NumericResultCase("byte plus uint", Numbers.Add, (byte)2, (uint)6, (uint)8, typeof(uint)), + new NumericResultCase("byte plus ulong", Numbers.Add, (byte)2, (ulong)8, (ulong)10, typeof(ulong)), + new NumericResultCase("short plus single", Numbers.Add, (short)-3, 1.5f, -1.5f, typeof(float)), + new NumericResultCase("int plus double", Numbers.Add, 5, 2.5d, 7.5d, typeof(double)), + new NumericResultCase("long plus decimal", Numbers.Add, (long)-7, 3.5m, -3.5m, typeof(decimal)), + new NumericResultCase("single plus decimal", Numbers.Add, 1.5f, 3.5m, 5.0m, typeof(decimal)), + new NumericResultCase("double plus decimal", Numbers.Add, 2.5d, 3.5m, 6.0m, typeof(decimal)), + new NumericResultCase("string plus int", Numbers.Add, "4", 5, 9m, typeof(decimal)), + new NumericResultCase("char plus decimal", Numbers.Add, '5', 3.5m, 8.5m, typeof(decimal))); + } + + [TestMethod] + public void NumbersSoustract_WithMixedNumericInputs_ReturnsCharacterizedValues() + { + VerifyAll( + new NumericResultCase("byte minus byte falls through", Numbers.Soustract, (byte)2, (byte)2, null, null), + new NumericResultCase("int minus byte falls through", Numbers.Soustract, 5, (byte)2, null, null), + new NumericResultCase("byte minus sbyte", Numbers.Soustract, (byte)2, (sbyte)-2, 4, typeof(int)), + new NumericResultCase("byte minus uint wraps", Numbers.Soustract, (byte)2, (uint)6, UInt32.MaxValue - 3, typeof(uint)), + new NumericResultCase("byte minus ulong wraps", Numbers.Soustract, (byte)2, (ulong)8, UInt64.MaxValue - 5, typeof(ulong)), + new NumericResultCase("uint minus ulong wraps", Numbers.Soustract, (uint)6, (ulong)8, UInt64.MaxValue - 1, typeof(ulong)), + new NumericResultCase("string minus char", Numbers.Soustract, "4", '5', -1m, typeof(decimal))); + } + + [TestMethod] + public void NumbersMultiply_WithMixedNumericInputs_ReturnsCharacterizedValues() + { + VerifyAll( + new NumericResultCase("byte times byte falls through", Numbers.Multiply, (byte)2, (byte)2, null, null), + new NumericResultCase("byte times sbyte", Numbers.Multiply, (byte)2, (sbyte)-2, -4, typeof(int)), + new NumericResultCase("ushort times ulong", Numbers.Multiply, (ushort)4, (ulong)8, (ulong)32, typeof(ulong)), + new NumericResultCase("int times double", Numbers.Multiply, 5, 2.5d, 12.5d, typeof(double)), + new NumericResultCase("long times decimal", Numbers.Multiply, (long)-7, 3.5m, -24.5m, typeof(decimal)), + new NumericResultCase("string times char", Numbers.Multiply, "4", '5', 20m, typeof(decimal))); + } + + [TestMethod] + public void NumbersDivide_WithMixedNumericInputs_ReturnsCharacterizedValues() + { + VerifyAll( + new NumericResultCase("byte divided by byte falls through", Numbers.Divide, (byte)8, (byte)2, null, null), + new NumericResultCase("byte divided by sbyte", Numbers.Divide, (byte)8, (sbyte)2, 4, typeof(int)), + new NumericResultCase("int divided by int uses integer division", Numbers.Divide, 5, 2, 2, typeof(int)), + new NumericResultCase("int divided by double", Numbers.Divide, 5, 2.5d, 2d, typeof(double)), + new NumericResultCase("uint divided by ulong", Numbers.Divide, (uint)8, (ulong)2, (ulong)4, typeof(ulong)), + new NumericResultCase("decimal divided by int", Numbers.Divide, 3.5m, 5, 0.7m, typeof(decimal)), + new NumericResultCase("string divided by char", Numbers.Divide, "8", '4', 2m, typeof(decimal))); + } + + [TestMethod] + public void NumbersModulo_WithMixedNumericInputs_ReturnsCharacterizedValues() + { + VerifyAll( + new NumericResultCase("byte modulo byte falls through", Numbers.Modulo, (byte)8, (byte)2, null, null), + new NumericResultCase("byte modulo sbyte", Numbers.Modulo, (byte)8, (sbyte)3, 2, typeof(int)), + new NumericResultCase("int modulo int", Numbers.Modulo, 5, 2, 1, typeof(int)), + new NumericResultCase("uint modulo ulong", Numbers.Modulo, (uint)8, (ulong)3, (ulong)2, typeof(ulong)), + new NumericResultCase("double modulo single", Numbers.Modulo, 5.5d, 2.0f, 1.5d, typeof(double)), + new NumericResultCase("decimal modulo int", Numbers.Modulo, 7.5m, 2, 1.5m, typeof(decimal)), + new NumericResultCase("string modulo char", Numbers.Modulo, "9", '4', 1m, typeof(decimal))); + } + + private static void VerifyAll(params NumericResultCase[] cases) + { + foreach (NumericResultCase testCase in cases) + { + testCase.Verify(); + } + } + } +} diff --git a/Evaluant.Calculator.Tests/NumbersConversionAndNullTests.cs b/Evaluant.Calculator.Tests/NumbersConversionAndNullTests.cs new file mode 100644 index 0000000..d6d749b --- /dev/null +++ b/Evaluant.Calculator.Tests/NumbersConversionAndNullTests.cs @@ -0,0 +1,82 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NCalc.Tests +{ + // Characterizes conversion and null behavior around the numeric switchboard. + [TestClass] + public class NumbersConversionAndNullTests + { + [TestMethod] + public void NumbersArithmetic_WithStringAndCharacterOperands_DoesParseOperandsAsDecimalsBeforeDispatch() + { + VerifyResults( + new NumericResultCase("add character plus string as decimals", Numbers.Add, '5', "4", 9m, typeof(decimal)), + new NumericResultCase("add string plus double through decimal conversion", Numbers.Add, "4", 2.5d, 6.5m, typeof(decimal)), + new NumericResultCase("soustract character minus string as decimals", Numbers.Soustract, '5', "4", 1m, typeof(decimal)), + new NumericResultCase("multiply character by string as decimals", Numbers.Multiply, '5', "4", 20m, typeof(decimal)), + new NumericResultCase("divide character by string as decimals", Numbers.Divide, '5', "4", 1.25m, typeof(decimal)), + new NumericResultCase("modulo character by string as decimals", Numbers.Modulo, '5', "4", 1m, typeof(decimal)), + new NumericResultCase("modulo decimal by character as decimals", Numbers.Modulo, 3.5m, '5', 3.5m, typeof(decimal))); + } + + [TestMethod] + public void NumbersArithmetic_WithInvalidStringOperand_DoesThrowFormatExceptionBeforeDispatch() + { + VerifyExceptions( + new NumericExceptionCase("add invalid string", Numbers.Add, "not-a-number", 1, typeof(FormatException), null), + new NumericExceptionCase("soustract invalid string", Numbers.Soustract, "not-a-number", 1, typeof(FormatException), null), + new NumericExceptionCase("multiply invalid string", Numbers.Multiply, "not-a-number", 1, typeof(FormatException), null), + new NumericExceptionCase("divide invalid string", Numbers.Divide, "not-a-number", 1, typeof(FormatException), null), + new NumericExceptionCase("modulo invalid string", Numbers.Modulo, "not-a-number", 1, typeof(FormatException), null), + new NumericExceptionCase("max invalid right string parses before bool fallthrough", Numbers.Max, true, "not-a-number", typeof(FormatException), null), + new NumericExceptionCase("min invalid left string parses before null handling", Numbers.Min, "not-a-number", null, typeof(FormatException), null)); + } + + [TestMethod] + public void NumbersArithmetic_WithNullOperand_DoesThrowNullReferenceExceptionBeforeDispatch() + { + VerifyExceptions( + new NumericExceptionCase("add null left", Numbers.Add, null, 1, typeof(NullReferenceException), null), + new NumericExceptionCase("add null right", Numbers.Add, 1, null, typeof(NullReferenceException), null), + new NumericExceptionCase("soustract null left", Numbers.Soustract, null, 1, typeof(NullReferenceException), null), + new NumericExceptionCase("soustract null right", Numbers.Soustract, 1, null, typeof(NullReferenceException), null), + new NumericExceptionCase("multiply null left", Numbers.Multiply, null, 1, typeof(NullReferenceException), null), + new NumericExceptionCase("multiply null right", Numbers.Multiply, 1, null, typeof(NullReferenceException), null), + new NumericExceptionCase("divide null left", Numbers.Divide, null, 1, typeof(NullReferenceException), null), + new NumericExceptionCase("divide null right", Numbers.Divide, 1, null, typeof(NullReferenceException), null), + new NumericExceptionCase("modulo null left", Numbers.Modulo, null, 1, typeof(NullReferenceException), null), + new NumericExceptionCase("modulo null right", Numbers.Modulo, 1, null, typeof(NullReferenceException), null)); + } + + [TestMethod] + public void NumbersExtrema_WithNullAndConvertedOperands_DoesReturnConvertedNonNullOperand() + { + VerifyResults( + new NumericResultCase("max null string parses right before null return", Numbers.Max, null, "4", 4m, typeof(decimal)), + new NumericResultCase("min null character parses right before null return", Numbers.Min, null, '5', 5m, typeof(decimal)), + new NumericResultCase("max string null parses left before null return", Numbers.Max, "4", null, 4m, typeof(decimal)), + new NumericResultCase("min character null parses left before null return", Numbers.Min, '5', null, 5m, typeof(decimal)), + new NumericResultCase("max bool null returns bool before unsupported switch", Numbers.Max, true, null, true, typeof(bool)), + new NumericResultCase("min null bool returns bool before unsupported switch", Numbers.Min, null, true, true, typeof(bool)), + new NumericResultCase("max bool int still falls through", Numbers.Max, true, 1, null, null), + new NumericResultCase("min bool int still falls through", Numbers.Min, true, 1, null, null)); + } + + private static void VerifyResults(params NumericResultCase[] cases) + { + foreach (NumericResultCase testCase in cases) + { + testCase.Verify(); + } + } + + private static void VerifyExceptions(params NumericExceptionCase[] cases) + { + foreach (NumericExceptionCase testCase in cases) + { + testCase.Verify(); + } + } + } +} diff --git a/Evaluant.Calculator.Tests/NumbersExtremaTests.cs b/Evaluant.Calculator.Tests/NumbersExtremaTests.cs new file mode 100644 index 0000000..a0f0bde --- /dev/null +++ b/Evaluant.Calculator.Tests/NumbersExtremaTests.cs @@ -0,0 +1,84 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NCalc.Tests +{ + [TestClass] + public class NumbersExtremaTests + { + [TestMethod] + public void NumbersMax_WithNullInputs_ReturnsCharacterizedValues() + { + VerifyAll( + new NumericResultCase("max null null", Numbers.Max, null, null, null, null), + new NumericResultCase("max null int", Numbers.Max, null, 3, 3, typeof(int)), + new NumericResultCase("max int null", Numbers.Max, 3, null, 3, typeof(int))); + } + + [TestMethod] + public void NumbersMin_WithNullInputs_ReturnsCharacterizedValues() + { + VerifyAll( + new NumericResultCase("min null null", Numbers.Min, null, null, null, null), + new NumericResultCase("min null int", Numbers.Min, null, 3, 3, typeof(int)), + new NumericResultCase("min int null", Numbers.Min, 3, null, 3, typeof(int))); + } + + [TestMethod] + public void NumbersMax_UsesLeftOperandTypeForConversionAndResult() + { + VerifyAll( + new NumericResultCase("max bool left returns null", Numbers.Max, true, 2, null, null), + new NumericResultCase("max byte converts right to byte", Numbers.Max, (byte)2, 3.5m, (byte)4, typeof(byte)), + new NumericResultCase("max sbyte converts string to sbyte", Numbers.Max, (sbyte)-2, "4", (sbyte)4, typeof(sbyte)), + new NumericResultCase("max short converts char to short", Numbers.Max, (short)-3, '5', (short)5, typeof(short)), + new NumericResultCase("max uint converts ulong to uint", Numbers.Max, (uint)6, (ulong)8, (uint)8, typeof(uint)), + new NumericResultCase("max long converts bool to long", Numbers.Max, (long)-7, true, (long)1, typeof(long)), + new NumericResultCase("max single converts decimal to single", Numbers.Max, 1.5f, 3.5m, 3.5f, typeof(float)), + new NumericResultCase("max double converts char to double", Numbers.Max, 2.5d, '5', 5d, typeof(double)), + new NumericResultCase("max decimal converts ulong to decimal", Numbers.Max, 3.5m, (ulong)8, 8m, typeof(decimal)), + new NumericResultCase("max string parses left as decimal", Numbers.Max, "4", '5', 5m, typeof(decimal))); + } + + [TestMethod] + public void NumbersMin_UsesLeftOperandTypeForConversionAndResult() + { + VerifyAll( + new NumericResultCase("min bool left returns null", Numbers.Min, true, 2, null, null), + new NumericResultCase("min byte converts bool to byte", Numbers.Min, (byte)2, true, (byte)1, typeof(byte)), + new NumericResultCase("min sbyte keeps left value", Numbers.Min, (sbyte)-2, "4", (sbyte)-2, typeof(sbyte)), + new NumericResultCase("min ushort converts double to ushort", Numbers.Min, (ushort)4, 2.5d, (ushort)2, typeof(ushort)), + new NumericResultCase("min int converts char to int", Numbers.Min, 5, '5', 5, typeof(int)), + new NumericResultCase("min single keeps left value", Numbers.Min, 1.5f, 3.5m, 1.5f, typeof(float)), + new NumericResultCase("min double keeps right converted value", Numbers.Min, 2.5d, 1.5f, 1.5d, typeof(double)), + new NumericResultCase("min decimal converts bool to decimal", Numbers.Min, 3.5m, true, 1m, typeof(decimal)), + new NumericResultCase("min string parses left as decimal", Numbers.Min, "4", '5', 4m, typeof(decimal))); + } + + [TestMethod] + public void NumbersExtrema_WithUnsupportedConversions_ThrowCharacterizedExceptions() + { + VerifyExceptions( + new NumericExceptionCase("max byte with negative sbyte", Numbers.Max, (byte)2, (sbyte)-2, typeof(OverflowException), "unsigned byte"), + new NumericExceptionCase("min byte with negative short", Numbers.Min, (byte)2, (short)-3, typeof(OverflowException), "unsigned byte"), + new NumericExceptionCase("max ulong with negative long", Numbers.Max, (ulong)8, (long)-7, typeof(OverflowException), "UInt64"), + new NumericExceptionCase("min ulong with negative sbyte", Numbers.Min, (ulong)8, (sbyte)-2, typeof(OverflowException), "UInt64")); + } + + private static void VerifyAll(params NumericResultCase[] cases) + { + foreach (NumericResultCase testCase in cases) + { + testCase.Verify(); + } + } + + private static void VerifyExceptions(params NumericExceptionCase[] cases) + { + foreach (NumericExceptionCase testCase in cases) + { + testCase.Verify(); + } + } + } +} diff --git a/Evaluant.Calculator.Tests/NumbersInvalidOperationTests.cs b/Evaluant.Calculator.Tests/NumbersInvalidOperationTests.cs new file mode 100644 index 0000000..74eec0b --- /dev/null +++ b/Evaluant.Calculator.Tests/NumbersInvalidOperationTests.cs @@ -0,0 +1,73 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NCalc.Tests +{ + [TestClass] + public class NumbersInvalidOperationTests + { + [TestMethod] + public void NumbersAdd_WithUnsupportedInputs_ThrowsCharacterizedExceptions() + { + VerifyAll( + new NumericExceptionCase("bool plus bool", Numbers.Add, true, true, typeof(InvalidOperationException), "bool' and 'bool"), + new NumericExceptionCase("bool plus decimal reports byte right side", Numbers.Add, true, 3.5m, typeof(InvalidOperationException), "bool' and 'byte"), + new NumericExceptionCase("uint plus bool reports unit typo", Numbers.Add, (uint)6, true, typeof(InvalidOperationException), "unit' and 'bool"), + new NumericExceptionCase("sbyte plus ulong", Numbers.Add, (sbyte)-2, (ulong)8, typeof(InvalidOperationException), "sbyte' and 'ulong"), + new NumericExceptionCase("long plus ulong", Numbers.Add, (long)-7, (ulong)8, typeof(InvalidOperationException), "long' and 'ulong"), + new NumericExceptionCase("non numeric string plus int", Numbers.Add, "not-a-number", 1, typeof(FormatException), null)); + } + + [TestMethod] + public void NumbersSoustract_WithUnsupportedInputs_ThrowsCharacterizedExceptions() + { + VerifyAll( + new NumericExceptionCase("bool minus bool", Numbers.Soustract, true, true, typeof(InvalidOperationException), "bool' and 'bool"), + new NumericExceptionCase("sbyte minus ulong", Numbers.Soustract, (sbyte)-2, (ulong)8, typeof(InvalidOperationException), "sbyte' and 'ulong"), + new NumericExceptionCase("ulong minus sbyte reports double typo", Numbers.Soustract, (ulong)8, (sbyte)-2, typeof(InvalidOperationException), "ulong' and 'double"), + new NumericExceptionCase("single minus decimal", Numbers.Soustract, 1.5f, 3.5m, typeof(InvalidOperationException), "float' and 'decimal"), + new NumericExceptionCase("decimal minus double", Numbers.Soustract, 3.5m, 2.5d, typeof(InvalidOperationException), "decimal' and 'double")); + } + + [TestMethod] + public void NumbersMultiply_WithUnsupportedInputs_ThrowsCharacterizedExceptions() + { + VerifyAll( + new NumericExceptionCase("byte times bool", Numbers.Multiply, (byte)2, true, typeof(InvalidOperationException), "byte' and 'bool"), + new NumericExceptionCase("sbyte times ulong", Numbers.Multiply, (sbyte)-2, (ulong)8, typeof(InvalidOperationException), "sbyte' and 'ulong"), + new NumericExceptionCase("ulong times int", Numbers.Multiply, (ulong)8, 5, typeof(InvalidOperationException), "ulong' and 'int"), + new NumericExceptionCase("single times decimal", Numbers.Multiply, 1.5f, 3.5m, typeof(InvalidOperationException), "float' and 'decimal"), + new NumericExceptionCase("decimal times double", Numbers.Multiply, 3.5m, 2.5d, typeof(InvalidOperationException), "decimal' and 'double")); + } + + [TestMethod] + public void NumbersDivide_WithUnsupportedInputs_ThrowsCharacterizedExceptions() + { + VerifyAll( + new NumericExceptionCase("byte divided by bool", Numbers.Divide, (byte)8, true, typeof(InvalidOperationException), "byte' and 'bool"), + new NumericExceptionCase("ulong divided by bool reports minus operator", Numbers.Divide, (ulong)8, true, typeof(InvalidOperationException), "Operator '-'"), + new NumericExceptionCase("int divided by ulong", Numbers.Divide, 8, (ulong)2, typeof(InvalidOperationException), "int' and 'ulong"), + new NumericExceptionCase("single divided by decimal", Numbers.Divide, 1.5f, 3.5m, typeof(InvalidOperationException), "float' and 'decimal"), + new NumericExceptionCase("double divided by decimal", Numbers.Divide, 2.5d, 3.5m, typeof(InvalidOperationException), "double' and 'decimal")); + } + + [TestMethod] + public void NumbersModulo_WithUnsupportedInputs_ThrowsCharacterizedExceptions() + { + VerifyAll( + new NumericExceptionCase("byte modulo bool", Numbers.Modulo, (byte)8, true, typeof(InvalidOperationException), "byte' and 'bool"), + new NumericExceptionCase("sbyte modulo ulong", Numbers.Modulo, (sbyte)-2, (ulong)8, typeof(InvalidOperationException), "sbyte' and 'ulong"), + new NumericExceptionCase("ulong modulo int", Numbers.Modulo, (ulong)8, 5, typeof(InvalidOperationException), "ulong' and 'int"), + new NumericExceptionCase("single modulo decimal", Numbers.Modulo, 1.5f, 3.5m, typeof(InvalidOperationException), "float' and 'decimal"), + new NumericExceptionCase("decimal modulo double reports decimal twice", Numbers.Modulo, 3.5m, 2.5d, typeof(InvalidOperationException), "decimal' and 'decimal")); + } + + private static void VerifyAll(params NumericExceptionCase[] cases) + { + foreach (NumericExceptionCase testCase in cases) + { + testCase.Verify(); + } + } + } +} diff --git a/Evaluant.Calculator.Tests/NumbersPrimitiveMatrixTests.cs b/Evaluant.Calculator.Tests/NumbersPrimitiveMatrixTests.cs new file mode 100644 index 0000000..98c1c12 --- /dev/null +++ b/Evaluant.Calculator.Tests/NumbersPrimitiveMatrixTests.cs @@ -0,0 +1,51 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NCalc.Tests +{ + // Exercises the full primitive numeric switchboard without changing runtime behavior. + [TestClass] + public class NumbersPrimitiveMatrixTests + { + [TestMethod] + public void NumbersAdd_WithPrimitiveNumericMatrix_DoesPreserveLegacyResultsAndExceptions() + { + LegacyNumbersMatrix.VerifyPrimitiveArithmeticMatrix("Numbers.Add", Numbers.Add, LegacyNumbersOperator.Add); + } + + [TestMethod] + public void NumbersSoustract_WithPrimitiveNumericMatrix_DoesPreserveLegacyResultsAndExceptions() + { + LegacyNumbersMatrix.VerifyPrimitiveArithmeticMatrix("Numbers.Soustract", Numbers.Soustract, LegacyNumbersOperator.Soustract); + } + + [TestMethod] + public void NumbersMultiply_WithPrimitiveNumericMatrix_DoesPreserveLegacyResultsNullsAndExceptions() + { + LegacyNumbersMatrix.VerifyPrimitiveArithmeticMatrix("Numbers.Multiply", Numbers.Multiply, LegacyNumbersOperator.Multiply); + } + + [TestMethod] + public void NumbersDivide_WithPrimitiveNumericMatrix_DoesPreserveLegacyResultsNullsAndExceptions() + { + LegacyNumbersMatrix.VerifyPrimitiveArithmeticMatrix("Numbers.Divide", Numbers.Divide, LegacyNumbersOperator.Divide); + } + + [TestMethod] + public void NumbersModulo_WithPrimitiveNumericMatrix_DoesPreserveLegacyResultsNullsAndExceptions() + { + LegacyNumbersMatrix.VerifyPrimitiveArithmeticMatrix("Numbers.Modulo", Numbers.Modulo, LegacyNumbersOperator.Modulo); + } + + [TestMethod] + public void NumbersMax_WithPrimitiveNumericMatrix_DoesPreserveLeftOperandConversionRules() + { + LegacyNumbersMatrix.VerifyPrimitiveExtremaMatrix("Numbers.Max", Numbers.Max, LegacyNumbersOperator.Max); + } + + [TestMethod] + public void NumbersMin_WithPrimitiveNumericMatrix_DoesPreserveLeftOperandConversionRules() + { + LegacyNumbersMatrix.VerifyPrimitiveExtremaMatrix("Numbers.Min", Numbers.Min, LegacyNumbersOperator.Min); + } + } +} diff --git a/Evaluant.Calculator.Tests/NumericTestCase.cs b/Evaluant.Calculator.Tests/NumericTestCase.cs new file mode 100644 index 0000000..1d2f26c --- /dev/null +++ b/Evaluant.Calculator.Tests/NumericTestCase.cs @@ -0,0 +1,108 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NCalc.Tests +{ + internal delegate object NumericOperation(object left, object right); + + internal sealed class NumericResultCase + { + public NumericResultCase(string name, NumericOperation operation, object left, object right, object expectedValue, Type expectedType) + { + Name = name; + Operation = operation; + Left = left; + Right = right; + ExpectedValue = expectedValue; + ExpectedType = expectedType; + } + + private string Name { get; set; } + private NumericOperation Operation { get; set; } + private object Left { get; set; } + private object Right { get; set; } + private object ExpectedValue { get; set; } + private Type ExpectedType { get; set; } + + public void Verify() + { + object actualValue = Operation(Left, Right); + + if (ExpectedType == null) + { + Assert.IsNull(actualValue, Name + " should return null"); + return; + } + + Assert.IsNotNull(actualValue, Name + " should not return null"); + Assert.AreEqual(ExpectedType, actualValue.GetType(), Name + " result type"); + Assert.AreEqual(ExpectedValue, actualValue, Name + " result value"); + } + } + + internal sealed class NumericExceptionCase + { + public NumericExceptionCase(string name, NumericOperation operation, object left, object right, Type expectedExceptionType, string expectedMessageFragment) + { + Name = name; + Operation = operation; + Left = left; + Right = right; + ExpectedExceptionType = expectedExceptionType; + ExpectedMessageFragment = expectedMessageFragment; + } + + private string Name { get; set; } + private NumericOperation Operation { get; set; } + private object Left { get; set; } + private object Right { get; set; } + private Type ExpectedExceptionType { get; set; } + private string ExpectedMessageFragment { get; set; } + + public void Verify() + { + try + { + Operation(Left, Right); + Assert.Fail(Name + " should throw " + ExpectedExceptionType.Name); + } + catch (Exception exception) + { + Assert.AreEqual(ExpectedExceptionType, exception.GetType(), Name + " exception type"); + + if (!String.IsNullOrEmpty(ExpectedMessageFragment)) + { + StringAssert.Contains( + exception.Message, + ExpectedMessageFragment, + Name + " message should contain '" + ExpectedMessageFragment + "' but was '" + exception.Message + "'"); + } + } + } + } + + internal sealed class ExpressionResultCase + { + public ExpressionResultCase(string name, string expression, object expectedValue, Type expectedType) + { + Name = name; + Expression = expression; + ExpectedValue = expectedValue; + ExpectedType = expectedType; + } + + private string Name { get; set; } + private string Expression { get; set; } + private object ExpectedValue { get; set; } + private Type ExpectedType { get; set; } + + public void Verify() + { + object actualValue = new Expression(Expression).Evaluate(); + + Assert.IsNotNull(actualValue, Name + " should not return null"); + Assert.AreEqual(ExpectedType, actualValue.GetType(), Name + " result type"); + Assert.AreEqual(ExpectedValue, actualValue, Name + " result value"); + } + } +} diff --git a/Evaluant.Calculator.Tests/ParserSyntaxTests.cs b/Evaluant.Calculator.Tests/ParserSyntaxTests.cs new file mode 100644 index 0000000..993ff9d --- /dev/null +++ b/Evaluant.Calculator.Tests/ParserSyntaxTests.cs @@ -0,0 +1,120 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NCalc.Tests +{ + /// + /// Characterizes parser grammar and operator behavior at the public API boundary. + /// + [TestClass] + public class ParserSyntaxTests + { + [TestMethod] + public void Literals_WithSupportedSyntax_ParseToExpectedValues() + { + object[,] cases = + { + { "123", 123 }, + { "40000000000", 40000000000F }, + { "123.456", 123.456D }, + { "1.22e1", 12.2D }, + { "1e-2", 0.01D }, + { "true", true }, + { "false", false }, + { "'text'", "text" }, + { @"'\u0048\u0065\u006C\u006C\u006F'", "Hello" }, + { @"'line\nbreak'", "line\nbreak" }, + { "#01/01/2001#", new DateTime(2001, 1, 1) } + }; + + for (int i = 0; i < cases.GetLength(0); i++) + { + Assert.AreEqual(cases[i, 1], TestSupport.Evaluate((string)cases[i, 0]), (string)cases[i, 0]); + } + } + + [TestMethod] + public void Identifiers_WithPlainAndBracketedNames_ReadParameterValues() + { + var expression = new Expression("plain + [customer age] + [_under123]"); + expression.Parameters["plain"] = 1; + expression.Parameters["customer age"] = 2; + expression.Parameters["_under123"] = 3; + + Assert.AreEqual(6, expression.Evaluate()); + } + + [TestMethod] + public void Parser_WithMalformedInputs_ReportsSyntaxErrorsBeforeEvaluation() + { + string[] cases = + { + "(", + "()", + "1 +", + "'unterminated", + "if(true,,1)" + }; + + for (int i = 0; i < cases.Length; i++) + { + var expression = new Expression(cases[i]); + Assert.IsTrue(expression.HasErrors(), cases[i]); + Assert.IsNotNull(expression.Error, cases[i]); + } + } + + [TestMethod] + public void Operators_WithPrecedenceAndAssociativity_EvaluateCurrentGrammarBehavior() + { + object[,] cases = + { + { "2 + 3 * 4", 14 }, + { "(2 + 3) * 4", 20 }, + { "18 / 2 / 2 * 3", 13.5D }, + { "1 + 2 + 3 * 4 / 2", 9D }, + { "2 << 1 + 1", 8 }, + { "8 >> 1 + 1", 2 }, + { "1 | 2 & 3", 3 }, + { "1 ^ 3 & 1", 0 }, + { "~1", -2 }, + { "-1 + 2", 1 }, + { "not false and true", true }, + { "false || true && false", false } + }; + + for (int i = 0; i < cases.GetLength(0); i++) + { + Assert.AreEqual(cases[i, 1], TestSupport.Evaluate((string)cases[i, 0]), (string)cases[i, 0]); + } + } + + [TestMethod] + public void TernaryExpression_WithNestedParenthesizedTernary_EvaluatesExpectedBranch() + { + Assert.AreEqual(2, TestSupport.Evaluate("false ? 1 : (true ? 2 : 3)")); + Assert.AreEqual(1, TestSupport.Evaluate("true ? 1 : (true ? 2 : 3)")); + } + + [TestMethod] + public void ComparisonOperators_WithMixedNumericValues_EvaluateExpectedBooleans() + { + object[,] cases = + { + { "1 = 1.0", true }, + { "1 == 1.0", true }, + { "1 != 1.0", false }, + { "1 <> 2", true }, + { "2 > 1", true }, + { "2 >= 2", true }, + { "1 < 2", true }, + { "1 <= 1", true } + }; + + for (int i = 0; i < cases.GetLength(0); i++) + { + Assert.AreEqual(cases[i, 1], TestSupport.Evaluate((string)cases[i, 0]), (string)cases[i, 0]); + } + } + } +} diff --git a/Evaluant.Calculator.Tests/Properties/AssemblyInfo.cs b/Evaluant.Calculator.Tests/Properties/AssemblyInfo.cs deleted file mode 100644 index 9bf4bd1..0000000 --- a/Evaluant.Calculator.Tests/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("Evaluant.Calculator.Tes.20")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("Evaluant.Calculator.Tes.20")] -[assembly: AssemblyCopyright("Copyright © 2007")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("cd623ffb-0b37-4cc1-9df6-8d91160d8021")] - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Revision and Build Numbers -// by using the '*' as shown below: -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/Evaluant.Calculator.Tests/SerializationTests.cs b/Evaluant.Calculator.Tests/SerializationTests.cs new file mode 100644 index 0000000..09f58bf --- /dev/null +++ b/Evaluant.Calculator.Tests/SerializationTests.cs @@ -0,0 +1,103 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NCalc.Domain; + +namespace NCalc.Tests +{ + /// + /// Characterizes domain expression serialization through ToString(). + /// + [TestClass] + public class SerializationTests + { + [TestMethod] + public void ToString_WithBinaryOperators_SerializesCurrentOperatorTokens() + { + object[,] cases = + { + { BinaryExpressionType.And, "True and False" }, + { BinaryExpressionType.Or, "True or False" }, + { BinaryExpressionType.Equal, "1 = 2" }, + { BinaryExpressionType.NotEqual, "1 != 2" }, + { BinaryExpressionType.Lesser, "1 < 2" }, + { BinaryExpressionType.LesserOrEqual, "1 <= 2" }, + { BinaryExpressionType.Greater, "1 > 2" }, + { BinaryExpressionType.GreaterOrEqual, "1 >= 2" }, + { BinaryExpressionType.Plus, "1 + 2" }, + { BinaryExpressionType.Minus, "1 - 2" }, + { BinaryExpressionType.Times, "1 * 2" }, + { BinaryExpressionType.Div, "1 / 2" }, + { BinaryExpressionType.Modulo, "1 % 2" }, + { BinaryExpressionType.BitwiseAnd, "1 & 2" }, + { BinaryExpressionType.BitwiseOr, "1 | 2" }, + { BinaryExpressionType.BitwiseXOr, "1 ~ 2" }, + { BinaryExpressionType.LeftShift, "1 << 2" }, + { BinaryExpressionType.RightShift, "1 >> 2" } + }; + + for (int i = 0; i < cases.GetLength(0); i++) + { + LogicalExpression left = IsBooleanOperator((BinaryExpressionType)cases[i, 0]) + ? (LogicalExpression)new ValueExpression(true) + : new ValueExpression(1); + LogicalExpression right = IsBooleanOperator((BinaryExpressionType)cases[i, 0]) + ? (LogicalExpression)new ValueExpression(false) + : new ValueExpression(2); + + Assert.AreEqual( + cases[i, 1], + new BinaryExpression((BinaryExpressionType)cases[i, 0], left, right).ToString()); + } + } + + [TestMethod] + public void ToString_WithUnaryOperators_SerializesCurrentOperatorTokens() + { + Assert.AreEqual("!True", new UnaryExpression(UnaryExpressionType.Not, new ValueExpression(true)).ToString()); + Assert.AreEqual("-1", new UnaryExpression(UnaryExpressionType.Negate, new ValueExpression(1)).ToString()); + Assert.AreEqual("~1", new UnaryExpression(UnaryExpressionType.BitwiseNot, new ValueExpression(1)).ToString()); + } + + [TestMethod] + public void ToString_WithTernaryExpression_SerializesBranches() + { + var expression = new TernaryExpression( + new ValueExpression(true), + new ValueExpression(1), + new ValueExpression(0)); + + Assert.AreEqual("True ? 1 : 0", expression.ToString()); + } + + [TestMethod] + public void ToString_WithFunctionIdentifierAndValues_SerializesFunctionCall() + { + var expression = new Function( + new Identifier("Mix"), + new LogicalExpression[] + { + new Identifier("name"), + new BinaryExpression(BinaryExpressionType.Plus, new ValueExpression(1), new ValueExpression(2)), + new ValueExpression(new DateTime(2009, 1, 1)) + }); + + Assert.AreEqual("Mix([name], 1 + 2, #" + new DateTime(2009, 1, 1) + "#)", expression.ToString()); + } + + [TestMethod] + public void FluentDomainMethods_WithValueOperands_CreateSerializableBinaryExpressions() + { + Assert.AreEqual("1 + 2", new ValueExpression(1).Plus(2).ToString()); + Assert.AreEqual("1 - 2", new ValueExpression(1).Minus(2).ToString()); + Assert.AreEqual("1 * 2", new ValueExpression(1).Mult(2).ToString()); + Assert.AreEqual("1 / 2", new ValueExpression(1).DividedBy(2).ToString()); + Assert.AreEqual("1 << 2", new ValueExpression(1).LeftShift(2).ToString()); + Assert.AreEqual("1 >> 2", new ValueExpression(1).RightShift(2).ToString()); + } + + private static bool IsBooleanOperator(BinaryExpressionType type) + { + return type == BinaryExpressionType.And || type == BinaryExpressionType.Or; + } + } +} diff --git a/Evaluant.Calculator.Tests/TestSupport.cs b/Evaluant.Calculator.Tests/TestSupport.cs new file mode 100644 index 0000000..0b27f5e --- /dev/null +++ b/Evaluant.Calculator.Tests/TestSupport.cs @@ -0,0 +1,41 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NCalc.Tests +{ + /// + /// Provides small compatibility assertions for characterization tests. + /// + public static class TestSupport + { + public static object Evaluate(string expression) + { + return new Expression(expression).Evaluate(); + } + + public static TException Throws(Action action) + where TException : Exception + { + try + { + action(); + } + catch (TException exception) + { + return exception; + } + + Assert.Fail("Expected exception of type " + typeof(TException).FullName + "."); + return null; + } + + public static void StringContains(string text, string expectedText) + { + Assert.IsNotNull(text); + StringAssert.Contains( + text, + expectedText, + "Expected <" + text + "> to contain <" + expectedText + ">."); + } + } +} diff --git a/Evaluant.Calculator/AntlrCompatibility.cs b/Evaluant.Calculator/AntlrCompatibility.cs new file mode 100644 index 0000000..3697c6d --- /dev/null +++ b/Evaluant.Calculator/AntlrCompatibility.cs @@ -0,0 +1,514 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.IO; +using System.Text; +using Antlr.Runtime.Tree; + +namespace Antlr.Runtime +{ + /// + /// Preserves ANTLR runtime type names that were embedded in the legacy NCalc + /// package and were renamed or removed by the newer ANTLR runtime package. + /// + public class RuleReturnScope : ParserRuleReturnScope + { + public new virtual object Start + { + get { return base.Start; } + set { base.Start = (IToken)value; } + } + + public new virtual object Stop + { + get { return base.Stop; } + set { base.Stop = (IToken)value; } + } + + public virtual object Tree { get; set; } + + public virtual object Template { get { return null; } } + } + + public class ParserRuleReturnScope : RuleReturnScope + { + } + + public static class Token + { + public const int EOR_TOKEN_TYPE = TokenTypes.EndOfRule; + public const int DOWN = TokenTypes.Down; + public const int UP = TokenTypes.Up; + public const int INVALID_TOKEN_TYPE = TokenTypes.Invalid; + public const int DEFAULT_CHANNEL = TokenChannels.Default; + public const int HIDDEN_CHANNEL = TokenChannels.Hidden; + + public static readonly int MIN_TOKEN_TYPE = TokenTypes.Min; + public static readonly int EOF = TokenTypes.EndOfFile; + public static readonly IToken EOF_TOKEN = new CommonToken(EOF); + public static readonly IToken INVALID_TOKEN = new CommonToken(INVALID_TOKEN_TYPE); + public static readonly IToken SKIP_TOKEN = Tokens.Skip; + } + + public class CommonErrorNode : Tree.CommonErrorNode + { + public CommonErrorNode(ITokenStream input, IToken start, IToken stop, RecognitionException e) + : base(input, start, stop, e) + { + } + } + + public delegate void SynPredPointer(); + +#if NETSTANDARD2_0 + public class ANTLRFileStream : ANTLRStringStream + { + protected string fileName; + + protected ANTLRFileStream() + { + } + + public ANTLRFileStream(string fileName) + { + this.fileName = fileName; + Load(fileName, null); + } + + public ANTLRFileStream(string fileName, Encoding encoding) + { + this.fileName = fileName; + Load(fileName, encoding); + } + + public override string SourceName { get { return fileName; } } + + public virtual void Load(string fileName, Encoding encoding) + { + string text = encoding == null ? File.ReadAllText(fileName) : File.ReadAllText(fileName, encoding); + + data = text.ToCharArray(); + n = data.Length; + p = 0; + Line = 1; + CharPositionInLine = 0; + } + } +#endif +} + +namespace Antlr.Runtime.Collections +{ + public class CollectionUtils + { + public static string DictionaryToString(IDictionary dict) + { + if (dict == null) + { + return "null"; + } + + List entries = new List(); + foreach (DictionaryEntry entry in dict) + { + entries.Add(entry.Key + "=" + entry.Value); + } + + return "[" + string.Join(", ", entries.ToArray()) + "]"; + } + + public static string ListToString(IList coll) + { + if (coll == null) + { + return "null"; + } + + List entries = new List(); + foreach (object item in coll) + { + entries.Add(Convert.ToString(item)); + } + + return "[" + string.Join(", ", entries.ToArray()) + "]"; + } + } + + public sealed class HashList : IDictionary + { + private readonly OrderedDictionary values; + + public HashList() + : this(0) + { + } + + public HashList(int capacity) + { + values = new OrderedDictionary(capacity); + } + + public object this[object key] + { + get { return values[key]; } + set { values[key] = value; } + } + + public ICollection Keys { get { return values.Keys; } } + + public ICollection Values { get { return values.Values; } } + + public bool IsReadOnly { get { return values.IsReadOnly; } } + + public bool IsFixedSize { get { return ((IDictionary)values).IsFixedSize; } } + + public int Count { get { return values.Count; } } + + public object SyncRoot { get { return ((ICollection)values).SyncRoot; } } + + public bool IsSynchronized { get { return ((ICollection)values).IsSynchronized; } } + + public void Add(object key, object value) + { + values.Add(key, value); + } + + public void Clear() + { + values.Clear(); + } + + public bool Contains(object key) + { + return values.Contains(key); + } + + public IDictionaryEnumerator GetEnumerator() + { + return values.GetEnumerator(); + } + + public void Remove(object key) + { + values.Remove(key); + } + + public void CopyTo(Array array, int index) + { + ((ICollection)values).CopyTo(array, index); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return values.GetEnumerator(); + } + } + + public class StackList : List + { + public void Push(object item) + { + Add(item); + } + + public object Pop() + { + object item = Peek(); + RemoveAt(Count - 1); + return item; + } + + public object Peek() + { + if (Count == 0) + { + throw new InvalidOperationException("Stack is empty."); + } + + return this[Count - 1]; + } + } +} + +namespace Antlr.Runtime.Misc +{ + public class ErrorManager + { + public static void InternalError(object error, Exception e) + { + throw new InvalidOperationException(Convert.ToString(error), e); + } + + public static void InternalError(object error) + { + throw new InvalidOperationException(Convert.ToString(error)); + } + + public static void Error(object arg) + { + throw new InvalidOperationException(Convert.ToString(arg)); + } + } + + public class Stats + { + public static double Avg(int[] values) + { + return values.Length == 0 ? 0 : (double)Sum(values) / values.Length; + } + + public static int Max(int[] values) + { + if (values.Length == 0) + { + return 0; + } + + int result = values[0]; + foreach (int value in values) + { + result = Math.Max(result, value); + } + + return result; + } + + public static int Min(int[] values) + { + if (values.Length == 0) + { + return 0; + } + + int result = values[0]; + foreach (int value in values) + { + result = Math.Min(result, value); + } + + return result; + } + + public static int Sum(int[] values) + { + int result = 0; + foreach (int value in values) + { + result += value; + } + + return result; + } + + public static double Stddev(int[] values) + { + if (values.Length <= 1) + { + return 0; + } + + double average = Avg(values); + double sumOfSquares = 0; + foreach (int value in values) + { + double difference = value - average; + sumOfSquares += difference * difference; + } + + return Math.Sqrt(sumOfSquares / (values.Length - 1)); + } + + public static string GetAbsoluteFileName(string filename) + { + return Path.GetFullPath(filename); + } + + public static void WriteReport(string filename, string data) + { + File.WriteAllText(GetAbsoluteFileName(filename), data); + } + } +} + +namespace Antlr.Runtime.Tree +{ + public sealed class Tree + { + public static readonly ITree INVALID_NODE = new CommonTree(Antlr.Runtime.Token.INVALID_TOKEN); + } + + public class TreeRuleReturnScope : Antlr.Runtime.RuleReturnScope + { + public override object Start { get; set; } + + public override object Stop { get; set; } + } + + public class UnBufferedTreeNodeStream : BufferedTreeNodeStream + { + private readonly List legacyTraversal = new List(); + private int legacyTraversalIndex = -1; + + public UnBufferedTreeNodeStream(object tree) + : base(tree) + { + AddTraversalNodes(tree); + } + + public UnBufferedTreeNodeStream(ITreeAdaptor adaptor, object tree) + : base(adaptor, tree) + { + AddTraversalNodes(tree); + } + + public bool HasUniqueNavigationNodes + { + get { return UniqueNavigationNodes; } + set { UniqueNavigationNodes = value; } + } + + public object Current { get; private set; } + + public object Get(int index) + { + throw new NotSupportedException("stream is unbuffered"); + } + + public bool MoveNext() + { + if (legacyTraversalIndex + 1 >= legacyTraversal.Count) + { + Current = null; + return false; + } + + legacyTraversalIndex++; + Current = legacyTraversal[legacyTraversalIndex]; + return true; + } + + public override void Reset() + { + base.Reset(); + legacyTraversalIndex = -1; + Current = null; + } + + public int Size() + { + return CountStreamItems(root); + } + + public new int Index() + { + return base.Index; + } + + private void AddTraversalNodes(object node) + { + if (node == null) + { + return; + } + + legacyTraversal.Add(node); + + ITree tree = node as ITree; + if (tree == null) + { + return; + } + + for (int childIndex = 0; childIndex < tree.ChildCount; childIndex++) + { + AddTraversalNodes(tree.GetChild(childIndex)); + } + } + + private static int CountStreamItems(object node) + { + if (node == null) + { + return 0; + } + + ITree tree = node as ITree; + if (tree == null) + { + return 1; + } + + int count = 1; + if (tree.ChildCount > 0) + { + count += 2; + } + + for (int childIndex = 0; childIndex < tree.ChildCount; childIndex++) + { + count += CountStreamItems(tree.GetChild(childIndex)); + } + + return count; + } + } + + public abstract class RewriteRuleElementStream : RewriteRuleElementStream + { + public RewriteRuleElementStream(ITreeAdaptor adaptor, string elementDescription) + : base(adaptor, elementDescription) + { + } + + public RewriteRuleElementStream(ITreeAdaptor adaptor, string elementDescription, T oneElement) + : base(adaptor, elementDescription, oneElement) + { + } + + public RewriteRuleElementStream(ITreeAdaptor adaptor, string elementDescription, IList elements) + : base(adaptor, elementDescription, new List(elements)) + { + } + + public RewriteRuleElementStream(ITreeAdaptor adaptor, string elementDescription, IList elements) + : base(adaptor, elementDescription, elements) + { + } + + public void Add(T element) + { + base.Add(element); + } + + public new bool HasNext() + { + return base.HasNext; + } + + public int Size() + { + return Count; + } + + protected object _Next() + { + return NextCore(); + } + + protected override object Dup(object element) + { + return element; + } + + protected virtual object ToTree(T element) + { + return element; + } + + protected override object ToTree(object element) + { + return ToTree((T)element); + } + } +} diff --git a/Evaluant.Calculator/ILRepack.targets b/Evaluant.Calculator/ILRepack.targets new file mode 100644 index 0000000..108401a --- /dev/null +++ b/Evaluant.Calculator/ILRepack.targets @@ -0,0 +1,35 @@ + + + + $(NuGetPackageRoot)antlr3.runtime/3.5.1/lib/net20/Antlr3.Runtime.dll + $(NuGetPackageRoot)antlr3.runtime/3.5.1/lib/netstandard1.1/Antlr3.Runtime.dll + $(MSBuildThisFileDirectory)../build/NCalc.BuildTools/NCalc.BuildTools.csproj + $(MSBuildThisFileDirectory)../build/NCalc.BuildTools/bin/Release/net10.0/NCalc.BuildTools.dll + + + + + + + + + + + + + + + + + + + + + diff --git a/Evaluant.Calculator/NCalc.csproj b/Evaluant.Calculator/NCalc.csproj index ae67ff7..e5f7af0 100644 --- a/Evaluant.Calculator/NCalc.csproj +++ b/Evaluant.Calculator/NCalc.csproj @@ -1,113 +1,24 @@ - - - - Debug - AnyCPU - 9.0.30729 - 2.0 - {5F014003-50D8-49E0-8AFE-91D38DCCC97C} - Library - Properties - NCalc - NCalc - - - 3.5 - - - v3.5 - publish\ - true - Disk - false - Foreground - 7 - Days - false - false - true - 0 - 1.0.0.%2a - false - false - true - - - - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - true - AllRules.ruleset - - - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - AllRules.ruleset - - - - False - ..\Binaries\Antlr3.Runtime.dll - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - False - .NET Framework 3.5 SP1 Client Profile - false - - - False - .NET Framework 3.5 SP1 - true - - - False - Windows Installer 3.1 - true - - - - - \ No newline at end of file + + + net35;netstandard2.0 + NCalc + NCalc + false + 2.0.0 + NCalc + false + Sebastien Ros + NCalc is a mathematical expressions evaluator in .NET. + true + $(NCalcStrongNameKeyFile) + + + + + + + + + + + diff --git a/Evaluant.Calculator/NCalcParser.cs b/Evaluant.Calculator/NCalcParser.cs index 49dd32b..0a5e9de 100644 --- a/Evaluant.Calculator/NCalcParser.cs +++ b/Evaluant.Calculator/NCalcParser.cs @@ -185,11 +185,12 @@ public override void DisplayRecognitionError(String[] tokenNames, RecognitionExc partial void LeaveRule(string ruleName, int ruleIndex); #region Rules - public class ncalcExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class ncalcExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public new CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_ncalcExpression(); @@ -239,14 +240,14 @@ public NCalcParser.ncalcExpression_return ncalcExpression() retval.Stop = (IToken)input.LT(-1); retval.Tree = (CommonTree)adaptor.RulePostProcessing(root_0); - adaptor.SetTokenBoundaries(retval.Tree, retval.Start, retval.Stop); + adaptor.SetTokenBoundaries(retval.Tree, (IToken)retval.Start, (IToken)retval.Stop); } catch (RecognitionException re) { ReportError(re); Recover(input,re); - retval.Tree = (CommonTree)adaptor.ErrorNode(input, retval.Start, input.LT(-1), re); + retval.Tree = (CommonTree)adaptor.ErrorNode(input, (IToken)retval.Start, input.LT(-1), re); } finally @@ -262,11 +263,12 @@ public NCalcParser.ncalcExpression_return ncalcExpression() } // $ANTLR end "ncalcExpression" - public class logicalExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class logicalExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public new CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_logicalExpression(); @@ -367,14 +369,14 @@ private NCalcParser.logicalExpression_return logicalExpression() retval.Stop = (IToken)input.LT(-1); retval.Tree = (CommonTree)adaptor.RulePostProcessing(root_0); - adaptor.SetTokenBoundaries(retval.Tree, retval.Start, retval.Stop); + adaptor.SetTokenBoundaries(retval.Tree, (IToken)retval.Start, (IToken)retval.Stop); } catch (RecognitionException re) { ReportError(re); Recover(input,re); - retval.Tree = (CommonTree)adaptor.ErrorNode(input, retval.Start, input.LT(-1), re); + retval.Tree = (CommonTree)adaptor.ErrorNode(input, (IToken)retval.Start, input.LT(-1), re); } finally @@ -390,11 +392,12 @@ private NCalcParser.logicalExpression_return logicalExpression() } // $ANTLR end "logicalExpression" - public class conditionalExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class conditionalExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public new CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_conditionalExpression(); @@ -507,14 +510,14 @@ private NCalcParser.conditionalExpression_return conditionalExpression() retval.Stop = (IToken)input.LT(-1); retval.Tree = (CommonTree)adaptor.RulePostProcessing(root_0); - adaptor.SetTokenBoundaries(retval.Tree, retval.Start, retval.Stop); + adaptor.SetTokenBoundaries(retval.Tree, (IToken)retval.Start, (IToken)retval.Stop); } catch (RecognitionException re) { ReportError(re); Recover(input,re); - retval.Tree = (CommonTree)adaptor.ErrorNode(input, retval.Start, input.LT(-1), re); + retval.Tree = (CommonTree)adaptor.ErrorNode(input, (IToken)retval.Start, input.LT(-1), re); } finally @@ -530,11 +533,12 @@ private NCalcParser.conditionalExpression_return conditionalExpression() } // $ANTLR end "conditionalExpression" - public class booleanAndExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class booleanAndExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public new CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_booleanAndExpression(); @@ -647,14 +651,14 @@ private NCalcParser.booleanAndExpression_return booleanAndExpression() retval.Stop = (IToken)input.LT(-1); retval.Tree = (CommonTree)adaptor.RulePostProcessing(root_0); - adaptor.SetTokenBoundaries(retval.Tree, retval.Start, retval.Stop); + adaptor.SetTokenBoundaries(retval.Tree, (IToken)retval.Start, (IToken)retval.Stop); } catch (RecognitionException re) { ReportError(re); Recover(input,re); - retval.Tree = (CommonTree)adaptor.ErrorNode(input, retval.Start, input.LT(-1), re); + retval.Tree = (CommonTree)adaptor.ErrorNode(input, (IToken)retval.Start, input.LT(-1), re); } finally @@ -670,11 +674,12 @@ private NCalcParser.booleanAndExpression_return booleanAndExpression() } // $ANTLR end "booleanAndExpression" - public class bitwiseOrExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class bitwiseOrExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public new CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_bitwiseOrExpression(); @@ -777,14 +782,14 @@ private NCalcParser.bitwiseOrExpression_return bitwiseOrExpression() retval.Stop = (IToken)input.LT(-1); retval.Tree = (CommonTree)adaptor.RulePostProcessing(root_0); - adaptor.SetTokenBoundaries(retval.Tree, retval.Start, retval.Stop); + adaptor.SetTokenBoundaries(retval.Tree, (IToken)retval.Start, (IToken)retval.Stop); } catch (RecognitionException re) { ReportError(re); Recover(input,re); - retval.Tree = (CommonTree)adaptor.ErrorNode(input, retval.Start, input.LT(-1), re); + retval.Tree = (CommonTree)adaptor.ErrorNode(input, (IToken)retval.Start, input.LT(-1), re); } finally @@ -800,11 +805,12 @@ private NCalcParser.bitwiseOrExpression_return bitwiseOrExpression() } // $ANTLR end "bitwiseOrExpression" - public class bitwiseXOrExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class bitwiseXOrExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public new CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_bitwiseXOrExpression(); @@ -907,14 +913,14 @@ private NCalcParser.bitwiseXOrExpression_return bitwiseXOrExpression() retval.Stop = (IToken)input.LT(-1); retval.Tree = (CommonTree)adaptor.RulePostProcessing(root_0); - adaptor.SetTokenBoundaries(retval.Tree, retval.Start, retval.Stop); + adaptor.SetTokenBoundaries(retval.Tree, (IToken)retval.Start, (IToken)retval.Stop); } catch (RecognitionException re) { ReportError(re); Recover(input,re); - retval.Tree = (CommonTree)adaptor.ErrorNode(input, retval.Start, input.LT(-1), re); + retval.Tree = (CommonTree)adaptor.ErrorNode(input, (IToken)retval.Start, input.LT(-1), re); } finally @@ -930,11 +936,12 @@ private NCalcParser.bitwiseXOrExpression_return bitwiseXOrExpression() } // $ANTLR end "bitwiseXOrExpression" - public class bitwiseAndExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class bitwiseAndExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public new CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_bitwiseAndExpression(); @@ -1037,14 +1044,14 @@ private NCalcParser.bitwiseAndExpression_return bitwiseAndExpression() retval.Stop = (IToken)input.LT(-1); retval.Tree = (CommonTree)adaptor.RulePostProcessing(root_0); - adaptor.SetTokenBoundaries(retval.Tree, retval.Start, retval.Stop); + adaptor.SetTokenBoundaries(retval.Tree, (IToken)retval.Start, (IToken)retval.Stop); } catch (RecognitionException re) { ReportError(re); Recover(input,re); - retval.Tree = (CommonTree)adaptor.ErrorNode(input, retval.Start, input.LT(-1), re); + retval.Tree = (CommonTree)adaptor.ErrorNode(input, (IToken)retval.Start, input.LT(-1), re); } finally @@ -1060,11 +1067,12 @@ private NCalcParser.bitwiseAndExpression_return bitwiseAndExpression() } // $ANTLR end "bitwiseAndExpression" - public class equalityExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class equalityExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public new CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_equalityExpression(); @@ -1239,14 +1247,14 @@ private NCalcParser.equalityExpression_return equalityExpression() retval.Stop = (IToken)input.LT(-1); retval.Tree = (CommonTree)adaptor.RulePostProcessing(root_0); - adaptor.SetTokenBoundaries(retval.Tree, retval.Start, retval.Stop); + adaptor.SetTokenBoundaries(retval.Tree, (IToken)retval.Start, (IToken)retval.Stop); } catch (RecognitionException re) { ReportError(re); Recover(input,re); - retval.Tree = (CommonTree)adaptor.ErrorNode(input, retval.Start, input.LT(-1), re); + retval.Tree = (CommonTree)adaptor.ErrorNode(input, (IToken)retval.Start, input.LT(-1), re); } finally @@ -1262,11 +1270,12 @@ private NCalcParser.equalityExpression_return equalityExpression() } // $ANTLR end "equalityExpression" - public class relationalExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class relationalExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public new CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_relationalExpression(); @@ -1467,14 +1476,14 @@ private NCalcParser.relationalExpression_return relationalExpression() retval.Stop = (IToken)input.LT(-1); retval.Tree = (CommonTree)adaptor.RulePostProcessing(root_0); - adaptor.SetTokenBoundaries(retval.Tree, retval.Start, retval.Stop); + adaptor.SetTokenBoundaries(retval.Tree, (IToken)retval.Start, (IToken)retval.Stop); } catch (RecognitionException re) { ReportError(re); Recover(input,re); - retval.Tree = (CommonTree)adaptor.ErrorNode(input, retval.Start, input.LT(-1), re); + retval.Tree = (CommonTree)adaptor.ErrorNode(input, (IToken)retval.Start, input.LT(-1), re); } finally @@ -1490,11 +1499,12 @@ private NCalcParser.relationalExpression_return relationalExpression() } // $ANTLR end "relationalExpression" - public class shiftExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class shiftExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public new CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_shiftExpression(); @@ -1649,14 +1659,14 @@ private NCalcParser.shiftExpression_return shiftExpression() retval.Stop = (IToken)input.LT(-1); retval.Tree = (CommonTree)adaptor.RulePostProcessing(root_0); - adaptor.SetTokenBoundaries(retval.Tree, retval.Start, retval.Stop); + adaptor.SetTokenBoundaries(retval.Tree, (IToken)retval.Start, (IToken)retval.Stop); } catch (RecognitionException re) { ReportError(re); Recover(input,re); - retval.Tree = (CommonTree)adaptor.ErrorNode(input, retval.Start, input.LT(-1), re); + retval.Tree = (CommonTree)adaptor.ErrorNode(input, (IToken)retval.Start, input.LT(-1), re); } finally @@ -1672,11 +1682,12 @@ private NCalcParser.shiftExpression_return shiftExpression() } // $ANTLR end "shiftExpression" - public class additiveExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class additiveExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public new CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_additiveExpression(); @@ -1831,14 +1842,14 @@ private NCalcParser.additiveExpression_return additiveExpression() retval.Stop = (IToken)input.LT(-1); retval.Tree = (CommonTree)adaptor.RulePostProcessing(root_0); - adaptor.SetTokenBoundaries(retval.Tree, retval.Start, retval.Stop); + adaptor.SetTokenBoundaries(retval.Tree, (IToken)retval.Start, (IToken)retval.Stop); } catch (RecognitionException re) { ReportError(re); Recover(input,re); - retval.Tree = (CommonTree)adaptor.ErrorNode(input, retval.Start, input.LT(-1), re); + retval.Tree = (CommonTree)adaptor.ErrorNode(input, (IToken)retval.Start, input.LT(-1), re); } finally @@ -1854,11 +1865,12 @@ private NCalcParser.additiveExpression_return additiveExpression() } // $ANTLR end "additiveExpression" - public class multiplicativeExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class multiplicativeExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public new CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_multiplicativeExpression(); @@ -2038,14 +2050,14 @@ private NCalcParser.multiplicativeExpression_return multiplicativeExpression() retval.Stop = (IToken)input.LT(-1); retval.Tree = (CommonTree)adaptor.RulePostProcessing(root_0); - adaptor.SetTokenBoundaries(retval.Tree, retval.Start, retval.Stop); + adaptor.SetTokenBoundaries(retval.Tree, (IToken)retval.Start, (IToken)retval.Stop); } catch (RecognitionException re) { ReportError(re); Recover(input,re); - retval.Tree = (CommonTree)adaptor.ErrorNode(input, retval.Start, input.LT(-1), re); + retval.Tree = (CommonTree)adaptor.ErrorNode(input, (IToken)retval.Start, input.LT(-1), re); } finally @@ -2061,11 +2073,12 @@ private NCalcParser.multiplicativeExpression_return multiplicativeExpression() } // $ANTLR end "multiplicativeExpression" - public class unaryExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class unaryExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public new CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_unaryExpression(); @@ -2252,14 +2265,14 @@ private NCalcParser.unaryExpression_return unaryExpression() retval.Stop = (IToken)input.LT(-1); retval.Tree = (CommonTree)adaptor.RulePostProcessing(root_0); - adaptor.SetTokenBoundaries(retval.Tree, retval.Start, retval.Stop); + adaptor.SetTokenBoundaries(retval.Tree, (IToken)retval.Start, (IToken)retval.Stop); } catch (RecognitionException re) { ReportError(re); Recover(input,re); - retval.Tree = (CommonTree)adaptor.ErrorNode(input, retval.Start, input.LT(-1), re); + retval.Tree = (CommonTree)adaptor.ErrorNode(input, (IToken)retval.Start, input.LT(-1), re); } finally @@ -2275,11 +2288,12 @@ private NCalcParser.unaryExpression_return unaryExpression() } // $ANTLR end "unaryExpression" - public class primaryExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class primaryExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public new CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_primaryExpression(); @@ -2449,14 +2463,14 @@ private NCalcParser.primaryExpression_return primaryExpression() retval.Stop = (IToken)input.LT(-1); retval.Tree = (CommonTree)adaptor.RulePostProcessing(root_0); - adaptor.SetTokenBoundaries(retval.Tree, retval.Start, retval.Stop); + adaptor.SetTokenBoundaries(retval.Tree, (IToken)retval.Start, (IToken)retval.Stop); } catch (RecognitionException re) { ReportError(re); Recover(input,re); - retval.Tree = (CommonTree)adaptor.ErrorNode(input, retval.Start, input.LT(-1), re); + retval.Tree = (CommonTree)adaptor.ErrorNode(input, (IToken)retval.Start, input.LT(-1), re); } finally @@ -2472,11 +2486,12 @@ private NCalcParser.primaryExpression_return primaryExpression() } // $ANTLR end "primaryExpression" - public class value_return : ParserRuleReturnScope, IAstRuleReturnScope + public class value_return : ParserRuleReturnScope, IAstRuleReturnScope { public ValueExpression value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public new CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_value(); @@ -2661,14 +2676,14 @@ private NCalcParser.value_return value() retval.Stop = (IToken)input.LT(-1); retval.Tree = (CommonTree)adaptor.RulePostProcessing(root_0); - adaptor.SetTokenBoundaries(retval.Tree, retval.Start, retval.Stop); + adaptor.SetTokenBoundaries(retval.Tree, (IToken)retval.Start, (IToken)retval.Stop); } catch (RecognitionException re) { ReportError(re); Recover(input,re); - retval.Tree = (CommonTree)adaptor.ErrorNode(input, retval.Start, input.LT(-1), re); + retval.Tree = (CommonTree)adaptor.ErrorNode(input, (IToken)retval.Start, input.LT(-1), re); } finally @@ -2684,11 +2699,12 @@ private NCalcParser.value_return value() } // $ANTLR end "value" - public class identifier_return : ParserRuleReturnScope, IAstRuleReturnScope + public class identifier_return : ParserRuleReturnScope, IAstRuleReturnScope { public Identifier value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public new CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_identifier(); @@ -2777,14 +2793,14 @@ private NCalcParser.identifier_return identifier() retval.Stop = (IToken)input.LT(-1); retval.Tree = (CommonTree)adaptor.RulePostProcessing(root_0); - adaptor.SetTokenBoundaries(retval.Tree, retval.Start, retval.Stop); + adaptor.SetTokenBoundaries(retval.Tree, (IToken)retval.Start, (IToken)retval.Stop); } catch (RecognitionException re) { ReportError(re); Recover(input,re); - retval.Tree = (CommonTree)adaptor.ErrorNode(input, retval.Start, input.LT(-1), re); + retval.Tree = (CommonTree)adaptor.ErrorNode(input, (IToken)retval.Start, input.LT(-1), re); } finally @@ -2800,11 +2816,12 @@ private NCalcParser.identifier_return identifier() } // $ANTLR end "identifier" - public class expressionList_return : ParserRuleReturnScope, IAstRuleReturnScope + public class expressionList_return : ParserRuleReturnScope, IAstRuleReturnScope { public List value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public new CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_expressionList(); @@ -2907,14 +2924,14 @@ private NCalcParser.expressionList_return expressionList() retval.Stop = (IToken)input.LT(-1); retval.Tree = (CommonTree)adaptor.RulePostProcessing(root_0); - adaptor.SetTokenBoundaries(retval.Tree, retval.Start, retval.Stop); + adaptor.SetTokenBoundaries(retval.Tree, (IToken)retval.Start, (IToken)retval.Stop); } catch (RecognitionException re) { ReportError(re); Recover(input,re); - retval.Tree = (CommonTree)adaptor.ErrorNode(input, retval.Start, input.LT(-1), re); + retval.Tree = (CommonTree)adaptor.ErrorNode(input, (IToken)retval.Start, input.LT(-1), re); } finally @@ -2930,11 +2947,12 @@ private NCalcParser.expressionList_return expressionList() } // $ANTLR end "expressionList" - public class arguments_return : ParserRuleReturnScope, IAstRuleReturnScope + public class arguments_return : ParserRuleReturnScope, IAstRuleReturnScope { public List value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public new CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_arguments(); @@ -3022,14 +3040,14 @@ private NCalcParser.arguments_return arguments() retval.Stop = (IToken)input.LT(-1); retval.Tree = (CommonTree)adaptor.RulePostProcessing(root_0); - adaptor.SetTokenBoundaries(retval.Tree, retval.Start, retval.Stop); + adaptor.SetTokenBoundaries(retval.Tree, (IToken)retval.Start, (IToken)retval.Stop); } catch (RecognitionException re) { ReportError(re); Recover(input,re); - retval.Tree = (CommonTree)adaptor.ErrorNode(input, retval.Start, input.LT(-1), re); + retval.Tree = (CommonTree)adaptor.ErrorNode(input, (IToken)retval.Start, input.LT(-1), re); } finally diff --git a/Evaluant.Calculator/Properties/AssemblyInfo.cs b/Evaluant.Calculator/Properties/AssemblyInfo.cs index 398a10f..92ccad2 100644 --- a/Evaluant.Calculator/Properties/AssemblyInfo.cs +++ b/Evaluant.Calculator/Properties/AssemblyInfo.cs @@ -13,6 +13,7 @@ [assembly: AssemblyCopyright("Sebastien Ros")] [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")] +[assembly: System.CLSCompliant(false)] // Setting ComVisible to false makes the types in this assembly not visible // to COM components. If you need to access a type in this assembly from @@ -31,5 +32,5 @@ // // You can specify all the values or you can default the Revision and Build Numbers // by using the '*' as shown below: -[assembly: AssemblyVersion("1.3.8.0")] -[assembly: AssemblyFileVersion("1.3.8.0")] +[assembly: AssemblyVersion("2.0.0.0")] +[assembly: AssemblyFileVersion("2.0.0.0")] diff --git a/Grammar/NCalc.g b/Grammar/NCalc.g index 3dc50b7..f81436a 100644 --- a/Grammar/NCalc.g +++ b/Grammar/NCalc.g @@ -183,7 +183,7 @@ multiplicativeExpression returns [LogicalExpression value] @init { BinaryExpressionType type = BinaryExpressionType.Unknown; } - : left=unaryExpression { $value = $left.value); } ( + : left=unaryExpression { $value = $left.value; } ( ( '*' { type = BinaryExpressionType.Times; } | '/' { type = BinaryExpressionType.Div; } | '%' { type = BinaryExpressionType.Modulo; } ) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d15b146 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2011 Sebastien Ros + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NCalc.sln b/NCalc.sln index 5118a1d..bb60763 100644 --- a/NCalc.sln +++ b/NCalc.sln @@ -5,7 +5,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution ProjectSection(SolutionItems) = preProject LocalTestRun.testrunconfig = LocalTestRun.testrunconfig NCalc.20.vsmdi = NCalc.20.vsmdi - NCalc.vsmdi = NCalc.vsmdi EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NCalc", "Evaluant.Calculator\NCalc.csproj", "{5F014003-50D8-49E0-8AFE-91D38DCCC97C}" @@ -14,10 +13,9 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NCalc.Play", "Evaluant.Calc EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NCalc.Tests", "Evaluant.Calculator.Tests\NCalc.Tests.csproj", "{F8E5E800-F842-4F84-A0C6-B3E1E5DB2BB4}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NCalc.FrameworkCompatibility.Tests", "Evaluant.Calculator.FrameworkCompatibility.Tests\NCalc.FrameworkCompatibility.Tests.csproj", "{0A580F51-5792-44A9-8818-C05072105D74}" +EndProject Global - GlobalSection(TestCaseManagementSettings) = postSolution - CategoryFile = NCalc.vsmdi - EndGlobalSection GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU @@ -35,6 +33,10 @@ Global {F8E5E800-F842-4F84-A0C6-B3E1E5DB2BB4}.Debug|Any CPU.Build.0 = Debug|Any CPU {F8E5E800-F842-4F84-A0C6-B3E1E5DB2BB4}.Release|Any CPU.ActiveCfg = Release|Any CPU {F8E5E800-F842-4F84-A0C6-B3E1E5DB2BB4}.Release|Any CPU.Build.0 = Release|Any CPU + {0A580F51-5792-44A9-8818-C05072105D74}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0A580F51-5792-44A9-8818-C05072105D74}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0A580F51-5792-44A9-8818-C05072105D74}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0A580F51-5792-44A9-8818-C05072105D74}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/README.md b/README.md index ef6141b..a760b61 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,93 @@ -> [!WARNING] -> This project is unmaintained and is no longer being actively developed. Please use [NCalc](https://github.com/ncalc/ncalc) instead. +# NCalc + +NCalc is a small expression evaluator for .NET applications. It parses a string expression into an abstract syntax tree and evaluates it with the parameters, functions, and options supplied by the host application. + +It is useful when an application needs user-editable rules, lightweight formulas, configuration-driven calculations, or small bits of business logic without compiling code at runtime. + +## Install + +```sh +dotnet add package NCalc +``` + +NCalc 2.x ships assets for: + +- .NET Standard 2.0 +- .NET Framework 3.5 + +## Quick Example + +```csharp +using NCalc; + +Expression expression = new Expression("2 * ([quantity] + 5)"); +expression.Parameters["quantity"] = 3; + +object result = expression.Evaluate(); +Console.WriteLine(result); // 16 +``` + +Parameters can be supplied directly: + +```csharp +Expression expression = new Expression("[price] * [quantity]"); +expression.Parameters["price"] = 12.5m; +expression.Parameters["quantity"] = 4; + +object total = expression.Evaluate(); +``` + +Functions can be handled lazily by the host application: + +```csharp +Expression expression = new Expression("discount([subtotal])"); +expression.Parameters["subtotal"] = 100m; + +expression.EvaluateFunction += (name, args) => +{ + if (name == "discount") + { + decimal subtotal = Convert.ToDecimal(args.Parameters[0].Evaluate()); + args.Result = subtotal * 0.9m; + } +}; + +object discountedTotal = expression.Evaluate(); +``` + +## Expression Features + +NCalc supports common arithmetic, logical, comparison, bitwise, and conditional expressions, including: + +- arithmetic operators such as `+`, `-`, `*`, `/`, and `%` +- comparison operators such as `=`, `!=`, `<`, `<=`, `>`, and `>=` +- logical operators such as `and`, `or`, and `not` +- ternary-style evaluation through `if(condition, whenTrue, whenFalse)` +- strings, numbers, booleans, dates, and parameters +- custom functions and parameter resolution callbacks + +The evaluator preserves the long-standing NCalc behavior around numeric conversions, culture-sensitive parsing, exception messages, lazy parameter evaluation, and generated parser surface area. Those compatibility points are covered by the test suite because existing applications often rely on them in quiet, surprising ways. + +## Compatibility Notes + +NCalc 2.x is a major release because the strong-name signing key changed. The old NCalc 1.3.8 assembly used public key token `973cde3f1cafed03`; NCalc 2.x uses public key token `f59bbff0fd43f598`. + +Source-compatible consumers can upgrade by rebuilding against the 2.x package. Already-compiled .NET Framework applications and libraries that reference the old strong-name identity must be recompiled; binding redirects cannot redirect one public key token to another. + +The NuGet package keeps the ANTLR runtime surface embedded in `NCalc.dll`, matching the historical package shape. Parser and lexer consumers can continue to use public types such as `NCalcLexer`, `NCalcParser`, `Antlr.Runtime.ParserRuleReturnScope`, and `Antlr.Runtime.Tree.CommonTree` from the package without adding a separate ANTLR dependency. + +## Validation + +The repository includes a local quality gate: + +```sh +scripts/validate.sh +``` + +The gate builds the library, builds the solution, runs the modern test suite with coverage thresholds, runs framework compatibility checks, creates a signed package, checks package compatibility against NCalc 1.3.8, runs SDK package validation, and performs consumer smoke tests. + +Release packaging also runs a Windows .NET Framework consumer smoke before publishing to NuGet. + +## License + +NCalc is released under the MIT License. diff --git a/build/NCalc.BuildTools/NCalc.BuildTools.csproj b/build/NCalc.BuildTools/NCalc.BuildTools.csproj new file mode 100644 index 0000000..f858dcf --- /dev/null +++ b/build/NCalc.BuildTools/NCalc.BuildTools.csproj @@ -0,0 +1,14 @@ + + + net10.0 + NCalc.BuildTools + NCalc.BuildTools + Exe + disable + disable + + + + + + diff --git a/build/NCalc.BuildTools/Program.cs b/build/NCalc.BuildTools/Program.cs new file mode 100644 index 0000000..a302f9c --- /dev/null +++ b/build/NCalc.BuildTools/Program.cs @@ -0,0 +1,169 @@ +using System; +using System.IO; +using System.Linq; +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace NCalc.BuildTools +{ + /// + /// Hosts build-time assembly patching that preserves legacy public surface + /// after third-party assemblies are merged into the NCalc package. + /// + internal static class Program + { + private const string RestoreNet35AntlrFileStreamCommand = "restore-net35-antlr-filestream"; + private const string AntlrFileStreamTypeName = "Antlr.Runtime.ANTLRFileStream"; + + private static int Main(string[] args) + { + try + { + if (args.Length < 2 || args[0] != RestoreNet35AntlrFileStreamCommand) + { + WriteUsage(); + return 2; + } + + string assemblyPath = args[1]; + string strongNameKeyPath = args.Length >= 3 ? args[2] : string.Empty; + + RestoreNet35AntlrFileStreamCompatibility(assemblyPath, strongNameKeyPath); + return 0; + } + catch (Exception exception) + { + Console.Error.WriteLine(exception.Message); + return 1; + } + } + + private static void RestoreNet35AntlrFileStreamCompatibility(string assemblyPath, string strongNameKeyPath) + { + if (!File.Exists(assemblyPath)) + { + throw new FileNotFoundException("Assembly to patch was not found.", assemblyPath); + } + + string symbolsPath = Path.ChangeExtension(assemblyPath, ".pdb"); + bool readSymbols = File.Exists(symbolsPath); + ReaderParameters readerParameters = new ReaderParameters + { + ReadSymbols = readSymbols, + ReadingMode = ReadingMode.Immediate + }; + + string temporaryPath; + using (AssemblyDefinition assembly = AssemblyDefinition.ReadAssembly(assemblyPath, readerParameters)) + { + ModuleDefinition module = assembly.MainModule; + TypeDefinition antlrFileStream = module.Types.FirstOrDefault(type => type.FullName == AntlrFileStreamTypeName); + + if (antlrFileStream == null) + { + throw new InvalidOperationException("Could not find " + AntlrFileStreamTypeName + " in " + assemblyPath + "."); + } + + bool changed = false; + changed |= EnsureProtectedDefaultConstructor(module, antlrFileStream); + + if (!changed) + { + Console.WriteLine("ANTLRFileStream legacy members already present."); + return; + } + + temporaryPath = WriteAssemblyToTemporaryFile(assembly, assemblyPath, strongNameKeyPath, readSymbols); + } + + ReplaceAssemblyWithTemporaryOutput(assemblyPath, temporaryPath, readSymbols); + Console.WriteLine("Restored net35 ANTLRFileStream legacy members."); + } + + private static bool EnsureProtectedDefaultConstructor(ModuleDefinition module, TypeDefinition antlrFileStream) + { + bool hasDefaultConstructor = antlrFileStream.Methods.Any(method => method.IsConstructor && !method.HasParameters); + if (hasDefaultConstructor) + { + return false; + } + + MethodReference baseConstructor = ImportDefaultBaseConstructor(module, antlrFileStream); + MethodDefinition constructor = new MethodDefinition( + ".ctor", + MethodAttributes.Family | MethodAttributes.HideBySig | MethodAttributes.SpecialName | MethodAttributes.RTSpecialName, + module.TypeSystem.Void); + + ILProcessor ilProcessor = constructor.Body.GetILProcessor(); + ilProcessor.Append(ilProcessor.Create(OpCodes.Ldarg_0)); + ilProcessor.Append(ilProcessor.Create(OpCodes.Call, baseConstructor)); + ilProcessor.Append(ilProcessor.Create(OpCodes.Ret)); + + antlrFileStream.Methods.Add(constructor); + return true; + } + + private static MethodReference ImportDefaultBaseConstructor(ModuleDefinition module, TypeDefinition typeDefinition) + { + TypeDefinition baseType = typeDefinition.BaseType.Resolve(); + MethodDefinition baseConstructor = baseType.Methods.FirstOrDefault(method => method.IsConstructor && !method.HasParameters); + + if (baseConstructor == null) + { + throw new InvalidOperationException(typeDefinition.BaseType.FullName + " does not expose a default constructor."); + } + + return module.ImportReference(baseConstructor); + } + + private static string WriteAssemblyToTemporaryFile( + AssemblyDefinition assembly, + string assemblyPath, + string strongNameKeyPath, + bool writeSymbols) + { + string temporaryPath = assemblyPath + ".patched"; + WriterParameters writerParameters = new WriterParameters { WriteSymbols = writeSymbols }; + + if (!string.IsNullOrWhiteSpace(strongNameKeyPath)) + { + writerParameters.StrongNameKeyBlob = File.ReadAllBytes(strongNameKeyPath); + } + + assembly.Write(temporaryPath, writerParameters); + return temporaryPath; + } + + private static void ReplaceAssemblyWithTemporaryOutput(string assemblyPath, string temporaryPath, bool writeSymbols) + { + File.Copy(temporaryPath, assemblyPath, true); + File.Delete(temporaryPath); + + if (!writeSymbols) + { + return; + } + + string originalSymbolsPath = Path.ChangeExtension(assemblyPath, ".pdb"); + string assemblyNameSidecarSymbolsPath = assemblyPath + ".pdb"; + string temporarySymbolsPath = temporaryPath + ".pdb"; + + if (File.Exists(assemblyNameSidecarSymbolsPath)) + { + File.Copy(assemblyNameSidecarSymbolsPath, originalSymbolsPath, true); + File.Delete(assemblyNameSidecarSymbolsPath); + } + + if (File.Exists(temporarySymbolsPath)) + { + File.Copy(temporarySymbolsPath, originalSymbolsPath, true); + File.Delete(temporarySymbolsPath); + } + } + + private static void WriteUsage() + { + Console.Error.WriteLine("Usage: NCalc.BuildTools restore-net35-antlr-filestream [strong-name-key-path]"); + } + } +} diff --git a/docs/modernization-release.md b/docs/modernization-release.md new file mode 100644 index 0000000..adacf3a --- /dev/null +++ b/docs/modernization-release.md @@ -0,0 +1,93 @@ +# Modernization Release Notes + +These notes describe the release infrastructure for the NCalc modernization stack. They assume the SDK-style .NET Standard 2.0 project conversion, modern test-project conversion, expanded characterization coverage, and compatibility gates are part of the release line. + +## Stack Order + +1. Port the core library project to SDK style and `netstandard2.0`. +2. Port the test project to a modern SDK-style test runner. +3. Expand characterization coverage while preserving existing behavior. +4. Add numeric edge-case and API-surface coverage. +5. Add build, package, and release infrastructure. +6. Harden package compatibility for the embedded ANTLR surface and major-version strong-name identity change. + +## Local Validation + +After the earlier modernization PRs are applied, maintainers should be able to run: + +```sh +dotnet restore Evaluant.Calculator/NCalc.csproj +dotnet restore Evaluant.Calculator.Tests/NCalc.Tests.csproj +dotnet restore Evaluant.Calculator.FrameworkCompatibility.Tests/NCalc.FrameworkCompatibility.Tests.csproj +dotnet build Evaluant.Calculator/NCalc.csproj --configuration Release --no-restore +dotnet build Evaluant.Calculator.Tests/NCalc.Tests.csproj --configuration Release --no-restore +dotnet build Evaluant.Calculator.FrameworkCompatibility.Tests/NCalc.FrameworkCompatibility.Tests.csproj --configuration Release --no-restore +dotnet run --project Evaluant.Calculator.FrameworkCompatibility.Tests/NCalc.FrameworkCompatibility.Tests.csproj --configuration Release --framework net10.0 --no-build +mono Evaluant.Calculator.FrameworkCompatibility.Tests/bin/Release/net35/NCalc.FrameworkCompatibility.Tests.exe +dotnet test Evaluant.Calculator.Tests/NCalc.Tests.csproj --configuration Release --no-build +dotnet pack Evaluant.Calculator/NCalc.csproj --configuration Release --no-build --output artifacts/packages /p:ContinuousIntegrationBuild=true /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg +``` + +The pack command emits both `.nupkg` and `.snupkg` files after the SDK-style project conversion. These commands depend on the earlier modernization PRs being applied first. + +The GitHub CI workflow runs restore, build, and test on Ubuntu, macOS, and Windows. A dedicated Ubuntu quality-gates job runs `scripts/validate.sh`, including solution restore/build, signed package creation, package compatibility, API compatibility, coverage, local-feed consumer smoke, and the compatibility harness against both `net35` under Mono and `netstandard2.0` under modern .NET. Windows remains part of the required gate because the modernization must preserve compatibility expectations for existing .NET Framework consumers. + +## Release Quality Gate + +Release infrastructure changes should not be treated as passing until the branch scores at least 95/100 in coordinator or reviewer assessment. The expected evidence is: + +- clean repository state after validation; +- successful workflow YAML parsing or equivalent linting; +- successful local validation for commands available on the current stack; +- explicit documentation of any stack dependency that prevents full validation; +- runtime-source and build-tool changes are documented, compatibility-focused, and covered by validation gates. + +## Package Identity + +The shared package metadata preserves the existing NuGet identity as `NCalc` and points package consumers back to `https://github.com/sheetsync/NCalc`. + +This lane owns shared release metadata in `Directory.Build.props` so the SDK-style project conversion can stay focused on target frameworks and dependencies. The metadata is conditioned to the `NCalc` project and SDK-only package references are guarded with `$(UsingMicrosoftNETSdk)` so the current legacy project files are not forced into PackageReference behavior. + +Packages are configured for deterministic builds, Source Link, repository metadata, and `.snupkg` symbol output once the core project is SDK-style. + +The package declares the MIT license expression and the repository contains a matching `LICENSE` file. This follows the legacy NCalc package's CodePlex license link and the current public NCalc project lineage. + +## Strong-Name Signing + +The old `NCalc` `1.3.8` strong-name private key is unavailable. Its public key token was `973cde3f1cafed03`, so a newly signed modernization package cannot be binary-compatible for already-compiled strong-name consumers. This release line is therefore `2.0.0` or later, uses assembly version `2.0.0.0`, and uses the new public key token `f59bbff0fd43f598`. + +Do not commit private signing keys. Local production-token validation should use a private key outside the repository, with token `f59bbff0fd43f598`: + +```sh +NCalcStrongNameKeyFile=~/.ncalc/signing/NCalc.snk \ +scripts/validate.sh +``` + +If the production key is stored elsewhere, set: + +```sh +NCalcStrongNameKeyFile=/secure/path/NCalc.snk scripts/validate.sh +``` + +For temporary non-release validation with a throwaway key, generate an ignored key and set `NCALC_EXPECTED_PUBLIC_KEY_TOKEN` to the generated token. Do not publish a package from a throwaway key. + +`scripts/validate.sh` rejects tracked key files and also rejects private-key paths inside the repository, even if the file is ignored. Keep the key in `~/.ncalc/signing`, a secure local directory, or the release runner's temp directory. + +GitHub release packaging requires `NCALC_STRONG_NAME_KEY_BASE64`, a base64-encoded `.snk` stored as a repository secret. The release workflow decodes that secret to the runner temp directory and fails if the public key token does not match `f59bbff0fd43f598`. + +For non-release CI, the quality-gates workflow can generate an ephemeral key when the secret is unavailable. That still proves the package is signable, repacked, and source-compatible, while the release workflow remains the authority for the stable production token. + +## Package Validation And Restore Locking + +Package validation is enabled in the release gate against the `NCalc` `1.3.8` baseline. The package compatibility script adds checks that SDK package validation does not cover, including strong-name token, assembly version, package major version, license metadata, embedded ANTLR surface, and parser/lexer consumer smoke for legacy source patterns such as non-generic `ParserRuleReturnScope` and derived `ANTLRFileStream` consumers. + +Restore lock files are still intentionally deferred. Generate and review `packages.lock.json` only after the dependency set has settled; do not check in a hand-written lock file. + +## GitHub Release Flow + +The release workflow builds, tests, packs, uploads the `.nupkg` and `.snupkg` artifacts, runs a Windows .NET Framework package consumer smoke, and publishes them to NuGet.org when either: + +- a GitHub Release is published; or +- the manual workflow is run with `publish` enabled. + +Publishing requires repository secrets named `NUGET_API_KEY` and `NCALC_STRONG_NAME_KEY_BASE64`. diff --git a/docs/quality-gates.md b/docs/quality-gates.md new file mode 100644 index 0000000..55ba8ac --- /dev/null +++ b/docs/quality-gates.md @@ -0,0 +1,146 @@ +# Quality Gates + +These validation gates keep the NCalc modernization stack honest. They run after the SDK-style core port, modern test-project migration, and compatibility hardening have been applied. + +## Passing Standard + +No modernization lane passes below a 95/100 review score. A lane also fails immediately if it breaks a compatibility must-pass item, even if the numeric score would otherwise pass. + +Required gates: + +- The original behavioral tests pass. +- The expanded characterization tests pass. +- A Cobertura coverage report is produced by `dotnet test`. +- The package builds locally with `dotnet pack`. +- The package assemblies are strong-name signed. +- The package declares the MIT NuGet license expression. +- The package does not expose `Antlr3.Runtime` as a NuGet dependency; the ANTLR runtime surface remains embedded in `NCalc.dll`. +- Public API changes require explicit approval and a written compatibility note. +- Framework compatibility tests pass against both the `net35` asset and the `netstandard2.0` asset. +- Consumer smoke tests pass from a local package feed for modern .NET. +- Windows must run a .NET Framework consumer smoke before any public release that claims Framework compatibility. + +## Strong-Name Key Policy + +The `NCalc` `1.3.8` package was signed with public key token `973cde3f1cafed03`. The private key for that token is not available, so the modernization release is a major-version package identity break even when source and runtime behavior are preserved. + +The new major-version key generated for this release line has public key token `f59bbff0fd43f598`. Private key material must never be committed or stored inside the worktree. The repository ignores `.snk`, `.pfx`, `.p12`, and `.pem` files, and `scripts/validate.sh` expects one of these signing paths: + +- local production-token validation: the ignored private key for token `f59bbff0fd43f598` at `~/.ncalc/signing/NCalc.snk`, or `NCalcStrongNameKeyFile=/secure/path/NCalc.snk`; +- local throwaway validation: any ignored `.snk`, plus `NCALC_EXPECTED_PUBLIC_KEY_TOKEN` set to that key's token; +- release CI: a repository secret named `NCALC_STRONG_NAME_KEY_BASE64`, containing the base64-encoded private `.snk`. + +`scripts/validate.sh` rejects tracked signing files and rejects `NCalcStrongNameKeyFile` values that resolve inside the repository, even if those files are ignored by Git. + +Because the public key token changes, already-compiled .NET Framework consumers that reference `NCalc, PublicKeyToken=973cde3f1cafed03` must rebuild against the major-version package. Binding redirects cannot redirect one strong-name public key token to another. The package compatibility gate records this deliberately as a major-version identity break rather than pretending binary compatibility exists. + +## Local Validation + +After stacking on the core port and modern test project, run: + +```sh +scripts/validate.sh +``` + +The script validates the production package and test project directly: + +1. `dotnet restore Evaluant.Calculator/NCalc.csproj` +2. `dotnet restore Evaluant.Calculator.Tests/NCalc.Tests.csproj` +3. `dotnet restore Evaluant.Calculator.FrameworkCompatibility.Tests/NCalc.FrameworkCompatibility.Tests.csproj` +4. `dotnet build Evaluant.Calculator/NCalc.csproj --configuration Release --no-restore` +5. `dotnet build Evaluant.Calculator.Tests/NCalc.Tests.csproj --configuration Release --no-restore` +6. `dotnet build Evaluant.Calculator.FrameworkCompatibility.Tests/NCalc.FrameworkCompatibility.Tests.csproj --configuration Release --no-restore` +7. `dotnet restore NCalc.sln` +8. `dotnet build NCalc.sln --configuration Release --no-restore` +9. Compatibility assertions against the `netstandard2.0` asset using `dotnet run` +10. Compatibility assertions against the `net35` asset using `mono` on Unix-like systems or the `.exe` directly on Windows +11. `dotnet test Evaluant.Calculator.Tests/NCalc.Tests.csproj --configuration Release --no-build --collect:"XPlat Code Coverage"` +12. coverage threshold enforcement, currently line coverage >= 93% and branch coverage >= 91% +13. `dotnet pack Evaluant.Calculator/NCalc.csproj --configuration Release --no-build` +14. package compatibility validation for package version, signing, license metadata, embedded ANTLR, parser source compatibility, and the deliberate major-version identity break +15. SDK package validation with `EnablePackageValidation=true` after hydrating the `NCalc` `1.3.8` baseline package +16. A modern .NET consumer smoke from the packed local package + +Coverage output is written to `artifacts/coverage` and must include `coverage.cobertura.xml`. `scripts/validate.sh` fails if line coverage falls below `93%` or branch coverage falls below `91%`. These thresholds sit below the current measured coverage so they prevent accidental erosion while leaving a small margin for harmless branch-shape changes. + +The Framework compatibility harness intentionally uses a plain console runner rather than MSTest so the same assertions can execute on the `net35` build under Mono and the `netstandard2.0` build under modern .NET. It focuses on culture-sensitive parsing, date and Unicode parsing, decimal/float conversion, unsigned arithmetic quirks, exact legacy exception messages, lazy callbacks, and manual AST evaluation. + +Local validation packs `NCalc` as `2.0.0-qualitygates` by default. This avoids NuGet reusing a globally cached `NCalc` `1.3.8` baseline package during consumer smoke tests and reflects the major-version package identity change. Override with `PACKAGE_VERSION=...` only when the replacement version is deliberate. + +Because NCalc 2.x uses a new strong-name key, local validation and release packaging reject package versions below `2.0.0` by default. + +## API Compatibility + +The validation script first hydrates the NuGet global-packages cache with the approved baseline package, then uses SDK package validation: + +```sh +baseline_root="$(mktemp -d "${TMPDIR:-/tmp}/ncalc-api-baseline.XXXXXX")" +cat >"${baseline_root}/NCalc.ApiCompatBaseline.csproj" <<'XML' + + + net35 + + + + + +XML + +dotnet restore "${baseline_root}/NCalc.ApiCompatBaseline.csproj" \ + --source https://api.nuget.org/v3/index.json + +dotnet pack Evaluant.Calculator/NCalc.csproj \ + --configuration Release \ + --no-build \ + --output artifacts/packages/api-compat \ + /p:ContinuousIntegrationBuild=true \ + /p:PackageVersion=2.0.0-qualitygates \ + /p:EnablePackageValidation=true \ + /p:PackageValidationBaselineVersion=1.3.8 \ + /p:NoWarn=PKV006 +``` + +`PKV006` is suppressed because the legacy `NCalc` `1.3.8` package places `NCalc.dll` directly under `lib/`, which current SDK package validation interprets as `.NETFramework,Version=v0.0`. The warning suppression is only for that unsupported legacy TFM shape; API breaking-change diagnostics must remain hard failures. + +If the public generated ANTLR parser surface makes package validation too noisy, the failure must be reviewed rather than ignored. Approved changes should be documented with: + +- the old public signature, +- the new public signature, +- why compatibility is still acceptable or why a breaking-change version is required. + +## Package Compatibility + +SDK package validation checks public API compatibility, but it does not prove all package identity facts that matter for this modernization. The additional package gate is: + +```sh +scripts/package-compatibility.sh artifacts/packages 2.0.0-qualitygates +``` + +It verifies: + +- the baseline `NCalc` `1.3.8` package has token `973cde3f1cafed03`; +- the current package assemblies are strong-name signed with the configured expected token; +- the current package assemblies use assembly version `2.0.0.0`; +- the current package declares license expression `MIT`; +- `Antlr3.Runtime` is not a package dependency; +- the `net35` and `netstandard2.0` assemblies embed public `Antlr.Runtime.*`, `NCalcLexer`, `NCalcParser`, and parser return-scope types; +- parser/lexer consumers using legacy ANTLR source patterns, including non-generic `ParserRuleReturnScope` and derived `ANTLRFileStream`, can compile and run against the packed `net35` assembly; +- a consumer compiled against the legacy package references the old token, documenting why a new key is a major-version binary identity break. + +## Consumer Smoke + +Modern .NET smoke is automated: + +```sh +scripts/consumer-smoke.sh artifacts/packages 2.0.0-qualitygates +``` + +Windows .NET Framework smoke is automated in the release workflow: + +```powershell +scripts/windows-framework-consumer-smoke.ps1 ` + -PackageSource artifacts/packages ` + -PackageVersion 2.0.0-qualitygates +``` + +The script creates a temporary `net48` console project, restores `NCalc` from the local package feed, builds it, then executes expression and parser/lexer assertions against the package asset. NuGet publishing depends on this Windows job. diff --git a/scripts/consumer-smoke.sh b/scripts/consumer-smoke.sh new file mode 100755 index 0000000..43afd71 --- /dev/null +++ b/scripts/consumer-smoke.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +REPOSITORY_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +readonly REPOSITORY_ROOT +readonly PACKAGE_SOURCE="${1:-${REPOSITORY_ROOT}/artifacts/packages}" +readonly PACKAGE_VERSION="${2:-${PACKAGE_VERSION:-}}" + +if [[ -z "${SMOKE_ROOT:-}" ]]; then + SMOKE_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/ncalc-consumer-smoke.XXXXXX")" +fi + +readonly SMOKE_ROOT +readonly SMOKE_PROJECT="${SMOKE_ROOT}/NCalc.ConsumerSmoke/NCalc.ConsumerSmoke.csproj" +readonly SMOKE_PACKAGES_DIR="${SMOKE_ROOT}/packages" + +main() { + require_command dotnet + require_package_source + create_consumer_project + add_local_package + write_smoke_program + run_smoke_program +} + +require_command() { + local command_name="$1" + + if ! command -v "${command_name}" >/dev/null 2>&1; then + echo "Required command not found: ${command_name}" >&2 + exit 127 + fi +} + +require_package_source() { + if [[ -n "${PACKAGE_VERSION}" ]]; then + if ! find "${PACKAGE_SOURCE}" -maxdepth 1 -name "NCalc.${PACKAGE_VERSION}.nupkg" -print -quit | grep -q .; then + echo "NCalc ${PACKAGE_VERSION} package not found in ${PACKAGE_SOURCE}" >&2 + exit 2 + fi + + return + fi + + if ! find "${PACKAGE_SOURCE}" -maxdepth 1 -name 'NCalc.*.nupkg' -print -quit | grep -q .; then + echo "No .nupkg files found in ${PACKAGE_SOURCE}" >&2 + exit 2 + fi +} + +create_consumer_project() { + dotnet new console \ + --framework net10.0 \ + --name NCalc.ConsumerSmoke \ + --output "${SMOKE_ROOT}/NCalc.ConsumerSmoke" \ + >/dev/null +} + +add_local_package() { + local package_arguments=(NCalc --source "${PACKAGE_SOURCE}" --no-restore) + + if [[ -n "${PACKAGE_VERSION}" ]]; then + package_arguments+=(--version "${PACKAGE_VERSION}") + fi + + dotnet add "${SMOKE_PROJECT}" package "${package_arguments[@]}" >/dev/null + + dotnet restore "${SMOKE_PROJECT}" \ + --source "${PACKAGE_SOURCE}" \ + --source "https://api.nuget.org/v3/index.json" \ + /p:RestorePackagesPath="${SMOKE_PACKAGES_DIR}" \ + >/dev/null +} + +write_smoke_program() { + cat >"${SMOKE_ROOT}/NCalc.ConsumerSmoke/Program.cs" <<'CSHARP' +using System; +using System.IO; +using System.Text; +using Antlr.Runtime; +using Antlr.Runtime.Tree; +using NCalc; + +object result = new Expression("2 * (3 + 5)").Evaluate(); + +if (!Equals(16, result)) +{ + throw new InvalidOperationException($"Unexpected NCalc result: {result}"); +} + +NCalcLexer lexer = new NCalcLexer(new ANTLRStringStream("1 + 2")); +CommonTokenStream tokens = new CommonTokenStream(lexer); +NCalcParser parser = new NCalcParser(tokens); +NCalcParser.ncalcExpression_return parserResult = parser.ncalcExpression(); +CommonTree? tree = parserResult.Tree as CommonTree; + +if (tree is null) +{ + throw new InvalidOperationException("Expected parser to return a CommonTree."); +} + +string filePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".txt"); +try +{ + File.WriteAllText(filePath, "abc", Encoding.UTF8); + ANTLRFileStream fileStream = new ANTLRFileStream(filePath, Encoding.UTF8); + + if (fileStream.SourceName != filePath || fileStream.Count != 3 || fileStream.Substring(0, 3) != "abc") + { + throw new InvalidOperationException("Expected ANTLRFileStream to read a file through the packed NCalc assembly."); + } +} +finally +{ + if (File.Exists(filePath)) + { + File.Delete(filePath); + } +} + +Console.WriteLine($"NCalc consumer smoke passed: {result}"); +CSHARP +} + +run_smoke_program() { + dotnet run --project "${SMOKE_PROJECT}" --configuration Release --no-restore +} + +main "$@" diff --git a/scripts/package-compatibility.sh b/scripts/package-compatibility.sh new file mode 100755 index 0000000..bd98d6a --- /dev/null +++ b/scripts/package-compatibility.sh @@ -0,0 +1,628 @@ +#!/usr/bin/env bash + +# Validates package-level compatibility facts that SDK API compatibility does not +# prove: strong-name identity, embedded ANTLR surface, NuGet dependencies, and +# the deliberate major-version binary identity break from the legacy key. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +REPOSITORY_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +readonly REPOSITORY_ROOT +readonly PACKAGE_SOURCE="${1:-${REPOSITORY_ROOT}/artifacts/packages}" +readonly PACKAGE_VERSION="${2:-${PACKAGE_VERSION:-}}" +readonly BASELINE_VERSION="${API_COMPAT_BASELINE_VERSION:-1.3.8}" +readonly NUGET_PACKAGE_SOURCE="${NUGET_PACKAGE_SOURCE:-https://api.nuget.org/v3/index.json}" +readonly EXPECTED_PUBLIC_KEY_TOKEN="${NCALC_EXPECTED_PUBLIC_KEY_TOKEN:-f59bbff0fd43f598}" +readonly EXPECTED_ASSEMBLY_VERSION="${NCALC_EXPECTED_ASSEMBLY_VERSION:-2.0.0.0}" +readonly EXPECTED_LICENSE_EXPRESSION="${NCALC_EXPECTED_LICENSE_EXPRESSION:-MIT}" +readonly LEGACY_PUBLIC_KEY_TOKEN="${NCALC_LEGACY_PUBLIC_KEY_TOKEN:-973cde3f1cafed03}" +readonly PACKAGE_IDENTITY_MODE="${NCALC_PACKAGE_IDENTITY_MODE:-major}" +readonly MINIMUM_MAJOR_PACKAGE_VERSION="${NCALC_MINIMUM_MAJOR_PACKAGE_VERSION:-2}" +if [[ -z "${PACKAGE_COMPATIBILITY_ROOT:-}" ]]; then + WORK_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/ncalc-package-compatibility.XXXXXX")" +else + WORK_ROOT="${PACKAGE_COMPATIBILITY_ROOT}" +fi + +readonly WORK_ROOT +readonly CURRENT_EXTRACT_ROOT="${WORK_ROOT}/current" +readonly BASELINE_EXTRACT_ROOT="${WORK_ROOT}/baseline" + +main() { + require_command dotnet + require_command monodis + require_command sn + require_command unzip + require_command mcs + require_command mono + require_identity_mode + + mkdir -p "${CURRENT_EXTRACT_ROOT}" "${BASELINE_EXTRACT_ROOT}" + + local current_package + local baseline_package + current_package="$(resolve_current_package)" + baseline_package="$(resolve_baseline_package)" + + extract_package "${current_package}" "${CURRENT_EXTRACT_ROOT}" + extract_package "${baseline_package}" "${BASELINE_EXTRACT_ROOT}" + + validate_baseline_identity + validate_current_identity + validate_current_package_shape + validate_antlr_public_surface_compatibility + run_net35_parser_source_smoke + run_legacy_binary_identity_probe + + echo "Package compatibility checks passed for ${current_package}." +} + +require_command() { + local command_name="$1" + + if ! command -v "${command_name}" >/dev/null 2>&1; then + echo "Required command not found: ${command_name}" >&2 + exit 127 + fi +} + +require_identity_mode() { + case "${PACKAGE_IDENTITY_MODE}" in + major|legacy) + ;; + *) + echo "Unsupported NCALC_PACKAGE_IDENTITY_MODE: ${PACKAGE_IDENTITY_MODE}. Expected major or legacy." >&2 + exit 2 + ;; + esac +} + +resolve_current_package() { + if [[ -n "${PACKAGE_VERSION}" ]]; then + find "${PACKAGE_SOURCE}" -maxdepth 1 -name "NCalc.${PACKAGE_VERSION}.nupkg" -print -quit + return + fi + + find "${PACKAGE_SOURCE}" -maxdepth 1 -name 'NCalc.*.nupkg' ! -name '*.symbols.nupkg' -print -quit +} + +resolve_baseline_package() { + local package_path + package_path="${NUGET_PACKAGES:-${HOME}/.nuget/packages}/ncalc/${BASELINE_VERSION}/ncalc.${BASELINE_VERSION}.nupkg" + + if [[ -f "${package_path}" ]]; then + printf '%s\n' "${package_path}" + return + fi + + hydrate_baseline_package + + if [[ -f "${package_path}" ]]; then + printf '%s\n' "${package_path}" + return + fi + + echo "Could not hydrate NCalc ${BASELINE_VERSION} baseline package at ${package_path}." >&2 + exit 2 +} + +hydrate_baseline_package() { + local baseline_restore_root + baseline_restore_root="${WORK_ROOT}/baseline-restore" + mkdir -p "${baseline_restore_root}" + + cat >"${baseline_restore_root}/NCalc.ApiCompatBaseline.csproj" < + + net35 + + + + + +XML + + dotnet restore "${baseline_restore_root}/NCalc.ApiCompatBaseline.csproj" \ + --source "${NUGET_PACKAGE_SOURCE}" \ + >/dev/null +} + +extract_package() { + local package_path="$1" + local extract_root="$2" + + if [[ -z "${package_path}" || ! -f "${package_path}" ]]; then + echo "Package not found: ${package_path:-}" >&2 + exit 2 + fi + + rm -rf "${extract_root}" + mkdir -p "${extract_root}" + unzip -q "${package_path}" -d "${extract_root}" +} + +validate_baseline_identity() { + local baseline_assembly + baseline_assembly="${BASELINE_EXTRACT_ROOT}/lib/NCalc.dll" + + require_file "${baseline_assembly}" + assert_public_key_token "${baseline_assembly}" "${LEGACY_PUBLIC_KEY_TOKEN}" "legacy baseline" +} + +validate_current_identity() { + local expected_token + expected_token="${EXPECTED_PUBLIC_KEY_TOKEN}" + + if [[ "${PACKAGE_IDENTITY_MODE}" == "legacy" ]]; then + expected_token="${LEGACY_PUBLIC_KEY_TOKEN}" + fi + + while IFS= read -r assembly_path; do + assert_strong_named "${assembly_path}" + assert_public_key_token "${assembly_path}" "${expected_token}" "current package" + assert_assembly_version "${assembly_path}" "${EXPECTED_ASSEMBLY_VERSION}" + done < <(current_assemblies) +} + +validate_current_package_shape() { + local nuspec_path + nuspec_path="$(find "${CURRENT_EXTRACT_ROOT}" -maxdepth 1 -name '*.nuspec' -print -quit)" + require_file "${nuspec_path}" + assert_package_version_metadata "${nuspec_path}" + assert_license_metadata "${nuspec_path}" + + if grep -q 'Antlr3.Runtime' "${nuspec_path}"; then + echo "Current package must not expose Antlr3.Runtime as a NuGet dependency." >&2 + exit 1 + fi + + if find "${CURRENT_EXTRACT_ROOT}" -name 'Antlr3.Runtime.dll' -print -quit | grep -q .; then + echo "Current package must not include Antlr3.Runtime.dll as a separate asset." >&2 + exit 1 + fi + + while IFS= read -r assembly_path; do + assert_no_antlr_assembly_reference "${assembly_path}" + assert_embedded_type "${assembly_path}" "NCalcLexer" + assert_embedded_type "${assembly_path}" "NCalcParser" + assert_embedded_type "${assembly_path}" "NCalcParser/ncalcExpression_return" + assert_embedded_type "${assembly_path}" "Antlr.Runtime.ParserRuleReturnScope" + assert_embedded_type "${assembly_path}" 'Antlr.Runtime.ParserRuleReturnScope`1' + assert_embedded_type "${assembly_path}" "Antlr.Runtime.IAstRuleReturnScope" + assert_embedded_type "${assembly_path}" "Antlr.Runtime.Tree.CommonTree" + done < <(current_assemblies) +} + +validate_antlr_public_surface_compatibility() { + local baseline_types + local excluded_types + local current_types + local missing_types + + baseline_types="${WORK_ROOT}/baseline-antlr-public-types.txt" + excluded_types="${WORK_ROOT}/approved-antlr-public-type-exclusions.txt" + public_antlr_types "${BASELINE_EXTRACT_ROOT}/lib/NCalc.dll" >"${baseline_types}" + approved_antlr_public_type_exclusions >"${excluded_types}" + + while IFS= read -r assembly_path; do + current_types="${WORK_ROOT}/current-antlr-public-types-$(basename "$(dirname "${assembly_path}")").txt" + missing_types="${WORK_ROOT}/missing-antlr-public-types-$(basename "$(dirname "${assembly_path}")").txt" + + public_antlr_types "${assembly_path}" >"${current_types}" + comm -23 "${baseline_types}" "${current_types}" | comm -23 - "${excluded_types}" >"${missing_types}" + + if [[ -s "${missing_types}" ]]; then + echo "Current package is missing legacy public ANTLR types in ${assembly_path}:" >&2 + cat "${missing_types}" >&2 + exit 1 + fi + done < <(current_assemblies) +} + +public_antlr_types() { + local assembly_path="$1" + + monodis --typedef "${assembly_path}" | + awk '/^[0-9]+:/ && $2 ~ /^Antlr\.Runtime/ && $0 ~ /flags=0x[0-9A-Fa-f]*[12][,)]/ { print $2 }' | + sort -u +} + +approved_antlr_public_type_exclusions() { + # These two legacy public names are nested inside upstream ANTLR runtime types. + # Restoring them would require vendoring or IL-weaving ANTLR itself; all safe + # top-level legacy names are restored by source shims and protected by smoke tests. + cat <<'TEXT' | sort -u +Antlr.Runtime.DFA/SpecialStateTransitionHandler +Antlr.Runtime.Tree.TreeWizard/ContextVisitor +TEXT +} + +assert_package_version_metadata() { + local nuspec_path="$1" + local nuspec_version + local package_major_version + + nuspec_version="$(grep -Eo '[^<]+' "${nuspec_path}" | sed -E 's###g' | head -n 1)" + + if [[ -n "${PACKAGE_VERSION}" && "${nuspec_version}" != "${PACKAGE_VERSION}" ]]; then + echo "Expected NuGet package version ${PACKAGE_VERSION} in ${nuspec_path}, got ${nuspec_version:-}." >&2 + exit 1 + fi + + package_major_version="$(extract_package_major_version "${nuspec_version}")" + + if [[ "${PACKAGE_IDENTITY_MODE}" == "major" && "${package_major_version}" -lt "${MINIMUM_MAJOR_PACKAGE_VERSION}" ]]; then + echo "Package version ${nuspec_version} is invalid for a new-key major release. Expected major version ${MINIMUM_MAJOR_PACKAGE_VERSION} or later." >&2 + exit 1 + fi +} + +extract_package_major_version() { + local package_version="$1" + + if [[ "${package_version}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)([-+][0-9A-Za-z.-]+)?$ ]]; then + printf '%s\n' "${BASH_REMATCH[1]}" + return + fi + + echo "Unsupported package version format: ${package_version}" >&2 + exit 2 +} + +assert_license_metadata() { + local nuspec_path="$1" + + if ! grep -Eq "]*type=\"expression\"[^>]*>${EXPECTED_LICENSE_EXPRESSION}" "${nuspec_path}"; then + echo "Expected NuGet license expression ${EXPECTED_LICENSE_EXPRESSION} in ${nuspec_path}." >&2 + exit 1 + fi +} + +current_assemblies() { + printf '%s\n' \ + "${CURRENT_EXTRACT_ROOT}/lib/net35/NCalc.dll" \ + "${CURRENT_EXTRACT_ROOT}/lib/netstandard2.0/NCalc.dll" +} + +require_file() { + local file_path="$1" + + if [[ ! -f "${file_path}" ]]; then + echo "Required file not found: ${file_path}" >&2 + exit 2 + fi +} + +assert_strong_named() { + local assembly_path="$1" + + require_file "${assembly_path}" + + if ! sn -vf "${assembly_path}" >/dev/null; then + echo "Assembly is not strong-named: ${assembly_path}" >&2 + exit 1 + fi +} + +assert_public_key_token() { + local assembly_path="$1" + local expected_token="$2" + local label="$3" + local actual_token + + actual_token="$(sn -T "${assembly_path}" | awk '/Public Key Token/ { print $4 }')" + + if [[ "${actual_token}" != "${expected_token}" ]]; then + echo "${label} public key token mismatch for ${assembly_path}: expected ${expected_token}, got ${actual_token:-}." >&2 + exit 1 + fi +} + +assert_assembly_version() { + local assembly_path="$1" + local expected_version="$2" + local actual_version + + actual_version="$(monodis --assembly "${assembly_path}" | awk '/^Version:/ { print $2; exit }')" + + if [[ "${actual_version}" != "${expected_version}" ]]; then + echo "Assembly version mismatch for ${assembly_path}: expected ${expected_version}, got ${actual_version:-}." >&2 + exit 1 + fi +} + +assert_no_antlr_assembly_reference() { + local assembly_path="$1" + + if monodis --assemblyref "${assembly_path}" | grep -q 'Antlr3.Runtime'; then + echo "Assembly must embed ANTLR instead of referencing Antlr3.Runtime: ${assembly_path}" >&2 + exit 1 + fi +} + +assert_embedded_type() { + local assembly_path="$1" + local type_name="$2" + + if ! monodis --typedef "${assembly_path}" | awk -v type_name="${type_name}" '$2 == type_name { found = 1 } END { exit(found ? 0 : 1) }'; then + echo "Expected type ${type_name} in ${assembly_path}." >&2 + exit 1 + fi +} + +assert_assembly_reference_token() { + local assembly_path="$1" + local reference_name="$2" + local expected_token="$3" + local reference_block + + reference_block="$( + monodis --assemblyref "${assembly_path}" | + awk -v reference_name="Name=${reference_name}" ' + /^[0-9]+:/ { in_reference = 0 } + index($0, reference_name) { in_reference = 1 } + in_reference { print } + ' + )" + + if ! printf '%s' "${reference_block}" | tr -d '[:space:]' | grep -qi "${expected_token}"; then + echo "Expected ${assembly_path} to reference ${reference_name} with public key token ${expected_token}." >&2 + exit 1 + fi +} + +run_net35_parser_source_smoke() { + local smoke_root + smoke_root="${WORK_ROOT}/net35-parser-source-smoke" + mkdir -p "${smoke_root}" + + cp "${CURRENT_EXTRACT_ROOT}/lib/net35/NCalc.dll" "${smoke_root}/NCalc.dll" + write_parser_smoke_program "${smoke_root}/ParserSmoke.cs" + + mcs \ + -sdk:4 \ + -r:"${smoke_root}/NCalc.dll" \ + -out:"${smoke_root}/ParserSmoke.exe" \ + "${smoke_root}/ParserSmoke.cs" \ + >/dev/null + + mono "${smoke_root}/ParserSmoke.exe" >/dev/null +} + +run_legacy_binary_identity_probe() { + local smoke_root + smoke_root="${WORK_ROOT}/legacy-binary-identity-probe" + mkdir -p "${smoke_root}" + + cp "${BASELINE_EXTRACT_ROOT}/lib/NCalc.dll" "${smoke_root}/NCalc.dll" + write_parser_smoke_program "${smoke_root}/ParserSmoke.cs" + + mcs \ + -sdk:4 \ + -r:"${smoke_root}/NCalc.dll" \ + -out:"${smoke_root}/ParserSmoke.exe" \ + "${smoke_root}/ParserSmoke.cs" \ + >/dev/null + + cp "${CURRENT_EXTRACT_ROOT}/lib/net35/NCalc.dll" "${smoke_root}/NCalc.dll" + assert_assembly_reference_token "${smoke_root}/ParserSmoke.exe" "NCalc" "${LEGACY_PUBLIC_KEY_TOKEN}" + + if [[ "${PACKAGE_IDENTITY_MODE}" == "legacy" ]]; then + mono "${smoke_root}/ParserSmoke.exe" >/dev/null + return + fi + + echo "Legacy binary probe references ${LEGACY_PUBLIC_KEY_TOKEN}; current package token ${EXPECTED_PUBLIC_KEY_TOKEN} is a deliberate major-version identity break." +} + +write_parser_smoke_program() { + local program_path="$1" + + cat >"${program_path}" <<'CSHARP' +using System; +using System.IO; +using Antlr.Runtime; +using Antlr.Runtime.Tree; +using NCalc; + +internal static class ParserSmoke +{ + private static int Main() + { + object expressionResult = new Expression("2 * (3 + 5)").Evaluate(); + if (!object.Equals(16, expressionResult)) + { + throw new InvalidOperationException("Unexpected expression result: " + expressionResult); + } + + NCalcLexer lexer = new NCalcLexer(new ANTLRStringStream("1 + 2")); + CommonTokenStream tokens = new CommonTokenStream(lexer); + NCalcParser parser = new NCalcParser(tokens); + ParserRuleReturnScope parserResult = parser.ncalcExpression(); + NCalcParser.ncalcExpression_return typedResult = (NCalcParser.ncalcExpression_return)parserResult; + CommonTree tree = typedResult.Tree as CommonTree; + + if (tree == null) + { + throw new InvalidOperationException("Expected parser to return a CommonTree."); + } + + if (parserResult.Start == null || parserResult.Stop == null) + { + throw new InvalidOperationException("Expected parser return scope to expose token boundaries."); + } + + AssertLegacyAntlrSurface(tokens, parserResult, tree); + + Console.WriteLine("Parser smoke passed."); + return 0; + } + + private static void AssertLegacyAntlrSurface(CommonTokenStream tokens, ParserRuleReturnScope parserResult, CommonTree tree) + { + RuleReturnScope ruleReturnScope = CreateParserReturnScopeFromTree(tree); + TreeRuleReturnScope treeRuleReturnScope = new TreeRuleReturnScope(); + treeRuleReturnScope.Start = tree; + + if (ruleReturnScope.Start == null || treeRuleReturnScope.Start == null) + { + throw new InvalidOperationException("Expected legacy return-scope shims to store Start values."); + } + + if (Token.EOF != -1 || Token.DOWN != 2 || Token.UP != 3 || Token.EOF_TOKEN == null || Token.SKIP_TOKEN == null) + { + throw new InvalidOperationException("Expected legacy Token constants and singleton fields."); + } + + if (Antlr.Runtime.Tree.Tree.INVALID_NODE == null) + { + throw new InvalidOperationException("Expected legacy Tree.INVALID_NODE."); + } + + SynPredPointer synPredPointer = delegate { }; + synPredPointer(); + + Antlr.Runtime.Collections.HashList hashList = new Antlr.Runtime.Collections.HashList(); + hashList.Add("key", "value"); + if (!hashList.Contains("key") || !object.Equals("value", hashList["key"])) + { + throw new InvalidOperationException("Expected legacy HashList behavior."); + } + + Antlr.Runtime.Collections.StackList stackList = new Antlr.Runtime.Collections.StackList(); + stackList.Push("value"); + if (!object.Equals("value", stackList.Peek()) || !object.Equals("value", stackList.Pop())) + { + throw new InvalidOperationException("Expected legacy StackList behavior."); + } + + if (Antlr.Runtime.Collections.CollectionUtils.ListToString(new object[] { "a", "b" }).Length == 0) + { + throw new InvalidOperationException("Expected legacy CollectionUtils behavior."); + } + + if (Antlr.Runtime.Misc.Stats.Sum(new[] { 1, 2, 3 }) != 6) + { + throw new InvalidOperationException("Expected legacy Stats behavior."); + } + + AssertLegacyAntlrFileStreamSurface(); + + Antlr.Runtime.Misc.ErrorManager errorManager = new Antlr.Runtime.Misc.ErrorManager(); + Antlr.Runtime.CommonErrorNode rootErrorNode = new Antlr.Runtime.CommonErrorNode( + tokens, + (IToken)parserResult.Start, + (IToken)parserResult.Stop, + new RecognitionException(tokens)); + Antlr.Runtime.Tree.UnBufferedTreeNodeStream unbufferedTreeNodeStream = new Antlr.Runtime.Tree.UnBufferedTreeNodeStream(tree); + unbufferedTreeNodeStream.HasUniqueNavigationNodes = true; + + AssertLegacyUnBufferedTreeNodeStreamSurface(unbufferedTreeNodeStream); + AssertLegacyRewriteRuleElementStreamSurface(); + + if (errorManager == null || rootErrorNode == null || unbufferedTreeNodeStream.Size() == 0) + { + throw new InvalidOperationException("Expected legacy ANTLR tree shims."); + } + } + + private static RuleReturnScope CreateParserReturnScopeFromTree(CommonTree tree) + { + RuleReturnScope ruleReturnScope = new RuleReturnScope(); + ruleReturnScope.Start = Token.INVALID_TOKEN; + ruleReturnScope.Stop = Token.EOF_TOKEN; + ruleReturnScope.Tree = tree; + return ruleReturnScope; + } + + private static void AssertLegacyUnBufferedTreeNodeStreamSurface(UnBufferedTreeNodeStream stream) + { + bool getThrowsNotSupported = false; + try + { + stream.Get(0); + } + catch (NotSupportedException) + { + getThrowsNotSupported = true; + } + + if (!getThrowsNotSupported || stream.Current != null || !stream.MoveNext() || stream.Current == null) + { + throw new InvalidOperationException("Expected legacy UnBufferedTreeNodeStream cursor members."); + } + + stream.Reset(); + if (stream.Current != null) + { + throw new InvalidOperationException("Expected legacy UnBufferedTreeNodeStream reset behavior."); + } + } + + private static void AssertLegacyRewriteRuleElementStreamSurface() + { + LegacyRewriteRuleElementStream stream = new LegacyRewriteRuleElementStream(new CommonTreeAdaptor()); + stream.Add("value"); + + if (!stream.HasNext() || stream.Size() != 1 || !object.Equals("value", stream.ReadNext())) + { + throw new InvalidOperationException("Expected legacy RewriteRuleElementStream subclass behavior."); + } + } + + private static void AssertLegacyAntlrFileStreamSurface() + { + string filePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".txt"); + + try + { + File.WriteAllText(filePath, "abc"); + LegacyAntlrFileStream fileStream = new LegacyAntlrFileStream(); + fileStream.Load(filePath, null); + + if (fileStream.Count != 3 || fileStream.FileName != null) + { + throw new InvalidOperationException("Expected legacy ANTLRFileStream protected members."); + } + } + finally + { + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + } + } + + private sealed class LegacyAntlrFileStream : ANTLRFileStream + { + public LegacyAntlrFileStream() + : base() + { + } + + public string FileName + { + get { return fileName; } + } + } + + private sealed class LegacyRewriteRuleElementStream : RewriteRuleElementStream + { + public LegacyRewriteRuleElementStream(ITreeAdaptor adaptor) + : base(adaptor, "legacy") + { + } + + public object ReadNext() + { + return _Next(); + } + } +} +CSHARP +} + +main "$@" diff --git a/scripts/validate.sh b/scripts/validate.sh new file mode 100755 index 0000000..5893f7a --- /dev/null +++ b/scripts/validate.sh @@ -0,0 +1,324 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +REPOSITORY_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +readonly REPOSITORY_ROOT +readonly CONFIGURATION="${CONFIGURATION:-Release}" +readonly COVERAGE_RESULTS_DIR="${COVERAGE_RESULTS_DIR:-${REPOSITORY_ROOT}/artifacts/coverage}" +readonly PACKAGE_OUTPUT_DIR="${PACKAGE_OUTPUT_DIR:-${REPOSITORY_ROOT}/artifacts/packages}" +readonly PACKAGE_VERSION="${PACKAGE_VERSION:-2.0.0-qualitygates}" +readonly API_COMPAT_BASELINE_VERSION="${API_COMPAT_BASELINE_VERSION:-1.3.8}" +readonly NUGET_PACKAGE_SOURCE="${NUGET_PACKAGE_SOURCE:-https://api.nuget.org/v3/index.json}" +readonly MINIMUM_LINE_COVERAGE="${MINIMUM_LINE_COVERAGE:-0.93}" +readonly MINIMUM_BRANCH_COVERAGE="${MINIMUM_BRANCH_COVERAGE:-0.91}" +readonly PACKAGE_IDENTITY_MODE="${NCALC_PACKAGE_IDENTITY_MODE:-major}" +readonly MINIMUM_MAJOR_PACKAGE_VERSION="${NCALC_MINIMUM_MAJOR_PACKAGE_VERSION:-2}" +readonly DEFAULT_LOCAL_STRONG_NAME_KEY="${HOME}/.ncalc/signing/NCalc.snk" +readonly CORE_PROJECT="${CORE_PROJECT:-Evaluant.Calculator/NCalc.csproj}" +readonly TEST_PROJECT="${TEST_PROJECT:-Evaluant.Calculator.Tests/NCalc.Tests.csproj}" +readonly FRAMEWORK_COMPATIBILITY_PROJECT="${FRAMEWORK_COMPATIBILITY_PROJECT:-Evaluant.Calculator.FrameworkCompatibility.Tests/NCalc.FrameworkCompatibility.Tests.csproj}" +readonly SOLUTION_FILE="${SOLUTION_FILE:-NCalc.sln}" + +main() { + cd "${REPOSITORY_ROOT}" + + require_command dotnet + require_command python3 + require_sdk_style_core_project + require_no_tracked_signing_keys + validate_package_version_policy "${PACKAGE_VERSION}" + prepare_strong_name_key + + restore_projects + build_projects + build_solution + run_framework_compatibility_tests + run_tests_with_coverage + pack_package + run_package_compatibility_check + run_api_compatibility_check + run_modern_consumer_smoke + + print_section "Validation complete" + echo "Quality gates passed locally." +} + +require_command() { + local command_name="$1" + + if ! command -v "${command_name}" >/dev/null 2>&1; then + echo "Required command not found: ${command_name}" >&2 + exit 127 + fi +} + +require_sdk_style_core_project() { + print_section "Checking stack dependencies" + + if ! grep -q '&2 + echo "${tracked_signing_files}" >&2 + exit 1 + fi + + echo "No tracked signing key files detected." +} + +validate_package_version_policy() { + local package_version="$1" + local package_major_version + + package_major_version="$(extract_package_major_version "${package_version}")" + + if [[ "${PACKAGE_IDENTITY_MODE}" == "major" && "${package_major_version}" -lt "${MINIMUM_MAJOR_PACKAGE_VERSION}" ]]; then + echo "Package version ${package_version} is invalid for a new-key major release. Expected major version ${MINIMUM_MAJOR_PACKAGE_VERSION} or later." >&2 + exit 1 + fi +} + +extract_package_major_version() { + local package_version="$1" + + if [[ "${package_version}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)([-+][0-9A-Za-z.-]+)?$ ]]; then + printf '%s\n' "${BASH_REMATCH[1]}" + return + fi + + echo "Unsupported package version format: ${package_version}" >&2 + exit 2 +} + +prepare_strong_name_key() { + print_section "Checking strong-name signing key" + + if [[ -z "${NCalcStrongNameKeyFile:-}" && -f "${DEFAULT_LOCAL_STRONG_NAME_KEY}" ]]; then + export NCalcStrongNameKeyFile="${DEFAULT_LOCAL_STRONG_NAME_KEY}" + fi + + if [[ -z "${NCalcStrongNameKeyFile:-}" ]]; then + cat >&2 <&2 + exit 2 + fi + + NCalcStrongNameKeyFile="$(cd "$(dirname "${NCalcStrongNameKeyFile}")" && pwd)/$(basename "${NCalcStrongNameKeyFile}")" + export NCalcStrongNameKeyFile + + if [[ "${NCalcStrongNameKeyFile}" == "${REPOSITORY_ROOT}/"* ]]; then + cat >&2 <"${baseline_restore_root}/NCalc.ApiCompatBaseline.csproj" < + + net35 + + + + + +XML + + dotnet restore "${baseline_restore_root}/NCalc.ApiCompatBaseline.csproj" \ + --source "${NUGET_PACKAGE_SOURCE}" \ + >/dev/null + ) +} + +run_modern_consumer_smoke() { + print_section "Running modern consumer smoke" + + "${SCRIPT_DIR}/consumer-smoke.sh" "${PACKAGE_OUTPUT_DIR}" "${PACKAGE_VERSION}" +} + +print_section() { + local title="$1" + printf '\n==> %s\n' "${title}" +} + +main "$@" diff --git a/scripts/windows-framework-consumer-smoke.ps1 b/scripts/windows-framework-consumer-smoke.ps1 new file mode 100644 index 0000000..4a06fb1 --- /dev/null +++ b/scripts/windows-framework-consumer-smoke.ps1 @@ -0,0 +1,106 @@ +param( + [Parameter(Mandatory = $true)] + [string] $PackageSource, + + [Parameter(Mandatory = $true)] + [string] $PackageVersion, + + [string] $SmokeRoot = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "ncalc-framework-consumer-smoke-" + [System.Guid]::NewGuid().ToString("N")) +) + +$ErrorActionPreference = "Stop" + +function Require-Command { + param([Parameter(Mandatory = $true)][string] $Name) + + $command = Get-Command $Name -ErrorAction SilentlyContinue + if ($null -eq $command) { + throw "Required command not found: $Name" + } +} + +function Write-SmokeProject { + param([Parameter(Mandatory = $true)][string] $ProjectDirectory) + + New-Item -ItemType Directory -Force -Path $ProjectDirectory | Out-Null + + @" + + + Exe + net48 + NCalc.FrameworkConsumerSmoke + NCalc.FrameworkConsumerSmoke + disable + disable + latest + + + + + + +"@ | Set-Content -NoNewline -Encoding UTF8 -Path (Join-Path $ProjectDirectory "NCalc.FrameworkConsumerSmoke.csproj") + + @" +using System; +using Antlr.Runtime; +using Antlr.Runtime.Tree; +using NCalc; + +namespace NCalc.FrameworkConsumerSmoke +{ + internal static class Program + { + private static int Main() + { + object result = new Expression("2 * (3 + 5)").Evaluate(); + if (!object.Equals(16, result)) + { + throw new InvalidOperationException("Unexpected expression result: " + result); + } + + NCalcLexer lexer = new NCalcLexer(new ANTLRStringStream("1 + 2")); + CommonTokenStream tokens = new CommonTokenStream(lexer); + NCalcParser parser = new NCalcParser(tokens); + NCalcParser.ncalcExpression_return parserResult = parser.ncalcExpression(); + CommonTree tree = parserResult.Tree as CommonTree; + + if (tree == null) + { + throw new InvalidOperationException("Expected parser to return a CommonTree."); + } + + Console.WriteLine("NCalc .NET Framework consumer smoke passed: " + result); + return 0; + } + } +} +"@ | Set-Content -NoNewline -Encoding UTF8 -Path (Join-Path $ProjectDirectory "Program.cs") +} + +function Invoke-Smoke { + Require-Command "dotnet" + + $package = Get-ChildItem -Path $PackageSource -Filter "NCalc.$PackageVersion.nupkg" -File | Select-Object -First 1 + if ($null -eq $package) { + throw "NCalc $PackageVersion package not found in $PackageSource" + } + + $projectDirectory = Join-Path $SmokeRoot "NCalc.FrameworkConsumerSmoke" + $packagesDirectory = Join-Path $SmokeRoot "packages" + Write-SmokeProject -ProjectDirectory $projectDirectory + + $projectPath = Join-Path $projectDirectory "NCalc.FrameworkConsumerSmoke.csproj" + dotnet restore $projectPath --source $PackageSource --source "https://api.nuget.org/v3/index.json" /p:RestorePackagesPath=$packagesDirectory + dotnet build $projectPath --configuration Release --no-restore + + $executablePath = Join-Path $projectDirectory "bin/Release/net48/NCalc.FrameworkConsumerSmoke.exe" + if (!(Test-Path $executablePath)) { + throw "Expected smoke executable not found: $executablePath" + } + + & $executablePath +} + +Invoke-Smoke