Skip to content
Open
Show file tree
Hide file tree
Changes from 53 commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
4fa6d7f
Added plus and minus functions.
olavsorl Jan 12, 2026
a58fd83
Merge branch 'main' into feature/expressions-with-list-and-object-sup…
olavsorl Jan 20, 2026
bc237c8
Added multiply and devide
olavsorl Jan 28, 2026
65fd994
Added average and more unit tests for function
olavsorl Jan 28, 2026
4882838
Renamed devide to divide
olavsorl Jan 28, 2026
4f04237
Double fix
olavsorl Feb 2, 2026
45396f2
Fixed unit tests
olavsorl Feb 2, 2026
e2ee639
Implemented CodeRabbits suggested changes
olavsorl Feb 3, 2026
fe071e5
Merge branch 'main' into feature/expressions-with-list-and-object-sup…
olavsorl Feb 3, 2026
3528b68
Changed to decimal return type
olavsorl Feb 3, 2026
1cd8211
Implementes suggested changes and added unit tests
olavsorl Feb 3, 2026
364e4b8
improved unit test names and added min value tests
olavsorl Feb 3, 2026
2a01a10
Changed from accepting multiple arguments to just accept two. Removed…
olavsorl Feb 9, 2026
8b89eae
Merge branch 'main' into feature/expressions-with-list-and-object-sup…
olavsorl Feb 9, 2026
5d427f5
Fixed unit tests and consistancy in functions
olavsorl Feb 9, 2026
fdcb872
Merge remote-tracking branch 'origin/feature/expressions-with-list-an…
olavsorl Feb 9, 2026
efd3168
Fixed exception message
olavsorl Feb 9, 2026
a90381c
Fixed test name
olavsorl Feb 9, 2026
46f6067
Implemented suggested changes
olavsorl Feb 11, 2026
1c226fb
Made shared test json file names more consistant
olavsorl Feb 11, 2026
f78d423
Added same-types-negative-positive-decimal tests for consistency with…
olavsorl Feb 11, 2026
9d9d715
Implemented suggested changes by code rabbit
olavsorl Feb 11, 2026
0c11ff4
Implemented suggested changes from code rabbit
olavsorl Feb 11, 2026
1debfe3
Changed to single json file for each arithmetic operation.
olavsorl Feb 11, 2026
362899e
Reverted change to ExpressionValue
olavsorl Feb 11, 2026
200b432
Fixes
olavsorl Feb 12, 2026
5258676
Improved support for unit tests nested in testCases in shared json te…
olavsorl Feb 13, 2026
e63e093
Merge branch 'main' into feature/expressions-with-list-and-object-sup…
olavsorl Feb 13, 2026
597fcaf
Reverted change making expression nullable in ExpressionTestCaseRoot
olavsorl Feb 13, 2026
1255046
Merge remote-tracking branch 'origin/feature/expressions-with-list-an…
olavsorl Feb 13, 2026
eb1220e
Added tests for large numbers formatted as string
olavsorl Feb 16, 2026
374ffaa
Implemented suggested changes by code rabbit
olavsorl Feb 16, 2026
b865d6b
Merge branch 'main' into feature/expressions-with-list-and-object-sup…
olavsorl Feb 16, 2026
299843b
Fixed unit test
olavsorl Feb 16, 2026
3020c24
Merge remote-tracking branch 'origin/feature/expressions-with-list-an…
olavsorl Feb 16, 2026
9d08787
Cleanup
olavsorl Feb 16, 2026
79189e2
Implemented suggested changes by code rabbit
olavsorl Feb 16, 2026
30a9c03
Implemented suggested changes by code rabbit
olavsorl Feb 17, 2026
ae83fb6
Implemented suggested changes
olavsorl Feb 19, 2026
3bc8d30
Made some changes to the max and min limits
olavsorl Feb 19, 2026
a191f76
Implemented suggested changes by code rabbit
olavsorl Feb 19, 2026
51c3ed4
Reverted requested change by code rabbit
olavsorl Feb 19, 2026
aee514a
Merge branch 'main' into feature/expressions-with-list-and-object-sup…
olavsorl Feb 19, 2026
5fbae42
Change to now converting string to double before converting to decima…
olavsorl Feb 20, 2026
90db3c3
Merge remote-tracking branch 'origin/feature/expressions-with-list-an…
olavsorl Feb 20, 2026
0ac8517
Added unit tests also for other functions calling PrepareNumericArgs
olavsorl Feb 23, 2026
a6a869b
Merge branch 'main' into feature/expressions-with-list-and-object-sup…
olavsorl Feb 23, 2026
ffcf72e
Fixed tests for greaterThan og greaterThanEq
olavsorl Feb 23, 2026
cd81b26
Merge remote-tracking branch 'origin/feature/expressions-with-list-an…
olavsorl Feb 23, 2026
776e361
Implemented suggested change from code rabbit
olavsorl Feb 23, 2026
150181d
Implemented suggested code rabbit changes
olavsorl Feb 23, 2026
c6fed3e
Implemented suggested changes by Code Rabbit
olavsorl Feb 23, 2026
5690e42
Implemented suggested changes
olavsorl Feb 24, 2026
a225e5f
Merge branch 'main' into feature/expressions-with-list-and-object-sup…
olavsorl Feb 24, 2026
760aef0
Merge branch 'main' into feature/expressions-with-list-and-object-sup…
olavsorl Mar 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 71 additions & 5 deletions src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ namespace Altinn.App.Core.Internal.Expressions;
/// <summary>
/// Static class used to evaluate expressions. Holds the implementation for all expression functions.
/// </summary>
public static class ExpressionEvaluator
public static partial class ExpressionEvaluator
{
/// <summary>
/// Shortcut for evaluating a boolean expression on a given property on a <see cref="Models.Layout.Components.Base.BaseComponent" />
Expand Down Expand Up @@ -127,6 +127,10 @@ internal static async Task<ExpressionValue> 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}"
),
Expand Down Expand Up @@ -810,7 +814,7 @@ private static (double?, double?) PrepareNumericArgs(ExpressionValue[] args)
{
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]);
Expand Down Expand Up @@ -861,11 +865,12 @@ private static ExpressionValue IfImpl(ExpressionValue[] args)
);
}

