diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs index 8f4d5d8735..73c4a478db 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Globalization; +using System.Numerics; using System.Text.Json; using System.Text.RegularExpressions; using Altinn.App.Core.Models; @@ -11,7 +12,7 @@ namespace Altinn.App.Core.Internal.Expressions; /// /// Static class used to evaluate expressions. Holds the implementation for all expression functions. /// -public static class ExpressionEvaluator +public static partial class ExpressionEvaluator { /// /// Shortcut for evaluating a boolean expression on a given property on a @@ -127,6 +128,10 @@ internal static async Task EvaluateExpression_internal( ExpressionFunction.argv => Argv(args, positionalArguments), ExpressionFunction.gatewayAction => state.GetGatewayAction(), ExpressionFunction.language => state.GetLanguage(), + ExpressionFunction.plus => Plus(args), + ExpressionFunction.minus => Minus(args), + ExpressionFunction.multiply => Multiply(args), + ExpressionFunction.divide => Divide(args), ExpressionFunction.INVALID => throw new ExpressionEvaluatorTypeErrorException( $"Function {expr.Args.FirstOrDefault()} not implemented in backend {expr}" ), @@ -610,8 +615,8 @@ private static int StringLength(ExpressionValue[] args) throw new ExpressionEvaluatorTypeErrorException($"Expected 2-3 arguments, got {args.Length}"); } string? subject = args[0].ToStringForEquals(); - double? start = PrepareNumericArg(args[1]); - double? end = args.Length == 3 ? PrepareNumericArg(args[2]) : null; + double? start = PrepareNumericArg(args[1]); + double? end = args.Length == 3 ? PrepareNumericArg(args[2]) : null; bool hasEnd = args.Length == 3; if (start == null || (hasEnd && end == null)) @@ -659,13 +664,13 @@ private static string Round(ExpressionValue[] args) ); } - var number = PrepareNumericArg(args[0]) ?? 0; + var number = PrepareNumericArg(args[0]) ?? 0; int precision = 0; if (args.Length == 2) { - precision = (int)(PrepareNumericArg(args[1]) ?? 0); + precision = (int)(PrepareNumericArg(args[1]) ?? 0); } return number.ToString($"N{precision}", CultureInfo.InvariantCulture); @@ -806,33 +811,51 @@ ExpressionValue[] args return !PrepareBooleanArg(args[0]); } - private static (double?, double?) PrepareNumericArgs(ExpressionValue[] args) + private static (T?, T?) PrepareNumericArgs(ExpressionValue[] args) + where T : struct, INumber { if (args.Length != 2) { - throw new ExpressionEvaluatorTypeErrorException("Invalid number of args for compare"); + throw new ExpressionEvaluatorTypeErrorException("Invalid number of args"); } - var a = PrepareNumericArg(args[0]); + var a = PrepareNumericArg(args[0]); - var b = PrepareNumericArg(args[1]); + var b = PrepareNumericArg(args[1]); return (a, b); } - private static double? PrepareNumericArg(ExpressionValue arg) + private static T? PrepareNumericArg(ExpressionValue arg) + where T : struct, INumber { return arg.ValueKind switch { JsonValueKind.True or JsonValueKind.False or JsonValueKind.Array or JsonValueKind.Object => throw new ExpressionEvaluatorTypeErrorException($"Expected number, got value {arg}"), - JsonValueKind.String => ParseNumber(arg.String, throwException: true), - JsonValueKind.Number => arg.Number, + JsonValueKind.String => ParseNumber(arg.String, throwException: true), + JsonValueKind.Number => CastNumber(arg.Number), _ => null, }; } + private static T? CastNumber(double? number) + where T : struct, INumber + { + if (typeof(T) != typeof(decimal)) + { + return number.HasValue ? T.CreateChecked(number.Value) : null; + } + + if (number.HasValue && ValidFloatingPoint(number.Value)) + { + return T.CreateChecked(number.Value); + } + + return null; + } + private static ExpressionValue IfImpl(ExpressionValue[] args) { if (args.Length == 2) @@ -861,11 +884,13 @@ private static ExpressionValue IfImpl(ExpressionValue[] args) ); } - private static readonly Regex _numberRegex = new Regex(@"^-?\d+(\.\d+)?$"); - - internal static double? ParseNumber(string s, bool throwException = true) + /// + /// Parses a number from a string representation. + /// + internal static T? ParseNumber(string s, bool throwException = true) + where T : struct, INumber { - if (_numberRegex.IsMatch(s) && double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var d)) + if (NumberRegex().IsMatch(s) && T.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var d)) { return d; } @@ -879,7 +904,7 @@ private static ExpressionValue IfImpl(ExpressionValue[] args) private static bool LessThan(ExpressionValue[] args) { - var (a, b) = PrepareNumericArgs(args); + var (a, b) = PrepareNumericArgs(args); if (a is null || b is null) { @@ -888,9 +913,37 @@ private static bool LessThan(ExpressionValue[] args) return a < b; // Actual implementation } + private static double? Plus(ExpressionValue[] args) + { + var (a, b) = PrepareNumericArgs(args); + return PerformArithmetic(a, b, (x, y) => x + y); + } + + private static double? Minus(ExpressionValue[] args) + { + var (a, b) = PrepareNumericArgs(args); + return PerformArithmetic(a, b, (x, y) => x - y); + } + + private static double? Multiply(ExpressionValue[] args) + { + var (a, b) = PrepareNumericArgs(args); + return PerformArithmetic(a, b, (x, y) => x * y); + } + + private static double? Divide(ExpressionValue[] args) + { + var (a, b) = PrepareNumericArgs(args); + if (a != null && b == 0) + { + throw new ExpressionEvaluatorTypeErrorException("The second argument is 0, cannot divide by 0"); + } + return PerformArithmetic(a, b, (x, y) => x / y); + } + private static bool LessThanEq(ExpressionValue[] args) { - var (a, b) = PrepareNumericArgs(args); + var (a, b) = PrepareNumericArgs(args); if (a is null || b is null) { @@ -901,7 +954,7 @@ private static bool LessThanEq(ExpressionValue[] args) private static bool GreaterThan(ExpressionValue[] args) { - var (a, b) = PrepareNumericArgs(args); + var (a, b) = PrepareNumericArgs(args); if (a is null || b is null) { @@ -912,7 +965,7 @@ private static bool GreaterThan(ExpressionValue[] args) private static bool GreaterThanEq(ExpressionValue[] args) { - var (a, b) = PrepareNumericArgs(args); + var (a, b) = PrepareNumericArgs(args); if (a is null || b is null) { @@ -943,7 +996,7 @@ private static ExpressionValue Argv(ExpressionValue[] args, ExpressionValue[]? p throw new ExpressionEvaluatorTypeErrorException($"Expected 1 argument(s), got {args.Length}"); } - var index = (int?)PrepareNumericArg(args[0]); + var index = (int?)PrepareNumericArg(args[0]); if (!index.HasValue) { throw new ExpressionEvaluatorTypeErrorException($"Expected number, got value \"{args[0]}\""); @@ -960,4 +1013,47 @@ private static ExpressionValue Argv(ExpressionValue[] args, ExpressionValue[]? p return positionalArguments[index.Value]; } + + /// + /// Performs arithmetic operation using decimal precision to avoid floating point precision issues. + /// Converts doubles to decimal, performs the operation, and converts back to double. + /// + /// First operand + /// Second operand + /// Function that performs the arithmetic operation on two decimals + /// Result of the operation as double, or null if any operand is null + private static double? PerformArithmetic( + decimal? aDecimal, + decimal? bDecimal, + Func operation + ) + { + if (aDecimal.HasValue is false || bDecimal.HasValue is false) + { + return null; + } + + var result = operation(aDecimal.Value, bDecimal.Value); + + return (double)result; + } + + private static bool ValidFloatingPoint(double value) + { + if ( + double.IsNaN(value) + || double.IsInfinity(value) + || value > (double)decimal.MaxValue + || value < (double)decimal.MinValue + ) + { + throw new ExpressionEvaluatorTypeErrorException( + $"Cannot convert non-finite or out-of-range number to decimal: {value}" + ); + } + return true; + } + + [GeneratedRegex(@"^-?\d+(\.\d+)?$")] + private static partial Regex NumberRegex(); } diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs index be4e2d351c..ce5e9b0fa0 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs @@ -141,9 +141,7 @@ public static ExpressionValue FromObject(object? value) uint numberValue => numberValue, long numberValue => numberValue, ulong numberValue => numberValue, - decimal numberValue => - (double?)numberValue // expressions uses double which needs an explicit cast - , + decimal numberValue => (double?)numberValue, // expressions uses double which needs an explicit cast DateTime dateTimeValue => JsonSerializer .Serialize(dateTimeValue, _unsafeSerializerOptionsForSerializingDates) .Trim( @@ -422,7 +420,7 @@ public override int GetHashCode() "0" => false, { } sValue when sValue.Equals("true", StringComparison.OrdinalIgnoreCase) => true, { } sValue when sValue.Equals("false", StringComparison.OrdinalIgnoreCase) => false, - _ => ExpressionEvaluator.ParseNumber(String, throwException: false) switch + _ => ExpressionEvaluator.ParseNumber(String, throwException: false) switch { 1 => true, 0 => false, @@ -525,7 +523,7 @@ public bool TryDeserialize(Type type, out object? result) // Support parsing numbers from strings for numeric types case JsonValueKind.String when IsSupportedNumericType(underlyingType): { - var parsedNumber = ExpressionEvaluator.ParseNumber(String, throwException: false); + var parsedNumber = ExpressionEvaluator.ParseNumber(String, throwException: false); if (parsedNumber.HasValue) { result = Convert.ChangeType(parsedNumber.Value, underlyingType, CultureInfo.InvariantCulture); @@ -560,7 +558,7 @@ public bool TryDeserialize(Type type, out object? result) try { var json = ToString(); - result = JsonSerializer.Deserialize(json, type, _unsafeSerializerOptionsForSerializingDates); + result = JsonSerializer.Deserialize(json, type); return true; } catch (JsonException) diff --git a/src/Altinn.App.Core/Models/Expressions/ExpressionFunction.cs b/src/Altinn.App.Core/Models/Expressions/ExpressionFunction.cs index f799c95a9f..57ecfe5d26 100644 --- a/src/Altinn.App.Core/Models/Expressions/ExpressionFunction.cs +++ b/src/Altinn.App.Core/Models/Expressions/ExpressionFunction.cs @@ -205,4 +205,24 @@ public enum ExpressionFunction /// If no translations exist for the current language, we will use the resources for "nb" /// text, + + /// + /// Adding numbers. Use a period (.) as the decimals separator. Must be numeric values. + /// + plus, + + /// + /// Subtracting all preceding values from the first. Use a period (.) as the decimals separator. Must be numeric values. + /// + minus, + + /// + /// Multiplying numbers. Use a period (.) as the decimals separator. Must be numeric values. + /// + multiply, + + /// + /// Divide numbers. Use a period (.) as the decimals separator. Must be numeric values. + /// + divide, } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs index d1a0d9de3b..a45da8a56f 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs @@ -10,6 +10,16 @@ namespace Altinn.App.Core.Tests.LayoutExpressions.CommonTests; public class ExpressionTestCaseRoot { + public ExpressionTestCaseRoot(TestCaseItem testCaseItem) + { + Name = testCaseItem.Name; + Expression = testCaseItem.Expression; + Expects = testCaseItem.Expects; + ExpectsFailure = testCaseItem.ExpectsFailure; + } + + public ExpressionTestCaseRoot() { } + [JsonIgnore] public string? Filename { get; set; } @@ -42,6 +52,9 @@ public class ExpressionTestCaseRoot public class TestCaseItem { + [JsonPropertyName("name")] + public string? Name { get; set; } + [JsonPropertyName("expression")] public required Expression Expression { get; set; } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs index 5ce7e4a049..7d2ddbfd8f 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Models.Expressions; using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; using Altinn.App.Core.Tests.TestUtils; diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs index ec7c206c8e..212b823635 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs @@ -203,6 +203,26 @@ public TestFunctions(ITestOutputHelper output) [SharedTest("stringLength")] public async Task StringLength_Theory(string testName, string folder) => await RunTestCase(testName, folder); + [Theory] + [SharedTestCases("plus")] + public async Task Plus_Theory(string testName, ExpressionTestCaseRoot.TestCaseItem testCaseItem) => + await RunTestCase(testName, new ExpressionTestCaseRoot(testCaseItem)); + + [Theory] + [SharedTestCases("minus")] + public async Task Minus_Theory(string testName, ExpressionTestCaseRoot.TestCaseItem testCaseItem) => + await RunTestCase(testName, new ExpressionTestCaseRoot(testCaseItem)); + + [Theory] + [SharedTestCases("multiply")] + public async Task Multiply_Theory(string testName, ExpressionTestCaseRoot.TestCaseItem testCaseItem) => + await RunTestCase(testName, new ExpressionTestCaseRoot(testCaseItem)); + + [Theory] + [SharedTestCases("divide")] + public async Task Divide_Theory(string testName, ExpressionTestCaseRoot.TestCaseItem testCaseItem) => + await RunTestCase(testName, new ExpressionTestCaseRoot(testCaseItem)); + [Theory] [SharedTest("round")] public async Task Round_Theory(string testName, string folder) => await RunTestCase(testName, folder); @@ -237,10 +257,15 @@ private static async Task LoadTestCase(string file, stri private async Task RunTestCase(string testName, string folder) { var test = await LoadTestCase(testName, folder); - _output.WriteLine(test.Name); + await RunTestCase(testName, test); + } + + private async Task RunTestCase(string testName, ExpressionTestCaseRoot test) + { + _output.WriteLine(testName); _output.WriteLine($"{test.Folder}{Path.DirectorySeparatorChar}{test.Filename}"); - _output.WriteLine(test.RawJson); - _output.WriteLine(test.FullPath); + _output.WriteLine(test.RawJson ?? ""); + _output.WriteLine(test.FullPath ?? ""); IInstanceDataAccessor dataAccessor; List dataTypes = new(); @@ -316,16 +341,16 @@ private async Task RunTestCase(string testName, string folder) componentModel = new LayoutModel([layout], null); } - var appRewourcesMock = new Mock(MockBehavior.Strict); + var appResourcesMock = new Mock(MockBehavior.Strict); var language = test.ProfileSettings?.Language ?? "nb"; - appRewourcesMock + appResourcesMock .Setup(ar => ar.GetTexts(It.IsAny(), It.IsAny(), language)) .ReturnsAsync(new TextResource() { Resources = test.TextResources ?? [] }); var translationService = new TranslationService( new Core.Models.AppIdentifier("org", "app"), - appRewourcesMock.Object, + appResourcesMock.Object, FakeLoggerXunit.Get(_output) ); @@ -385,6 +410,7 @@ private async Task RunTestCaseItem( object?[]? positionalArguments ) { + _output.WriteLine(test.Name ?? ""); if (test.ExpectsFailure is not null) { _output.WriteLine($"Expecting failure: {test.ExpectsFailure}"); @@ -457,7 +483,10 @@ public void Ensure_tests_For_All_Folders() var testMethods = this.GetType() .GetMethods() .Select(m => - m.CustomAttributes.FirstOrDefault(ca => ca.AttributeType == typeof(SharedTestAttribute)) + m.CustomAttributes.FirstOrDefault(ca => + ca.AttributeType == typeof(SharedTestAttribute) + || ca.AttributeType == typeof(SharedTestCasesAttribute) + ) ?.ConstructorArguments.FirstOrDefault() .Value ) @@ -473,3 +502,7 @@ public class SharedTestAttribute(string folder) : FileNamesInFolderDataAttribute( Path.Join("LayoutExpressions", "CommonTests", "shared-tests", "functions", folder) ) { } + +// Can be used when you only want to run the tests listed in the testCases array in the json file +public class SharedTestCasesAttribute(string folder) + : TestCasesAttribute(Path.Join("LayoutExpressions", "CommonTests", "shared-tests", "functions", folder)) { } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs index c935f44554..d8c47ca911 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Models.Expressions; using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; using Altinn.App.Core.Tests.TestUtils; diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/divide/divide.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/divide/divide.json new file mode 100644 index 0000000000..83f4f37025 --- /dev/null +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/divide/divide.json @@ -0,0 +1,150 @@ +{ + "name": "Divide tests", + "testCases": [ + { + "name": "Should divide the first argument with the second", + "expression": ["divide", 0.1, 0.2], + "expects": 0.5 + }, + { + "name": "Should divide the first argument with the second", + "expression": ["divide", -0.1, -0.2], + "expects": 0.5 + }, + { + "name": "Should return null when both of the arguments are null", + "expression": ["divide", null, null], + "expects": null + }, + { + "name": "Should return null when one of the arguments is null", + "expression": ["divide", null, 2.1], + "expects": null + }, + { + "name": "Should divide two integers", + "expression": ["divide", 30, 6], + "expects": 5 + }, + { + "name": "Should divide [float, integer]", + "expression": ["divide", 30.0, 6], + "expects": 5.0 + }, + { + "name": "Should divide [string, integer] when string is valid number", + "expression": ["divide", "30", 6], + "expects": 5 + }, + { + "name": "Should divide [string, float] when string is valid number", + "expression": ["divide", "22.0", 4.0], + "expects": 5.5 + }, + { + "name": "Should handle dividing zero by a number", + "expression": ["divide", 0, 42], + "expects": 0 + }, + { + "name": "Should divide negative by positive", + "expression": ["divide", -30, 6], + "expects": -5 + }, + { + "name": "Should divide two negative numbers", + "expression": ["divide", -30, -6], + "expects": 5 + }, + { + "name": "Should divide numbers in scientific notation", + "expression": ["divide", 6e3, 2e3], + "expects": 3 + }, + { + "name": "Should divide numbers with negative exponents", + "expression": ["divide", 6e-3, 2e-3], + "expects": 3 + }, + { + "name": "Should divide scientific notation by regular number", + "expression": ["divide", 5e2, 10], + "expects": 50 + }, + { + "name": "Should fail with invalid string that cannot be parsed as number", + "expression": ["divide", 100, "not a number"], + "expectsFailure": "Expected number, got value \"not a number\"" + }, + { + "name": "Should fail with string with no decimals after dot", + "expression": ["divide", "55.", 5], + "expectsFailure": "Expected number, got value \"55.\"" + }, + { + "name": "Should handle very large numbers", + "expression": ["divide", 1000000000000, 1000000], + "expects": 1000000 + }, + { + "name": "Should handle very small numbers", + "expression": ["divide", 0.00000001, 0.0001], + "expects": 0.0001 + }, + { + "name": "Should divide string scientific notation by number", + "expression": ["divide", "5e2", 10], + "expectsFailure": "Expected number, got value \"5e2\"" + }, + { + "name": "Should throw exception when less than two arguments are provided", + "expression": ["divide", 2.1], + "expectsFailure": "Invalid" + }, + { + "name": "Should throw exception when more than two arguments are present", + "expression": ["divide", 0.1, 0.2, 15, 1.50], + "expectsFailure": "Invalid" + }, + { + "name": "Should divide negative by positive decimal", + "expression": ["divide", -0.1, 0.2], + "expects": -0.5 + }, + { + "name": "Should return null when dividend is null and divisor is zero", + "expression": ["divide", null, 0], + "expects": null + }, + { + "name": "Should throw exception when trying to divide by zero", + "expression": ["divide", 15, 0], + "expectsFailure": "The second argument is 0, cannot divide by 0" + }, + { + "name": "Should fail with [boolean, integer]", + "expression": ["divide", true, 100], + "expectsFailure": "Expected number, got value true" + }, + { + "name": "Should throw exception when any argument is double max value", + "expression": ["divide", 0, 1.7976931348623157E+308], + "expectsFailure": "Cannot convert non-finite or out-of-range number to decimal:" + }, + { + "name": "Should throw exception when any argument is double min value", + "expression": ["divide", -1.7976931348623157E+308, 0], + "expectsFailure": "Cannot convert non-finite or out-of-range number to decimal:" + }, + { + "name": "Should throw exception when any argument is float max value", + "expression": ["divide", 3.4028235E+38, 0], + "expectsFailure": "Cannot convert non-finite or out-of-range number to decimal:" + }, + { + "name": "Should throw exception when any argument is float min value", + "expression": ["divide", 0, -3.402823e+38], + "expectsFailure": "Cannot convert non-finite or out-of-range number to decimal:" + } + ] +} diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/minus/minus.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/minus/minus.json new file mode 100644 index 0000000000..86a92d9c68 --- /dev/null +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/minus/minus.json @@ -0,0 +1,140 @@ +{ + "name": "Minus tests", + "testCases": [ + { + "name": "Should subtract the second argument from the first", + "expression": ["minus", 0.1, 0.2], + "expects": -0.1 + }, + { + "name": "Should subtract the second negative argument from the first", + "expression": ["minus", -0.1, -0.2], + "expects": 0.1 + }, + { + "name": "Should return null when both of the arguments are null", + "expression": ["minus", null, null], + "expects": null + }, + { + "name": "Should return null when one of the arguments is null", + "expression": ["minus", null, 2.1], + "expects": null + }, + { + "name": "Should subtract two integers", + "expression": ["minus", 50, 20], + "expects": 30 + }, + { + "name": "Should subtract [float, integer]", + "expression": ["minus", 50.5, 20], + "expects": 30.5 + }, + { + "name": "Should subtract [string, integer] when string is valid number", + "expression": ["minus", "50", 20], + "expects": 30 + }, + { + "name": "Should subtract [string, float] when string is valid number", + "expression": ["minus", "50.8", 20.3], + "expects": 30.5 + }, + { + "name": "Should handle subtracting zero", + "expression": ["minus", 42, 0], + "expects": 42 + }, + { + "name": "Should handle subtracting from zero", + "expression": ["minus", 0, 42], + "expects": -42 + }, + { + "name": "Should subtract negative from positive", + "expression": ["minus", 50, -30], + "expects": 80 + }, + { + "name": "Should subtract numbers in scientific notation", + "expression": ["minus", 5e3, 2e3], + "expects": 3000 + }, + { + "name": "Should subtract numbers with negative exponents", + "expression": ["minus", 5e-3, 2e-3], + "expects": 0.003 + }, + { + "name": "Should subtract scientific notation from regular number", + "expression": ["minus", 150, 1e2], + "expects": 50 + }, + { + "name": "Should fail with invalid string that cannot be parsed as number", + "expression": ["minus", 50, "not a number"], + "expectsFailure": "Expected number, got value \"not a number\"" + }, + { + "name": "Should fail with [boolean, integer]", + "expression": ["minus", false, 10], + "expectsFailure": "Expected number, got value false" + }, + { + "name": "Should fail with string with no decimals after dot", + "expression": ["minus", "55.", 10], + "expectsFailure": "Expected number, got value \"55.\"" + }, + { + "name": "Should handle very large numbers", + "expression": ["minus", 9007199254740992, 1], + "expects": 9007199254740989 + }, + { + "name": "Should handle very small numbers", + "expression": ["minus", 0.0000003, 0.0000001], + "expects": 0.0000002 + }, + { + "name": "Should throw exception when less than two arguments are provided", + "expression": ["minus", 0.2], + "expectsFailure": "Invalid" + }, + { + "name": "Should subtract string scientific notation from number", + "expression": ["minus", 150, "1e2"], + "expectsFailure": "Expected number, got value \"1e2\"" + }, + { + "name": "Should throw exception when more than two arguments are present", + "expression": ["minus", 0.1, 0.2, 15, 1.50], + "expectsFailure": "Invalid" + }, + { + "name": "Should subtract negative from positive decimal", + "expression": ["minus", 50.55, -30.77], + "expects": 81.32 + }, + { + "name": "Should throw exception when any argument is double max value", + "expression": ["minus", 0, 1.7976931348623157E+308], + "expectsFailure": "Cannot convert non-finite or out-of-range number to decimal:" + }, + { + "name": "Should throw exception when any argument is double min value", + "expression": ["minus", -1.7976931348623157E+308, 0], + "expectsFailure": "Cannot convert non-finite or out-of-range number to decimal:" + }, + { + "name": "Should throw exception when any argument is float max value", + "expression": ["minus", 3.4028235E+38, 0], + "expectsFailure": "Cannot convert non-finite or out-of-range number to decimal:" + }, + { + "name": "Should throw exception when any argument is float min value", + "expression": ["minus", 0, -3.402823e+38], + "expectsFailure": "Cannot convert non-finite or out-of-range number to decimal:" + } + ] +} diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/multiply/multiply.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/multiply/multiply.json new file mode 100644 index 0000000000..ca3b0794ff --- /dev/null +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/multiply/multiply.json @@ -0,0 +1,140 @@ +{ + "name": "Mutiply tests", + "testCases": [ + { + "name": "Should multiply the two arguments", + "expression": ["multiply", 0.1, 0.2], + "expects": 0.02 + }, + { + "name": "Should return null when both of the arguments are null", + "expression": ["multiply", null, null], + "expects": null + }, + { + "name": "Should multiply the two negative arguments", + "expression": ["multiply", -0.1, -0.2], + "expects": 0.02 + }, + { + "name": "Should return null when one of the arguments is null", + "expression": ["multiply", null, 2.1], + "expects": null + }, + { + "name": "Should multiply two integers", + "expression": ["multiply", 5, 6], + "expects": 30 + }, + { + "name": "Should multiply [float, integer]", + "expression": ["multiply", 7.5, 4], + "expects": 30.0 + }, + { + "name": "Should multiply [string, integer] when string is valid number", + "expression": ["multiply", "5", 6], + "expects": 30 + }, + { + "name": "Should multiply [string, float] when string is valid number", + "expression": ["multiply", "5.5", 4.0], + "expects": 22.0 + }, + { + "name": "Should handle multiplying by zero", + "expression": ["multiply", 42, 0], + "expects": 0 + }, + { + "name": "Should multiply negative and positive numbers", + "expression": ["multiply", -5, 6], + "expects": -30 + }, + { + "name": "Should multiply two negative numbers", + "expression": ["multiply", -5, -6], + "expects": 30 + }, + { + "name": "Should multiply numbers in scientific notation", + "expression": ["multiply", 2e3, 3e2], + "expects": 600000 + }, + { + "name": "Should multiply numbers with negative exponents", + "expression": ["multiply", 2e-3, 3e-2], + "expects": 0.00006 + }, + { + "name": "Should multiply scientific notation with regular number", + "expression": ["multiply", 1e2, 5], + "expects": 500 + }, + { + "name": "Should fail with invalid string that cannot be parsed as number", + "expression": ["multiply", "not a number", 5], + "expectsFailure": "Expected number, got value \"not a number\"" + }, + { + "name": "Should fail with [boolean, integer]", + "expression": ["multiply", true, 5], + "expectsFailure": "Expected number, got value true" + }, + { + "name": "Should fail with string with no decimals after dot", + "expression": ["multiply", "55.", 5], + "expectsFailure": "Expected number, got value \"55.\"" + }, + { + "name": "Should handle very large numbers", + "expression": ["multiply", 1000000, 1000000], + "expects": 1000000000000 + }, + { + "name": "Should handle very small numbers", + "expression": ["multiply", 0.0001, 0.0001], + "expects": 0.00000001 + }, + { + "name": "Should multiply string scientific notation with number", + "expression": ["multiply", "1e2", 5], + "expectsFailure": "Expected number, got value \"1e2\"" + }, + { + "name": "Should throw exception when less than two arguments are provided", + "expression": ["multiply", 0.1], + "expectsFailure": "Invalid" + }, + { + "name": "Should throw exception when more than two arguments are present", + "expression": ["multiply", 0.1, 0.2, 15, 1.50], + "expectsFailure": "Invalid" + }, + { + "name": "Should multiply negative and positive decimal numbers", + "expression": ["multiply", -5.55, 6.66], + "expects": -36.963 + }, + { + "name": "Should throw exception when any argument is double max value", + "expression": ["multiply", 0, 1.7976931348623157E+308], + "expectsFailure": "Cannot convert non-finite or out-of-range number to decimal:" + }, + { + "name": "Should throw exception when any argument is double min value", + "expression": ["multiply", -1.7976931348623157E+308, 0], + "expectsFailure": "Cannot convert non-finite or out-of-range number to decimal:" + }, + { + "name": "Should throw exception when any argument is float max value", + "expression": ["multiply", 3.4028235E+38, 0], + "expectsFailure": "Cannot convert non-finite or out-of-range number to decimal:" + }, + { + "name": "Should throw exception when any argument is float min value", + "expression": ["multiply", 0, -3.402823e+38], + "expectsFailure": "Cannot convert non-finite or out-of-range number to decimal:" + } + ] +} diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/plus/plus.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/plus/plus.json new file mode 100644 index 0000000000..21d32e9005 --- /dev/null +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/plus/plus.json @@ -0,0 +1,130 @@ +{ + "name": "Plus tests", + "testCases": [ + { + "name": "Should add the two given numbers together", + "expression": ["plus", 0.1, 0.2], + "expects": 0.3 + }, + { + "name": "Should add two negative numbers together", + "expression": ["plus", -4, -0.2], + "expects": -4.2 + }, + { + "name": "Should return null when both of the arguments are null", + "expression": ["plus", null, null], + "expects": null + }, + { + "name": "Should return null when one of the arguments is null", + "expression": ["plus", 4.3, null], + "expects": null + }, + { + "name": "Should add two integers", + "expression": ["plus", 10, 20], + "expects": 30 + }, + { + "name": "Should add [float, integer]", + "expression": ["plus", 10.5, 20], + "expects": 30.5 + }, + { + "name": "Should add [string, integer] when string is valid number", + "expression": ["plus", "10", 20], + "expects": 30 + }, + { + "name": "Should add [string, float] when string is valid number", + "expression": ["plus", "10.5", 20.3], + "expects": 30.8 + }, + { + "name": "Should add negative and positive numbers", + "expression": ["plus", 50, -30], + "expects": 20 + }, + { + "name": "Should add numbers in scientific notation", + "expression": ["plus", 1e3, 2e3], + "expects": 3000 + }, + { + "name": "Should add numbers with negative exponents", + "expression": ["plus", 1e-3, 2e-3], + "expects": 0.003 + }, + { + "name": "Should add scientific notation with regular number", + "expression": ["plus", 1e2, 50], + "expects": 150 + }, + { + "name": "Should fail with invalid string that cannot be parsed as number", + "expression": ["plus", "not a number", 10], + "expectsFailure": "Expected number, got value \"not a number\"" + }, + { + "name": "Should fail with [boolean, integer]", + "expression": ["plus", true, 10], + "expectsFailure": "Expected number, got value true" + }, + { + "name": "Should fail with string with no decimals after dot", + "expression": ["plus", "55.", 10], + "expectsFailure": "Expected number, got value \"55.\"" + }, + { + "name": "Should handle very large numbers", + "expression": ["plus", 9007199254740991, 1], + "expects": 9007199254740991 + }, + { + "name": "Should handle very small numbers", + "expression": ["plus", 0.0000001, 0.0000002], + "expects": 0.0000003 + }, + { + "name": "Should add string scientific notation with number", + "expression": ["plus", "1e2", 50], + "expectsFailure": "Expected number, got value \"1e2\"" + }, + { + "name": "Should throw exception when less than two arguments are provided", + "expression": ["plus", 0.2], + "expectsFailure": "Invalid" + }, + { + "name": "Should throw exception when more than two arguments are present", + "expression": ["plus", 0.1, 0.2, 15, 1.50], + "expectsFailure": "Invalid" + }, + { + "name": "Should add negative and positive decimal numbers", + "expression": ["plus", 50.55, -30.66], + "expects": 19.89 + }, + { + "name": "Should throw exception when any argument is double max value", + "expression": ["plus", 0, 1.7976931348623157E+308], + "expectsFailure": "Cannot convert non-finite or out-of-range number to decimal:" + }, + { + "name": "Should throw exception when any argument is double min value", + "expression": ["plus", -1.7976931348623157E+308, 0], + "expectsFailure": "Cannot convert non-finite or out-of-range number to decimal:" + }, + { + "name": "Should throw exception when any argument is float max value", + "expression": ["plus", 3.4028235E+38, 0], + "expectsFailure": "Cannot convert non-finite or out-of-range number to decimal:" + }, + { + "name": "Should throw exception when any argument is float min value", + "expression": ["plus", 0, -3.402823e+38], + "expectsFailure": "Cannot convert non-finite or out-of-range number to decimal:" + } + ] +} diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/ExpressionEvaluatorTests/EqualsTests.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/ExpressionEvaluatorTests/EqualsTests.cs index b34947d406..938eb0373d 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/ExpressionEvaluatorTests/EqualsTests.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/ExpressionEvaluatorTests/EqualsTests.cs @@ -50,10 +50,10 @@ public static TheoryData GetNumericTestData(double value) DateTimeOffset.Parse("2025-02-04T13:13:15.84473533+01:00"), DateOnly.FromDateTime(DateTime.Parse("2025-02-04T13:13:15.8447353+01:00")), TimeOnly.FromDateTime(DateTime.Parse("2025-02-04T13:13:15.8447353+01:00")), - ((long)int.MaxValue) + 1, - ((ulong)uint.MaxValue) + 1, - ((decimal)int.MaxValue) + 1, - ((decimal)uint.MaxValue) + 1, + (long)int.MaxValue + 1, + (ulong)uint.MaxValue + 1, + (decimal)int.MaxValue + 1, + (decimal)uint.MaxValue + 1, (double)((decimal)long.MaxValue + 1), (double)((decimal)ulong.MaxValue + 1), }; diff --git a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt index ec33a03324..ac60716416 100644 --- a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt +++ b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt @@ -4577,6 +4577,10 @@ namespace Altinn.App.Core.Models.Expressions gatewayAction = 34, language = 35, text = 36, + plus = 37, + minus = 38, + multiply = 39, + divide = 40, } } namespace Altinn.App.Core.Models.Layout.Components.Base diff --git a/test/Altinn.App.Core.Tests/TestUtils/FileNamesInFolderAttribute.cs b/test/Altinn.App.Core.Tests/TestUtils/FileNamesInFolderAttribute.cs index d7009ccab8..cb1ff3166f 100644 --- a/test/Altinn.App.Core.Tests/TestUtils/FileNamesInFolderAttribute.cs +++ b/test/Altinn.App.Core.Tests/TestUtils/FileNamesInFolderAttribute.cs @@ -1,5 +1,4 @@ using System.Reflection; -using System.Runtime.CompilerServices; using Xunit.Sdk; namespace Altinn.App.Core.Tests.TestUtils; @@ -11,7 +10,7 @@ public FileNamesInFolderDataAttribute(string[] folderParts) public override IEnumerable GetData(MethodInfo testMethod) { - var basePath = AltinnAppTestsBasePath(); + var basePath = TestAttributeHelper.AltinnAppTestsBasePath(); var folder = Path.Join(basePath, folderName); if (!Directory.Exists(folder)) { @@ -27,24 +26,4 @@ public override IEnumerable GetData(MethodInfo testMethod) } ); } - - private static string AltinnAppTestsBasePath([CallerFilePath] string? callerFilePath = null) - { - if (callerFilePath is null) - { - throw new Exception("Caller path is null"); - } - var testUtilsDirectoryPath = Path.GetDirectoryName(callerFilePath); - if (testUtilsDirectoryPath is null) - { - throw new Exception("Caller path is null"); - } - var callerDirectoryPath = Path.GetDirectoryName(testUtilsDirectoryPath); - if (callerDirectoryPath is null) - { - throw new Exception("Caller path is null"); - } - - return callerDirectoryPath; - } } diff --git a/test/Altinn.App.Core.Tests/TestUtils/TestAttributeHelper.cs b/test/Altinn.App.Core.Tests/TestUtils/TestAttributeHelper.cs new file mode 100644 index 0000000000..27de2819c3 --- /dev/null +++ b/test/Altinn.App.Core.Tests/TestUtils/TestAttributeHelper.cs @@ -0,0 +1,26 @@ +using System.Runtime.CompilerServices; + +namespace Altinn.App.Core.Tests.TestUtils; + +public static class TestAttributeHelper +{ + public static string AltinnAppTestsBasePath([CallerFilePath] string? callerFilePath = null) + { + if (callerFilePath is null) + { + throw new Exception("Caller path is null"); + } + var testUtilsDirectoryPath = Path.GetDirectoryName(callerFilePath); + if (testUtilsDirectoryPath is null) + { + throw new Exception("Caller path is null"); + } + var callerDirectoryPath = Path.GetDirectoryName(testUtilsDirectoryPath); + if (callerDirectoryPath is null) + { + throw new Exception("Caller path is null"); + } + + return callerDirectoryPath; + } +} diff --git a/test/Altinn.App.Core.Tests/TestUtils/TestCasesAttribute.cs b/test/Altinn.App.Core.Tests/TestUtils/TestCasesAttribute.cs new file mode 100644 index 0000000000..8fb329e0cf --- /dev/null +++ b/test/Altinn.App.Core.Tests/TestUtils/TestCasesAttribute.cs @@ -0,0 +1,38 @@ +using System.Reflection; +using System.Text.Json; +using Altinn.App.Core.Tests.LayoutExpressions.CommonTests; +using Xunit.Sdk; + +namespace Altinn.App.Core.Tests.TestUtils; + +public class TestCasesAttribute(string folderName) : DataAttribute +{ + private static readonly JsonSerializerOptions _jsonSerializerOptions = new() + { + ReadCommentHandling = JsonCommentHandling.Skip, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + public override IEnumerable GetData(MethodInfo testMethod) + { + var basePath = TestAttributeHelper.AltinnAppTestsBasePath(); + var folder = Path.Join(basePath, folderName); + if (!Directory.Exists(folder)) + { + throw new DirectoryNotFoundException($"Folder not found: {folder}"); + } + var files = Directory.GetFiles(folder); + var theoryData = new List(); + foreach (var file in files) + { + var data = File.ReadAllText(file); + var rootCases = JsonSerializer.Deserialize(data, _jsonSerializerOptions)!; + if (rootCases.TestCases is not null) + { + theoryData.AddRange(rootCases.TestCases); + } + } + + return theoryData.Select(x => new object[] { x.Name ?? string.Empty, x }); + } +}