private static readonly Regex _numberRegex = new Regex(@"^-?\d+(\.\d+)?$");

/// <summary>
/// Parses a number from a string representation.
/// </summary>
internal static double? ParseNumber(string s, bool throwException = true)
{
if (_numberRegex.IsMatch(s) && double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var d))
if (NumberRegex().IsMatch(s) && double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var d))
{
return d;
}
Expand All @@ -888,6 +893,30 @@ 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);
return PerformArithmetic(a, b, (x, y) => x / y);
}

private static bool LessThanEq(ExpressionValue[] args)
{
var (a, b) = PrepareNumericArgs(args);
Expand Down Expand Up @@ -960,4 +989,41 @@ private static ExpressionValue Argv(ExpressionValue[] args, ExpressionValue[]? p

return positionalArguments[index.Value];
}

/// <summary>
/// Performs arithmetic operation using decimal precision to avoid floating point precision issues.
/// Converts doubles to decimal, performs the operation, and converts back to double.
/// </summary>
/// <param name="a">First operand</param>
/// <param name="b">Second operand</param>
/// <param name="operation">Function that performs the arithmetic operation on two decimals</param>
/// <returns>Result of the operation as double, or null if any operand is null</returns>
private static double? PerformArithmetic(double? a, double? b, Func<decimal, decimal, decimal> operation)
{
if (a.HasValue is false || b.HasValue is false)
{
return null;
}

try
{
var aDecimal = (decimal)a.Value;
var bDecimal = (decimal)b.Value;
var result = operation(aDecimal, bDecimal);
return (double)result;
}
catch (OverflowException)
{
throw new ExpressionEvaluatorTypeErrorException(
$"Arithmetic overflow: {a.Value} and {b.Value} or operation on them exceeds the supported range"
);
}
catch (DivideByZeroException)
{
throw new ExpressionEvaluatorTypeErrorException("The second argument is 0, cannot divide by 0");
}
}

[GeneratedRegex(@"^[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?$")]
private static partial Regex NumberRegex();
}
6 changes: 2 additions & 4 deletions src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down
20 changes: 20 additions & 0 deletions src/Altinn.App.Core/Models/Expressions/ExpressionFunction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -205,4 +205,24 @@ public enum ExpressionFunction
/// If no translations exist for the current language, we will use the resources for "nb"
/// </summary>
text,

/// <summary>
/// Adding numbers. Must be numeric values.
/// </summary>
plus,

/// <summary>
/// Subtracting all preceding values from the first. Must be numeric values.
/// </summary>
minus,

/// <summary>
/// Multiplying numbers. Must be numeric values.
/// </summary>
multiply,

/// <summary>
/// Divide numbers. Must be numeric values.
/// </summary>
divide,
}
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

Expand Down Expand Up @@ -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; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -237,10 +257,15 @@ private static async Task<ExpressionTestCaseRoot> 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<DataType> dataTypes = new();
Expand Down Expand Up @@ -316,16 +341,16 @@ private async Task RunTestCase(string testName, string folder)
componentModel = new LayoutModel([layout], null);
}

var appRewourcesMock = new Mock<IAppResources>(MockBehavior.Strict);
var appResourcesMock = new Mock<IAppResources>(MockBehavior.Strict);

var language = test.ProfileSettings?.Language ?? "nb";
appRewourcesMock
appResourcesMock
.Setup(ar => ar.GetTexts(It.IsAny<string>(), It.IsAny<string>(), language))
.ReturnsAsync(new TextResource() { Resources = test.TextResources ?? [] });

var translationService = new TranslationService(
new Core.Models.AppIdentifier("org", "app"),
appRewourcesMock.Object,
appResourcesMock.Object,
FakeLoggerXunit.Get<TranslationService>(_output)
);

Expand Down Expand Up @@ -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}");
Expand Down Expand Up @@ -422,7 +448,6 @@ private async Task RunTestCaseItem(
break;
case JsonValueKind.True:
Assert.True(result as bool?);
result.Should().Be(true);
break;
case JsonValueKind.False:
Assert.False(result as bool?);
Expand Down Expand Up @@ -457,7 +482,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
)
Expand All @@ -473,3 +501,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)) { }
Loading
Loading