From 43f09b3ce8bdaf95f34b3db16f69d7993fb9d8dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olav=20S=C3=B8rlie?= Date: Mon, 9 Feb 2026 14:26:56 +0100 Subject: [PATCH 1/9] POC --- .../Configuration/AppSettings.cs | 18 +- .../DataFieldValueCalculator.cs | 316 ++++++++++++++++++ .../Telemetry.ApplicationMetadata.Service.cs | 3 + .../Validation/Default/ExpressionValidator.cs | 40 +-- .../Implementation/AppResourcesSI.cs | 17 + .../Internal/App/IAppResources.cs | 6 + .../Expressions/ExpressionEvaluator.cs | 14 + .../Internal/Expressions/ExpressionHelper.cs | 42 +++ .../Models/RawDataFieldValueCalculation.cs | 30 ++ 9 files changed, 443 insertions(+), 43 deletions(-) create mode 100644 src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculator.cs create mode 100644 src/Altinn.App.Core/Internal/Expressions/ExpressionHelper.cs create mode 100644 src/Altinn.App.Core/Models/RawDataFieldValueCalculation.cs diff --git a/src/Altinn.App.Core/Configuration/AppSettings.cs b/src/Altinn.App.Core/Configuration/AppSettings.cs index 447bf32b65..2109a4fc88 100644 --- a/src/Altinn.App.Core/Configuration/AppSettings.cs +++ b/src/Altinn.App.Core/Configuration/AppSettings.cs @@ -8,14 +8,19 @@ public class AppSettings { #pragma warning disable CA1707 // Identifiers should not contain underscores /// - /// Constant for the location of json schema file + /// Constant for the suffix on json schema file names /// public const string JSON_SCHEMA_FILENAME = "schema.json"; /// - /// Constant for the location of validation configuration file + /// Constant for the suffix on validation file names /// public const string VALIDATION_CONFIG_FILENAME = "validation.json"; + + /// + /// Constant for the suffix on calculation file names + /// + public const string CALCULATION_CONFIG_FILENAME = "calculation.json"; #pragma warning restore CA1707 // Identifiers should not contain underscores /// @@ -95,15 +100,20 @@ public class AppSettings public string RuleConfigurationJSONFileName { get; set; } = "RuleConfiguration.json"; /// - /// Gets or sets The JSON schema file name + /// Gets or sets the file names suffix for the json schema files /// public string JsonSchemaFileName { get; set; } = JSON_SCHEMA_FILENAME; /// - /// Gets or sets The JSON schema file name + /// Gets or sets the file names suffix for the validation files /// public string ValidationConfigurationFileName { get; set; } = VALIDATION_CONFIG_FILENAME; + /// + /// Gets or sets the file names suffix for the calculation files + /// + public string CalculationConfigurationFileName { get; set; } = CALCULATION_CONFIG_FILENAME; + /// /// Gets or sets the filename for application meta data /// diff --git a/src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculator.cs b/src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculator.cs new file mode 100644 index 0000000000..41236ab5ad --- /dev/null +++ b/src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculator.cs @@ -0,0 +1,316 @@ +using System.Text.Json; +using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Internal.Data; +using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Models; +using Altinn.App.Core.Models.Layout; +using Altinn.Platform.Storage.Interface.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ComponentContext = Altinn.App.Core.Models.Expressions.ComponentContext; + +namespace Altinn.App.Core.Features.DataProcessing; + +public class DataFieldValueCalculator +{ + private static readonly JsonSerializerOptions _jsonSerializerOptions = new() + { + ReadCommentHandling = JsonCommentHandling.Skip, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + private readonly ILogger _logger; + private readonly IAppResources _appResourceService; + private readonly ILayoutEvaluatorStateInitializer _layoutEvaluatorStateInitializer; + private readonly IDataElementAccessChecker _dataElementAccessChecker; + private readonly IFormDataWrapper _formDataWrapper; + + public DataFieldValueCalculator( + ILogger logger, + ILayoutEvaluatorStateInitializer layoutEvaluatorStateInitializer, + IAppResources appResourceService, + IServiceProvider serviceProvider, + IFormDataWrapper formDataWrapper + ) + { + _logger = logger; + _appResourceService = appResourceService; + _layoutEvaluatorStateInitializer = layoutEvaluatorStateInitializer; + _dataElementAccessChecker = serviceProvider.GetRequiredService(); + _formDataWrapper = formDataWrapper; + } + + public async Task Calculate(IInstanceDataAccessor dataAccessor, string taskId) + { + foreach (var (dataType, dataElement) in dataAccessor.GetDataElementsWithFormDataForTask(taskId)) + { + if (await _dataElementAccessChecker.CanRead(dataAccessor.Instance, dataType) is false) + { + continue; + } + + var calculationConfig = _appResourceService.GetCalculationConfiguration(dataType.Id); + if (!string.IsNullOrEmpty(calculationConfig)) + { + await CalculateFormData(dataAccessor, dataElement, taskId, calculationConfig); + } + } + } + + internal async Task CalculateFormData( + IInstanceDataAccessor dataAccessor, + DataElement dataElement, + string taskId, + string rawCalculationConfig + ) + { + var evaluatorState = await _layoutEvaluatorStateInitializer.Init(dataAccessor, taskId); + var hiddenFields = await LayoutEvaluator.GetHiddenFieldsForRemoval( + evaluatorState, + evaluateRemoveWhenHidden: false + ); + DataElementIdentifier dataElementIdentifier = dataElement; + var dataFieldCalculations = ParseDataFieldCalculationConfig(rawCalculationConfig, _logger); + + foreach (var (baseField, calculations) in dataFieldCalculations) + { + var resolvedFields = await evaluatorState.GetResolvedKeys( + new DataReference() { Field = baseField, DataElementIdentifier = dataElementIdentifier } + ); + foreach (var resolvedField in resolvedFields) + { + if ( + hiddenFields.Exists(d => + d.DataElementIdentifier == resolvedField.DataElementIdentifier + && resolvedField.Field.StartsWith(d.Field, StringComparison.InvariantCulture) + ) + ) + { + continue; + } + + var context = new ComponentContext( + evaluatorState, + component: null, + rowIndices: ExpressionHelper.GetRowIndices(resolvedField.Field), + dataElementIdentifier: resolvedField.DataElementIdentifier + ); + var positionalArguments = new object[] { resolvedField.Field }; + foreach (var calculation in calculations) + { + await RunCalculation(evaluatorState, resolvedField, context, positionalArguments, calculation); + } + } + } + } + + private async Task RunCalculation( + LayoutEvaluatorState evaluatorState, + DataReference resolvedField, + ComponentContext context, + object[] positionalArguments, + DataFieldCalculation calculation + ) + { + try + { + var calculationResult = await ExpressionEvaluator.EvaluateExpressionToExpressionValue( + evaluatorState, + calculation.Condition, + context, + positionalArguments + ); + _formDataWrapper.Set(resolvedField.Field.ToArray(), calculationResult); + } + catch (Exception e) + { + _logger.LogError(e, "Error while evaluating calculation for field {field}", resolvedField.Field); + throw; + } + } + + private Dictionary> ParseDataFieldCalculationConfig( + string rawCalculationConfig, + ILogger logger + ) + { + using var calculationConfigDocument = JsonDocument.Parse(rawCalculationConfig); + var calculationDefinitions = new Dictionary(); + var hasDefinitions = calculationConfigDocument.RootElement.TryGetProperty( + "definitions", + out JsonElement definitionsObject + ); + if (hasDefinitions) + { + foreach (var definitionProperty in definitionsObject.EnumerateObject()) + { + var resolvedDefinition = ResolveCalculationDefinition( + definitionProperty, + calculationDefinitions, + logger + ); + if (resolvedDefinition == null) + { + logger.LogError("Calculation definition {name} could not be resolved", definitionProperty.Name); + continue; + } + calculationDefinitions[definitionProperty.Name] = resolvedDefinition; + } + } + + var dataFieldCalculations = new Dictionary>(); + var hasCalculations = calculationConfigDocument.RootElement.TryGetProperty( + "calculations", + out JsonElement calculationsObject + ); + if (hasCalculations) + { + foreach (var calculationArray in calculationsObject.EnumerateObject()) + { + var field = calculationArray.Name; + var calculations = calculationArray.Value; + foreach (var calculation in calculations.EnumerateArray()) + { + if (!dataFieldCalculations.TryGetValue(field, out var dataFieldCalculation)) + { + dataFieldCalculation = new List(); + dataFieldCalculations[field] = dataFieldCalculation; + } + var resolvedDataFieldCalculation = ResolveDataFieldCalculation( + field, + calculation, + calculationDefinitions, + logger + ); + if (resolvedDataFieldCalculation == null) + { + logger.LogError("Calculation for field {field} could not be resolved", field); + continue; + } + dataFieldCalculation.Add(resolvedDataFieldCalculation); + } + } + } + return dataFieldCalculations; + } + + private DataFieldCalculation? ResolveDataFieldCalculation( + string field, + JsonElement definition, + Dictionary resolvedDefinitions, + ILogger logger + ) + { + var rawDataFieldValueCalculation = new RawDataFieldValueCalculation(); + + if (definition.ValueKind == JsonValueKind.String) + { + var stringReference = definition.GetString(); + if (stringReference == null) + { + logger.LogError("Could not resolve null reference for calculation for field {field}", field); + return null; + } + + var reference = resolvedDefinitions.GetValueOrDefault(stringReference); + if (reference == null) + { + logger.LogError( + "Could not resolve reference {stringReference} for calculation for field {field}", + stringReference, + field + ); + return null; + } + rawDataFieldValueCalculation.Condition = reference.Condition; + } + else + { + var dataFieldCalculationDefinition = definition.Deserialize( + _jsonSerializerOptions + ); + if (dataFieldCalculationDefinition == null) + { + logger.LogError("Calculation for field {field} could not be parsed", field); + return null; + } + + if (dataFieldCalculationDefinition.Ref != null) + { + var reference = resolvedDefinitions.GetValueOrDefault(dataFieldCalculationDefinition.Ref); + if (reference == null) + { + logger.LogError( + "Could not resolve reference {expressionDefinitionRef} for calculation for field {field}", + dataFieldCalculationDefinition.Ref, + field + ); + return null; + } + rawDataFieldValueCalculation.Condition = reference.Condition; + } + + if (dataFieldCalculationDefinition.Condition != null) + { + rawDataFieldValueCalculation.Condition = dataFieldCalculationDefinition.Condition; + } + } + + if (rawDataFieldValueCalculation.Condition == null) + { + logger.LogError("Calculation for field {field} is missing condition", field); + return null; + } + + var dataFieldCalculation = new DataFieldCalculation + { + Condition = rawDataFieldValueCalculation.Condition.Value, + }; + + return dataFieldCalculation; + } + + private static RawDataFieldValueCalculation? ResolveCalculationDefinition( + JsonProperty definitionProperty, + Dictionary resolvedDefinitions, + ILogger logger + ) + { + var resolvedDefinition = new RawDataFieldValueCalculation(); + var rawDefinition = definitionProperty.Value.Deserialize(_jsonSerializerOptions); + if (rawDefinition == null) + { + logger.LogError("Calculation definition {name} could not be parsed", definitionProperty.Name); + return null; + } + + if (rawDefinition.Ref != null) + { + var reference = resolvedDefinitions.GetValueOrDefault(rawDefinition.Ref); + if (reference == null) + { + logger.LogError( + "Could not resolve reference {rawDefinitionRef} for calculation {name}", + rawDefinition.Ref, + definitionProperty.Name + ); + return null; + } + + resolvedDefinition.Condition = reference.Condition; + } + + if (rawDefinition.Condition != null) + { + resolvedDefinition.Condition = rawDefinition.Condition; + } + + if (resolvedDefinition.Condition == null) + { + logger.LogError("Calculation {name} is missing condition", definitionProperty.Name); + return null; + } + + return resolvedDefinition; + } +} diff --git a/src/Altinn.App.Core/Features/Telemetry/Telemetry.ApplicationMetadata.Service.cs b/src/Altinn.App.Core/Features/Telemetry/Telemetry.ApplicationMetadata.Service.cs index 399cb752f8..ed7a7ae451 100644 --- a/src/Altinn.App.Core/Features/Telemetry/Telemetry.ApplicationMetadata.Service.cs +++ b/src/Altinn.App.Core/Features/Telemetry/Telemetry.ApplicationMetadata.Service.cs @@ -49,6 +49,9 @@ partial class Telemetry internal Activity? StartGetValidationConfigurationActivity() => ActivitySource.StartActivity($"{Prefix}.GetValidationConfiguration"); + internal Activity? StartGetCalculationConfigurationActivity() => + ActivitySource.StartActivity($"{Prefix}.GetCalculationConfiguration"); + internal Activity? StartGetLayoutModelActivity() => ActivitySource.StartActivity($"{Prefix}.GetLayoutModel"); internal Activity? StartGetClassRefActivity() => ActivitySource.StartActivity($"{Prefix}.GetClassRef"); diff --git a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs index 3defcd9177..899b05ecfb 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs @@ -147,7 +147,7 @@ internal async Task> ValidateFormData( var context = new ComponentContext( evaluatorState, component: null, - rowIndices: GetRowIndices(resolvedField.Field), + rowIndices: ExpressionHelper.GetRowIndices(resolvedField.Field), dataElementIdentifier: resolvedField.DataElementIdentifier ); var positionalArguments = new object[] { resolvedField.Field }; @@ -168,44 +168,6 @@ await RunValidation( return validationIssues; } - private static int[]? GetRowIndices(string field) - { - Span rowIndicesSpan = stackalloc int[200]; // Assuming max 200 indices for simplicity recursion will never go deeper than 3-4 - int count = 0; - for (int index = 0; index < field.Length; index++) - { - if (field[index] == '[') - { - int startIndex = index + 1; - int endIndex = field.IndexOf(']', startIndex); - if (endIndex == -1) - { - throw new InvalidOperationException($"Unpaired [ character in field: {field}"); - } - string indexString = field[startIndex..endIndex]; - if (int.TryParse(indexString, out int rowIndex)) - { - rowIndicesSpan[count] = rowIndex; - count++; - index = endIndex; // Move index to the end of the current bracket - } - else - { - throw new InvalidOperationException( - $"Invalid row index in field: {field} at position {startIndex}" - ); - } - } - } - if (count == 0) - { - return null; // No indices found - } - int[] rowIndices = new int[count]; - rowIndicesSpan[..count].CopyTo(rowIndices); - return rowIndices; - } - private async Task RunValidation( LayoutEvaluatorState evaluatorState, List validationIssues, diff --git a/src/Altinn.App.Core/Implementation/AppResourcesSI.cs b/src/Altinn.App.Core/Implementation/AppResourcesSI.cs index 95be38aa60..2d36cb2524 100644 --- a/src/Altinn.App.Core/Implementation/AppResourcesSI.cs +++ b/src/Altinn.App.Core/Implementation/AppResourcesSI.cs @@ -523,4 +523,21 @@ private static byte[] ReadFileContentsFromLegalPath(string legalPath, string fil return filedata; } + + /// + public string? GetCalculationConfiguration(string dataTypeId) + { + using var activity = _telemetry?.StartGetCalculationConfigurationActivity(); + string legalPath = Path.Join(_settings.AppBasePath, _settings.ModelsFolder); + string filename = Path.Join(legalPath, $"{dataTypeId}.{_settings.CalculationConfigurationFileName}"); + PathHelper.EnsureLegalPath(legalPath, filename); + + string? fileData = null; + if (File.Exists(filename)) + { + fileData = File.ReadAllText(filename, Encoding.UTF8); + } + + return fileData; + } } diff --git a/src/Altinn.App.Core/Internal/App/IAppResources.cs b/src/Altinn.App.Core/Internal/App/IAppResources.cs index 6a08382f28..d6fd507522 100644 --- a/src/Altinn.App.Core/Internal/App/IAppResources.cs +++ b/src/Altinn.App.Core/Internal/App/IAppResources.cs @@ -166,4 +166,10 @@ public interface IAppResources /// Gets the validation configuration for a given data type /// string? GetValidationConfiguration(string dataTypeId); + + /// + /// Gets the calculation configuration for a given data type + /// + /// The calculation configuration in JSON format represented as string + string? GetCalculationConfiguration(string dataTypeId); } diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs index 8f4d5d8735..64b5f3e745 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs @@ -51,6 +51,20 @@ bool defaultReturn } } + /// + /// Evaluate a from a given in a + /// + public static async Task EvaluateExpressionToExpressionValue( + LayoutEvaluatorState state, + Expression expr, + ComponentContext context, + object?[]? positionalArguments = null + ) + { + var positionalArgumentUnions = positionalArguments?.Select(ExpressionValue.FromObject).ToArray(); + return await EvaluateExpression_internal(state, expr, context, positionalArgumentUnions); + } + /// /// Evaluate a from a given in a /// diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionHelper.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionHelper.cs new file mode 100644 index 0000000000..84601f30f6 --- /dev/null +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionHelper.cs @@ -0,0 +1,42 @@ +namespace Altinn.App.Core.Internal.Expressions; + +internal static class ExpressionHelper +{ + internal static int[]? GetRowIndices(string field) + { + Span rowIndicesSpan = stackalloc int[200]; // Assuming max 200 indices for simplicity recursion will never go deeper than 3-4 + int count = 0; + for (int index = 0; index < field.Length; index++) + { + if (field[index] == '[') + { + int startIndex = index + 1; + int endIndex = field.IndexOf(']', startIndex); + if (endIndex == -1) + { + throw new InvalidOperationException($"Unpaired [ character in field: {field}"); + } + string indexString = field[startIndex..endIndex]; + if (int.TryParse(indexString, out int rowIndex)) + { + rowIndicesSpan[count] = rowIndex; + count++; + index = endIndex; // Move index to the end of the current bracket + } + else + { + throw new InvalidOperationException( + $"Invalid row index in field: {field} at position {startIndex}" + ); + } + } + } + if (count == 0) + { + return null; // No indices found + } + int[] rowIndices = new int[count]; + rowIndicesSpan[..count].CopyTo(rowIndices); + return rowIndices; + } +} diff --git a/src/Altinn.App.Core/Models/RawDataFieldValueCalculation.cs b/src/Altinn.App.Core/Models/RawDataFieldValueCalculation.cs new file mode 100644 index 0000000000..7d1121e87a --- /dev/null +++ b/src/Altinn.App.Core/Models/RawDataFieldValueCalculation.cs @@ -0,0 +1,30 @@ +using Altinn.App.Core.Models.Expressions; + +namespace Altinn.App.Core.Models; + +/// +/// Resolved data field calculation +/// +public class DataFieldCalculation +{ + /// + /// Condition to evaluate + /// + public Expression Condition { get; set; } +} + +/// +/// Raw value calculation expression from the calculaiton configuration file +/// +public class RawDataFieldValueCalculation +{ + /// + /// Condition to evaluate + /// + public Expression? Condition { get; set; } + + /// + /// Reference to expression definitions + /// + public string? Ref { get; set; } +} From 03339cd67cca63dd9242b7d81dee8b3817965314 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olav=20S=C3=B8rlie?= Date: Tue, 10 Feb 2026 15:54:01 +0100 Subject: [PATCH 2/9] Added data processor --- .../Extensions/ServiceCollectionExtensions.cs | 2 + .../DataLists/InstanceDataListsFactory.cs | 2 +- .../DataFieldValueCalculator.cs | 18 ++++++--- .../DataFieldValueCalculatorProcessor.cs | 38 +++++++++++++++++++ .../DataProcessing/GenericDataProcessor.cs | 2 +- 5 files changed, 54 insertions(+), 8 deletions(-) create mode 100644 src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculatorProcessor.cs diff --git a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs index 0e4dc047f4..2ee392bf02 100644 --- a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs @@ -180,6 +180,8 @@ IWebHostEnvironment env #pragma warning restore CS0618, CS0612 // Type or member is obsolete services.TryAddTransient(); services.TryAddTransient(); + services.TryAddTransient(); + services.TryAddTransient(); services.TryAddTransient(); services.TryAddTransient(); services.TryAddTransient(); diff --git a/src/Altinn.App.Core/Features/DataLists/InstanceDataListsFactory.cs b/src/Altinn.App.Core/Features/DataLists/InstanceDataListsFactory.cs index f04d517c45..a78f276089 100644 --- a/src/Altinn.App.Core/Features/DataLists/InstanceDataListsFactory.cs +++ b/src/Altinn.App.Core/Features/DataLists/InstanceDataListsFactory.cs @@ -11,7 +11,7 @@ public class InstanceDataListsFactory private readonly AppImplementationFactory _appImplementationFactory; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// public InstanceDataListsFactory(IServiceProvider serviceProvider) { diff --git a/src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculator.cs b/src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculator.cs index 41236ab5ad..4c37508ac8 100644 --- a/src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculator.cs +++ b/src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculator.cs @@ -23,21 +23,18 @@ public class DataFieldValueCalculator private readonly IAppResources _appResourceService; private readonly ILayoutEvaluatorStateInitializer _layoutEvaluatorStateInitializer; private readonly IDataElementAccessChecker _dataElementAccessChecker; - private readonly IFormDataWrapper _formDataWrapper; public DataFieldValueCalculator( ILogger logger, ILayoutEvaluatorStateInitializer layoutEvaluatorStateInitializer, IAppResources appResourceService, - IServiceProvider serviceProvider, - IFormDataWrapper formDataWrapper + IServiceProvider serviceProvider ) { _logger = logger; _appResourceService = appResourceService; _layoutEvaluatorStateInitializer = layoutEvaluatorStateInitializer; _dataElementAccessChecker = serviceProvider.GetRequiredService(); - _formDataWrapper = formDataWrapper; } public async Task Calculate(IInstanceDataAccessor dataAccessor, string taskId) @@ -98,13 +95,22 @@ string rawCalculationConfig var positionalArguments = new object[] { resolvedField.Field }; foreach (var calculation in calculations) { - await RunCalculation(evaluatorState, resolvedField, context, positionalArguments, calculation); + var formDataWrapper = await dataAccessor.GetFormDataWrapper(dataElement); + await RunCalculation( + formDataWrapper, + evaluatorState, + resolvedField, + context, + positionalArguments, + calculation + ); } } } } private async Task RunCalculation( + IFormDataWrapper formDataWrapper, LayoutEvaluatorState evaluatorState, DataReference resolvedField, ComponentContext context, @@ -120,7 +126,7 @@ DataFieldCalculation calculation context, positionalArguments ); - _formDataWrapper.Set(resolvedField.Field.ToArray(), calculationResult); + formDataWrapper.Set(resolvedField.Field.ToArray(), calculationResult); } catch (Exception e) { diff --git a/src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculatorProcessor.cs b/src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculatorProcessor.cs new file mode 100644 index 0000000000..2217b7283f --- /dev/null +++ b/src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculatorProcessor.cs @@ -0,0 +1,38 @@ +using Altinn.App.Core.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace Altinn.App.Core.Features.DataProcessing; + +/// +/// Processing data fields values that is calculated by expressions provided in [modelName].calculation.json. +/// +public class DataFieldValueCalculatorProcessor : IDataWriteProcessor +{ + private readonly DataFieldValueCalculator _dataFieldValueCalculator; + + /// + /// Initializes a new instance of the class. + /// + /// + public DataFieldValueCalculatorProcessor(IServiceProvider serviceProvider) + { + _dataFieldValueCalculator = serviceProvider.GetRequiredService(); + } + + /// + /// Processes data write operations on properties in the data model. + /// + /// Object to fetch data elements not included in changes + /// The current task ID + /// Not used in this context + /// Not used in this context + public async Task ProcessDataWrite( + IInstanceDataMutator instanceDataMutator, + string taskId, + DataElementChanges changes, + string? language + ) + { + await _dataFieldValueCalculator.Calculate(instanceDataMutator, taskId); + } +} diff --git a/src/Altinn.App.Core/Features/DataProcessing/GenericDataProcessor.cs b/src/Altinn.App.Core/Features/DataProcessing/GenericDataProcessor.cs index 317191959c..acc961d743 100644 --- a/src/Altinn.App.Core/Features/DataProcessing/GenericDataProcessor.cs +++ b/src/Altinn.App.Core/Features/DataProcessing/GenericDataProcessor.cs @@ -16,7 +16,7 @@ public abstract class GenericDataProcessor : IDataProcessor /// /// Do changes to the model before it is written to storage, and report back to frontend. - /// Tyipically used to add calculated values to the model. + /// Typically used to add calculated values to the model. /// public abstract Task ProcessDataWrite( Instance instance, From dd637ed8a0fa169377d0014641dbdd1919904a71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olav=20S=C3=B8rlie?= Date: Thu, 5 Mar 2026 14:10:25 +0100 Subject: [PATCH 3/9] Modified logic in DataModelWrapper so that the GetResolvedKeysRecursive method can be used during calculation of data fields using expressions. Added unit tests for the DataFieldValueCalculator --- .../DataFieldValueCalculator.cs | 15 +- .../Helpers/DataModel/DataModelWrapper.cs | 117 ++++++--- .../Internal/Data/IFormDataWrapper.cs | 8 +- .../Internal/Expressions/ExpressionHelper.cs | 4 +- .../Internal/Expressions/ExpressionValue.cs | 5 +- .../Expressions/LayoutEvaluatorState.cs | 4 +- .../DataFieldValueCalculatorTests.cs | 225 ++++++++++++++++++ .../backend/hidden-field.json | 41 ++++ .../backend/hidden-page.json | 42 ++++ .../shared/component-lookup-hidden.json | 41 ++++ .../shared/single-expression-boolean.json | 77 ++++++ .../shared/single-expression-number.json | 38 +++ 12 files changed, 569 insertions(+), 48 deletions(-) create mode 100644 test/Altinn.App.Core.Tests/Features/DataProcessing/DataFieldValueCalculatorTests.cs create mode 100644 test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/backend/hidden-field.json create mode 100644 test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/backend/hidden-page.json create mode 100644 test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/shared/component-lookup-hidden.json create mode 100644 test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/shared/single-expression-boolean.json create mode 100644 test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/shared/single-expression-number.json diff --git a/src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculator.cs b/src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculator.cs index 4c37508ac8..126468dc62 100644 --- a/src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculator.cs +++ b/src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculator.cs @@ -72,7 +72,8 @@ string rawCalculationConfig foreach (var (baseField, calculations) in dataFieldCalculations) { var resolvedFields = await evaluatorState.GetResolvedKeys( - new DataReference() { Field = baseField, DataElementIdentifier = dataElementIdentifier } + new DataReference() { Field = baseField, DataElementIdentifier = dataElementIdentifier }, + true ); foreach (var resolvedField in resolvedFields) { @@ -93,9 +94,9 @@ string rawCalculationConfig dataElementIdentifier: resolvedField.DataElementIdentifier ); var positionalArguments = new object[] { resolvedField.Field }; + var formDataWrapper = await dataAccessor.GetFormDataWrapper(dataElement); foreach (var calculation in calculations) { - var formDataWrapper = await dataAccessor.GetFormDataWrapper(dataElement); await RunCalculation( formDataWrapper, evaluatorState, @@ -126,7 +127,15 @@ DataFieldCalculation calculation context, positionalArguments ); - formDataWrapper.Set(resolvedField.Field.ToArray(), calculationResult); + if (!formDataWrapper.Set(resolvedField.Field, calculationResult)) + { + _logger.LogWarning( + "Could not set calculated value for field {Field} in data element {DataElementId}. " + + "This is because the type conversion failed.", + resolvedField.Field, + resolvedField.DataElementIdentifier.Id + ); + } } catch (Exception e) { diff --git a/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs b/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs index 6c2fa1b89a..b89fbf503b 100644 --- a/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs +++ b/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs @@ -121,7 +121,7 @@ ReadOnlySpan rowIndexes /// ... /// ] /// - public string[] GetResolvedKeys(string field) + public string[] GetResolvedKeys(string field, bool isCalculating = false) { if (_dataModel is null) { @@ -129,7 +129,7 @@ public string[] GetResolvedKeys(string field) } var fieldParts = field.Split('.'); - return GetResolvedKeysRecursive(fieldParts, _dataModel); + return GetResolvedKeysRecursive(fieldParts, _dataModel, _dataModel.GetType(), isCalculating: isCalculating); } private static string JoinFieldKeyParts(string? currentKey, string? key) @@ -146,63 +146,102 @@ private static string JoinFieldKeyParts(string? currentKey, string? key) return currentKey + "." + key; } - private static string[] GetResolvedKeysRecursive( - string[] keyParts, - object currentModel, + private static string?[] GetResolvedKeysRecursive( + string?[] keyParts, + object? currentModel, + Type currentType, int currentIndex = 0, - string currentKey = "" + string currentKey = "", + bool isCalculating = false ) { - if (currentModel is null) - { - return []; - } - if (currentIndex == keyParts.Length) { return [currentKey]; } var (key, groupIndex) = ParseKeyPart(keyParts[currentIndex]); - var prop = Array.Find(currentModel.GetType().GetProperties(), p => IsPropertyWithJsonName(p, key)); - var childModel = prop?.GetValue(currentModel); - if (childModel is null) + var prop = Array.Find(currentType.GetProperties(), p => IsPropertyWithJsonName(p, key)); + if (prop is null) { return []; } - if (childModel is not string && childModel is System.Collections.IEnumerable childModelList) + var childType = prop.PropertyType; + // Check if the property is a collection type (but not string) + if (childType != typeof(string) && childType.IsAssignableTo(typeof(System.Collections.IEnumerable))) { - // childModel is a list - if (groupIndex is null) + // For collections, we need the actual object to enumerate + var childModel = currentModel is not null ? prop.GetValue(currentModel) : null; + if (childModel is System.Collections.IEnumerable childModelList) { - // Index not specified, recurse on all elements - int i = 0; - var resolvedKeys = new List(); - foreach (var child in childModelList) + // Get the element type of the collection + var elementType = childType.GetGenericArguments().FirstOrDefault() ?? typeof(object); + if (groupIndex is null) { - var newResolvedKeys = GetResolvedKeysRecursive( - keyParts, - child, - currentIndex + 1, - JoinFieldKeyParts(currentKey, key + "[" + i + "]") - ); - resolvedKeys.AddRange(newResolvedKeys); - i++; + // Index not specified, recurse on all elements + int i = 0; + var resolvedKeys = new List(); + foreach (var child in childModelList) + { + var newResolvedKeys = GetResolvedKeysRecursive( + keyParts, + child, + elementType, + currentIndex + 1, + JoinFieldKeyParts(currentKey, key + "[" + i + "]"), + isCalculating + ); + resolvedKeys.AddRange(newResolvedKeys); + i++; + } + return resolvedKeys.ToArray(); } - return resolvedKeys.ToArray(); + // Index specified, recurse on that element + var elementAt = GetElementAt(childModelList, groupIndex.Value); + return GetResolvedKeysRecursive( + keyParts, + elementAt, + elementType, + currentIndex + 1, + JoinFieldKeyParts(currentKey, key + "[" + groupIndex + "]"), + isCalculating + ); } - // Index specified, recurse on that element - return GetResolvedKeysRecursive( - keyParts, - childModel, - currentIndex + 1, - JoinFieldKeyParts(currentKey, key + "[" + groupIndex + "]") - ); + + // Collection is null/empty + if (isCalculating && currentIndex == keyParts.Length - 1) + { + return [JoinFieldKeyParts(currentKey, key)]; + } + return []; + } + + // For non-collection properties, we can work with just the type + // Get the child object for further traversal + var childValue = currentModel is not null ? prop.GetValue(currentModel) : null; + // If this is the last key part + if (currentIndex == keyParts.Length - 1) + { + // Return the key if value exists, or if calculating (to allow null fields during calculation) + return childValue is not null || isCalculating ? [JoinFieldKeyParts(currentKey, key)] : []; } - // Otherwise, just recurse - return GetResolvedKeysRecursive(keyParts, childModel, currentIndex + 1, JoinFieldKeyParts(currentKey, key)); + // If child is null and we're not calculating, we can't continue + if (childValue is null && !isCalculating) + { + return []; + } + + // Continue recursion using type information (childValue may be null if isCalculating=true) + return GetResolvedKeysRecursive( + keyParts, + childValue, + childType, + currentIndex + 1, + JoinFieldKeyParts(currentKey, key), + isCalculating + ); } private static object? GetElementAt(System.Collections.IEnumerable enumerable, int index) diff --git a/src/Altinn.App.Core/Internal/Data/IFormDataWrapper.cs b/src/Altinn.App.Core/Internal/Data/IFormDataWrapper.cs index be0cda83e4..9696087d89 100644 --- a/src/Altinn.App.Core/Internal/Data/IFormDataWrapper.cs +++ b/src/Altinn.App.Core/Internal/Data/IFormDataWrapper.cs @@ -285,7 +285,11 @@ private static int InvokeReturnIntOrError(MethodInfo info, object instance) /// group[1].name /// group[1].age /// - public static DataReference[] GetResolvedKeys(this IFormDataWrapper formDataWrapper, DataReference reference) + public static DataReference[] GetResolvedKeys( + this IFormDataWrapper formDataWrapper, + DataReference reference, + bool isCalculating = false + ) { //TODO: write more efficient code that uses the formDataWrapper to resolve keys instead of reflection in DataModelWrapper var data = formDataWrapper.BackingData(); @@ -293,7 +297,7 @@ public static DataReference[] GetResolvedKeys(this IFormDataWrapper formDataWrap var dataModelWrapper = new DataModelWrapper(data); #pragma warning restore CS0618 // Type or member is obsolete return dataModelWrapper - .GetResolvedKeys(reference.Field) + .GetResolvedKeys(reference.Field, isCalculating) .Select(resolvedField => reference with { Field = resolvedField }) .ToArray(); } diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionHelper.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionHelper.cs index 84601f30f6..fc122606ef 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionHelper.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionHelper.cs @@ -2,8 +2,10 @@ namespace Altinn.App.Core.Internal.Expressions; internal static class ExpressionHelper { - internal static int[]? GetRowIndices(string field) + internal static int[]? GetRowIndices(string? field) { + if (field == null) + return null; Span rowIndicesSpan = stackalloc int[200]; // Assuming max 200 indices for simplicity recursion will never go deeper than 3-4 int count = 0; for (int index = 0; index < field.Length; index++) diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs index be4e2d351c..a6291bcdfe 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs @@ -516,6 +516,9 @@ public bool TryDeserialize(Type type, out object? result) case JsonValueKind.False or JsonValueKind.True when IsSupportedNumericType(underlyingType): result = Convert.ChangeType(Bool ? 1 : 0, underlyingType, CultureInfo.InvariantCulture); return true; + case JsonValueKind.False or JsonValueKind.True when underlyingType == typeof(string): + result = Bool ? "true" : "false"; + return true; case JsonValueKind.Number when IsSupportedNumericType(underlyingType): result = Convert.ChangeType(Number, underlyingType, CultureInfo.InvariantCulture); return true; @@ -560,7 +563,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/Internal/Expressions/LayoutEvaluatorState.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs index 2a58f33800..1a5186a108 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs @@ -197,10 +197,10 @@ private static bool RowIndexMatch(int[]? searchRowIndexes, int[]? componentRowIn /// /// Get all the resolved keys (including all possible indexes) from a data model key /// - public async Task GetResolvedKeys(DataReference reference) + public async Task GetResolvedKeys(DataReference reference, bool isCalculating = false) { var data = await _dataAccessor.GetFormDataWrapper(reference.DataElementIdentifier); - return data.GetResolvedKeys(reference); + return data.GetResolvedKeys(reference, isCalculating); } /// diff --git a/test/Altinn.App.Core.Tests/Features/DataProcessing/DataFieldValueCalculatorTests.cs b/test/Altinn.App.Core.Tests/Features/DataProcessing/DataFieldValueCalculatorTests.cs new file mode 100644 index 0000000000..6ddc99ca64 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Features/DataProcessing/DataFieldValueCalculatorTests.cs @@ -0,0 +1,225 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Altinn.App.Core.Configuration; +using Altinn.App.Core.Features; +using Altinn.App.Core.Features.DataProcessing; +using Altinn.App.Core.Internal.Data; +using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Internal.Texts; +using Altinn.App.Core.Models; +using Altinn.App.Core.Models.Layout; +using Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; +using Altinn.App.Core.Tests.TestUtils; +using Altinn.Platform.Storage.Interface.Models; +using FluentAssertions; +using Microsoft.Extensions.Logging.Testing; +using Microsoft.Extensions.Options; +using Moq; +using Xunit.Abstractions; +using IAppResources = Altinn.App.Core.Internal.App.IAppResources; + +namespace Altinn.App.Core.Tests.Features.DataProcessing; + +public class DataFieldValueCalculatorTests +{ + private readonly ITestOutputHelper _output; + private readonly DataFieldValueCalculator _dataFieldValueCalculator; + private readonly FakeLogger _logger = new(); + private readonly Mock _appResources = new(MockBehavior.Strict); + private readonly Mock _layoutInitializer = new(MockBehavior.Strict); + private readonly IOptions _frontendSettings = Microsoft.Extensions.Options.Options.Create( + new FrontEndSettings() + ); + private static readonly JsonSerializerOptions _jsonSerializerOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + public DataFieldValueCalculatorTests(ITestOutputHelper output) + { + var accessCheckerMock = new Mock(); + accessCheckerMock.Setup(x => x.CanRead(It.IsAny(), It.IsAny())).ReturnsAsync(true); + + var serviceProviderMock = new Mock(); + serviceProviderMock + .Setup(x => x.GetService(typeof(IDataElementAccessChecker))) + .Returns(accessCheckerMock.Object); + + _output = output; + _dataFieldValueCalculator = new DataFieldValueCalculator( + _logger, + _layoutInitializer.Object, + _appResources.Object, + serviceProviderMock.Object + ); + } + + public async Task LoadData(string fileName, string folder) + { + var data = await File.ReadAllTextAsync(Path.Join(folder, fileName)); + _output.WriteLine(data); + return JsonSerializer.Deserialize(data, _jsonSerializerOptions)!; + } + + [Theory] + [FileNamesInFolderData(["Features", "DataProcessing", "data-field-value-calculator-tests", "backend"])] + public async Task RunDataFieldCalculationTestsForBackend(string fileName, string folder) + { + var (result, testCase) = await RunDataFieldCalculatorTest(fileName, folder); + + foreach (var expected in testCase.Expects) + { + result.Get(expected.Field).Should().Be(expected.Result.ToObject()); + } + } + + [Theory] + [FileNamesInFolderData(["Features", "DataProcessing", "data-field-value-calculator-tests", "shared"])] + public async Task RunDataFieldCalculationTestsForShared(string fileName, string folder) + { + var (result, testCase) = await RunDataFieldCalculatorTest(fileName, folder); + + foreach (var expected in testCase.Expects) + { + result.Get(expected.Field).Should().Be(expected.Result.ToObject()); + } + } + + [Fact] + public async Task ShouldLogWarningWhenTryingToSetUnsupportedDataType() + { + var testCaseJson = """ + { + "name": "Should log warning when trying to set field with unsupported data type", + "expects": [ + { + "logMessageWarning": "Could not set calculated value for field form.unsupportedDataType in data element 30844cc0-81af-4429-9f9e-035d78f1f9da. This is because the type conversion failed." + } + ], + "calculationConfig": { + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/validation/validation.schema.v1.json", + "calculations": { + "form.unsupportedDataType": ["four-times-two"] + }, + "definitions": { + "four-times-two": { + "condition": ["language"] + } + } + }, + "formData": { + "form": { + "unsupportedDataType": true + } + }, + "layouts": { + "Page": { + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/layout/layout.schema.v1.json", + "data": { + "layout": [ + ] + } + } + } + } + """; + _output.WriteLine(testCaseJson); + var testCase = JsonSerializer.Deserialize( + testCaseJson, + _jsonSerializerOptions + )!; + + await RunDataFieldCalculatorTest(testCase); + + foreach (var expected in testCase.Expects) + { + _logger.Collector.GetSnapshot().Select(x => x.Message).Should().Contain(expected.LogMessageWarning); + } + } + + private async Task<(IFormDataWrapper, DataFieldValueCalculatorTestModel)> RunDataFieldCalculatorTest( + string fileName, + string folder + ) + { + var testCase = await LoadData(fileName, folder); + + return (await RunDataFieldCalculatorTest(testCase), testCase); + } + + private async Task RunDataFieldCalculatorTest(DataFieldValueCalculatorTestModel testCase) + { + var instance = new Instance() { Id = "1337/fa0678ad-960d-4307-aba2-ba29c9804c9d", AppId = "org/app" }; + var dataElement = new DataElement { Id = "30844cc0-81af-4429-9f9e-035d78f1f9da", DataType = "default" }; + var dataType = new DataType() { Id = "default" }; + + var dataAccessor = DynamicClassBuilder.DataAccessorFromJsonDocument(instance, testCase.FormData, dataElement); + + var layout = new LayoutSetComponent(testCase.Layouts, "layout", dataType); + var componentModel = new LayoutModel([layout], null); + var translationService = new TranslationService( + new AppIdentifier("org", "app"), + _appResources.Object, + FakeLoggerXunit.Get(_output) + ); + var evaluatorState = new LayoutEvaluatorState( + dataAccessor, + componentModel, + translationService, + _frontendSettings.Value + ); + _layoutInitializer + .Setup(init => + init.Init(It.IsAny(), "Task_1", It.IsAny(), It.IsAny()) + ) + .ReturnsAsync(evaluatorState); + + _appResources + .Setup(ar => ar.GetTexts("org", "app", "nb")) + .ReturnsAsync( + testCase.TextResources is null + ? null + : new TextResource { Language = "nb", Resources = testCase.TextResources } + ); + + await _dataFieldValueCalculator.CalculateFormData( + dataAccessor, + dataElement, + "Task_1", + JsonSerializer.Serialize(testCase.CalculationConfig) + ); + + return await dataAccessor.GetFormDataWrapper(dataElement); + } + + public record DataFieldValueCalculatorTestModel + { + [JsonPropertyName("name")] + public required string Name { get; set; } + + [JsonPropertyName("expects")] + public required Expected[] Expects { get; set; } + + [JsonPropertyName("calculationConfig")] + public required JsonElement CalculationConfig { get; set; } + + [JsonPropertyName("formData")] + public required JsonElement FormData { get; set; } + + [JsonPropertyName("layouts")] + public required IReadOnlyDictionary Layouts { get; set; } + + [JsonPropertyName("textResources")] + public List? TextResources { get; set; } + } + + public record Expected + { + public string Field { get; set; } + + public ExpressionValue Result { get; set; } + + public string LogMessageWarning { get; set; } + } +} diff --git a/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/backend/hidden-field.json b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/backend/hidden-field.json new file mode 100644 index 0000000000..de7f4b6e43 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/backend/hidden-field.json @@ -0,0 +1,41 @@ +{ + "name": "Should set field when component is hidden", + "expects": [ + { + "field": "form.name", + "result": "feil" + } + ], + "calculationConfig": { + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/validation/validation.schema.v1.json", + "calculations": { + "form.name": [ + { + "condition": ["equals", ["dataModel", ["argv", 0]], "feil"] + } + ] + } + }, + "formData": { + "form": { + "name": "feil" + } + }, + "layouts": { + "Page": { + "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", + "data": { + "layout": [ + { + "id": "name-input", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "form.name" + }, + "hidden": true + } + ] + } + } + } +} diff --git a/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/backend/hidden-page.json b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/backend/hidden-page.json new file mode 100644 index 0000000000..852dc7b332 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/backend/hidden-page.json @@ -0,0 +1,42 @@ +{ + "name": "Should set field if field is on hidden page", + "expects": [ + { + "field": "form.name", + "result": "none" + } + ], + "calculationConfig": { + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/validation/validation.schema.v1.json", + "calculations": { + "form.name": ["none-is-not-allowed"] + }, + "definitions": { + "none-is-not-allowed": { + "condition": ["equals", ["dataModel", ["argv", 0]], "none"] + } + } + }, + "formData": { + "form": { + "name": "none" + } + }, + "layouts": { + "Page": { + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/layout/layout.schema.v1.json", + "data": { + "hidden": true, + "layout": [ + { + "id": "name-input", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "form.name" + } + } + ] + } + } + } +} diff --git a/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/shared/component-lookup-hidden.json b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/shared/component-lookup-hidden.json new file mode 100644 index 0000000000..39ae5cd7fa --- /dev/null +++ b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/shared/component-lookup-hidden.json @@ -0,0 +1,41 @@ +{ + "name": "Should not set field when looking up hidden component", + "expects": [ + { + "field": "form.name", + "result": "feil" + } + ], + "calculationConfig": { + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/validation/validation.schema.v1.json", + "calculations": { + "form.name": [ + { + "condition": ["equals", ["dataModel", ["argv", 0]], "feil"] + } + ] + } + }, + "formData": { + "form": { + "name": "feil" + } + }, + "layouts": { + "Page": { + "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", + "data": { + "layout": [ + { + "id": "name-input", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "form.name" + }, + "hidden": true + } + ] + } + } + } +} diff --git a/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/shared/single-expression-boolean.json b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/shared/single-expression-boolean.json new file mode 100644 index 0000000000..14e78e4cc4 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/shared/single-expression-boolean.json @@ -0,0 +1,77 @@ +{ + "name": "Should set boolean value to field with supported data type", + "expects": [ + { + "field": "form.nameResultString", "result": "true" + }, + { + "field": "form.emailResultString", "result": "false" + }, + { + "field": "form.nameResultBoolean", "result": true + }, + { + "field": "form.emailResultBoolean", "result": false + }, + { + "field": "form.nameResultNumber", "result": 1 + }, + { + "field": "form.emailResultNumber", "result": 0 + } + ], + "calculationConfig": { + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/validation/validation.schema.v1.json", + "calculations": { + "form.nameResultString": ["name-is-equal-to-none"], + "form.emailResultString": ["email-is-equal-to-none"], + "form.nameResultBoolean": ["name-is-equal-to-none"], + "form.emailResultBoolean": ["email-is-equal-to-none"], + "form.nameResultNumber": ["name-is-equal-to-none"], + "form.emailResultNumber": ["email-is-equal-to-none"] + }, + "definitions": { + "name-is-equal-to-none": { + "condition": ["equals", ["dataModel", "form.name"], "none"] + }, + "email-is-equal-to-none": { + "condition": ["equals", ["dataModel", "form.email"], "none"] + } + } + }, + "formData": { + "form": { + "name": "none", + "email": "email@address.com", + "nameResultString": "", + "emailResultString": "", + "nameResultBoolean": false, + "emailResultBoolean": true, + "nameResultNumber": 0, + "emailResultNumber": 1 + } + }, + "layouts": { + "Page": { + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/layout/layout.schema.v1.json", + "data": { + "layout": [ + { + "id": "name-input", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "form.name" + } + }, + { + "id": "email-input", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "form.email" + } + } + ] + } + } + } +} diff --git a/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/shared/single-expression-number.json b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/shared/single-expression-number.json new file mode 100644 index 0000000000..d133d1ca27 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/shared/single-expression-number.json @@ -0,0 +1,38 @@ +{ + "name": "Should set number value to field with supported data type", + "expects": [ + { + "field": "form.fourTimesTwoString", "result": "8" + }, + { + "field": "form.fourTimesTwoNumber", "result": 8 + } + ], + "calculationConfig": { + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/validation/validation.schema.v1.json", + "calculations": { + "form.fourTimesTwoString": ["four-times-two"], + "form.fourTimesTwoNumber": ["four-times-two"] + }, + "definitions": { + "four-times-two": { + "condition": ["multiply", 4, 2] + } + } + }, + "formData": { + "form": { + "fourTimesTwoString": "0", + "fourTimesTwoNumber": 0 + } + }, + "layouts": { + "Page": { + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/layout/layout.schema.v1.json", + "data": { + "layout": [ + ] + } + } + } +} From a11d093e4ea0e6f6d9627130bff4bc15e890fb1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olav=20S=C3=B8rlie?= Date: Thu, 5 Mar 2026 16:12:39 +0100 Subject: [PATCH 4/9] Temp fix for unit test --- .../DataFieldValueCalculator.cs | 22 +++++++++---------- .../single-expression-boolean.json | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) rename test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/{shared => }/single-expression-boolean.json (97%) diff --git a/src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculator.cs b/src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculator.cs index 126468dc62..6d050014f2 100644 --- a/src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculator.cs +++ b/src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculator.cs @@ -139,7 +139,7 @@ DataFieldCalculation calculation } catch (Exception e) { - _logger.LogError(e, "Error while evaluating calculation for field {field}", resolvedField.Field); + _logger.LogError(e, "Error while evaluating calculation for field {Field}", resolvedField.Field); throw; } } @@ -166,7 +166,7 @@ out JsonElement definitionsObject ); if (resolvedDefinition == null) { - logger.LogError("Calculation definition {name} could not be resolved", definitionProperty.Name); + logger.LogError("Calculation definition {Name} could not be resolved", definitionProperty.Name); continue; } calculationDefinitions[definitionProperty.Name] = resolvedDefinition; @@ -199,7 +199,7 @@ out JsonElement calculationsObject ); if (resolvedDataFieldCalculation == null) { - logger.LogError("Calculation for field {field} could not be resolved", field); + logger.LogError("Calculation for field {Field} could not be resolved", field); continue; } dataFieldCalculation.Add(resolvedDataFieldCalculation); @@ -223,7 +223,7 @@ ILogger logger var stringReference = definition.GetString(); if (stringReference == null) { - logger.LogError("Could not resolve null reference for calculation for field {field}", field); + logger.LogError("Could not resolve null reference for calculation for field {Field}", field); return null; } @@ -231,7 +231,7 @@ ILogger logger if (reference == null) { logger.LogError( - "Could not resolve reference {stringReference} for calculation for field {field}", + "Could not resolve reference {StringReference} for calculation for field {Field}", stringReference, field ); @@ -246,7 +246,7 @@ ILogger logger ); if (dataFieldCalculationDefinition == null) { - logger.LogError("Calculation for field {field} could not be parsed", field); + logger.LogError("Calculation for field {Field} could not be parsed", field); return null; } @@ -256,7 +256,7 @@ ILogger logger if (reference == null) { logger.LogError( - "Could not resolve reference {expressionDefinitionRef} for calculation for field {field}", + "Could not resolve reference {ExpressionDefinitionRef} for calculation for field {Field}", dataFieldCalculationDefinition.Ref, field ); @@ -273,7 +273,7 @@ ILogger logger if (rawDataFieldValueCalculation.Condition == null) { - logger.LogError("Calculation for field {field} is missing condition", field); + logger.LogError("Calculation for field {Field} is missing condition", field); return null; } @@ -295,7 +295,7 @@ ILogger logger var rawDefinition = definitionProperty.Value.Deserialize(_jsonSerializerOptions); if (rawDefinition == null) { - logger.LogError("Calculation definition {name} could not be parsed", definitionProperty.Name); + logger.LogError("Calculation definition {Name} could not be parsed", definitionProperty.Name); return null; } @@ -305,7 +305,7 @@ ILogger logger if (reference == null) { logger.LogError( - "Could not resolve reference {rawDefinitionRef} for calculation {name}", + "Could not resolve reference {RawDefinitionRef} for calculation {Name}", rawDefinition.Ref, definitionProperty.Name ); @@ -322,7 +322,7 @@ ILogger logger if (resolvedDefinition.Condition == null) { - logger.LogError("Calculation {name} is missing condition", definitionProperty.Name); + logger.LogError("Calculation {Name} is missing condition", definitionProperty.Name); return null; } diff --git a/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/shared/single-expression-boolean.json b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/single-expression-boolean.json similarity index 97% rename from test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/shared/single-expression-boolean.json rename to test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/single-expression-boolean.json index 14e78e4cc4..84a5fe0126 100644 --- a/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/shared/single-expression-boolean.json +++ b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/single-expression-boolean.json @@ -1,5 +1,5 @@ { - "name": "Should set boolean value to field with supported data type", + "name": "Should set boolean value to field with supported data type. TODO: Move to shared when #1651 is merged", "expects": [ { "field": "form.nameResultString", "result": "true" From ce5f067b7ca863ccd7473b591e721669de6bd2a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olav=20S=C3=B8rlie?= Date: Thu, 5 Mar 2026 16:15:06 +0100 Subject: [PATCH 5/9] Fix --- .../{ => shared}/single-expression-boolean.json | 2 +- .../{shared => }/single-expression-number.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/{ => shared}/single-expression-boolean.json (97%) rename test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/{shared => }/single-expression-number.json (95%) diff --git a/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/single-expression-boolean.json b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/shared/single-expression-boolean.json similarity index 97% rename from test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/single-expression-boolean.json rename to test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/shared/single-expression-boolean.json index 84a5fe0126..14e78e4cc4 100644 --- a/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/single-expression-boolean.json +++ b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/shared/single-expression-boolean.json @@ -1,5 +1,5 @@ { - "name": "Should set boolean value to field with supported data type. TODO: Move to shared when #1651 is merged", + "name": "Should set boolean value to field with supported data type", "expects": [ { "field": "form.nameResultString", "result": "true" diff --git a/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/shared/single-expression-number.json b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/single-expression-number.json similarity index 95% rename from test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/shared/single-expression-number.json rename to test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/single-expression-number.json index d133d1ca27..70d11e51be 100644 --- a/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/shared/single-expression-number.json +++ b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/single-expression-number.json @@ -1,5 +1,5 @@ { - "name": "Should set number value to field with supported data type", + "name": "Should set number value to field with supported data type. TODO: Move to shared when #1651 is merged", "expects": [ { "field": "form.fourTimesTwoString", "result": "8" From c589a4c4ab8d0975110f1ae8cf7c48d58ee249c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olav=20S=C3=B8rlie?= Date: Tue, 10 Mar 2026 11:39:04 +0100 Subject: [PATCH 6/9] Implemented suggested changes from code rabbit --- .../Extensions/ServiceCollectionExtensions.cs | 2 +- .../DataFieldValueCalculator.cs | 118 ++---------------- .../DataFieldValueCalculatorProcessor.cs | 9 +- .../Telemetry.DataFieldValueCalculator.cs | 20 +++ .../Helpers/DataModel/DataModelWrapper.cs | 4 +- .../Internal/Expressions/ExpressionHelper.cs | 6 + .../Internal/Expressions/ExpressionValue.cs | 3 - .../Models/RawDataFieldValueCalculation.cs | 11 +- .../DataFieldValueCalculatorTests.cs | 25 ++-- .../backend/hidden-field.json | 6 +- .../backend/hidden-page.json | 15 ++- .../shared/component-lookup-hidden.json | 8 +- .../shared/single-expression-boolean.json | 45 ++++--- .../single-expression-number.json | 19 +-- .../shared/component-lookup-hidden.json | 2 +- ...ouldNotChange_Unintentionally.verified.txt | 9 +- 16 files changed, 109 insertions(+), 193 deletions(-) create mode 100644 src/Altinn.App.Core/Features/Telemetry/Telemetry.DataFieldValueCalculator.cs diff --git a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs index 70d7d0646b..4081b954f1 100644 --- a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs @@ -182,7 +182,6 @@ IWebHostEnvironment env #pragma warning restore CS0618, CS0612 // Type or member is obsolete services.TryAddTransient(); services.TryAddTransient(); - services.TryAddTransient(); services.TryAddTransient(); services.TryAddTransient(); services.TryAddTransient(); @@ -191,6 +190,7 @@ IWebHostEnvironment env services.TryAddTransient(); services.TryAddTransient(); services.TryAddTransient(); + services.AddTransient(); services.AddSingleton(); services.AddTransient(); services.AddSingleton(); diff --git a/src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculator.cs b/src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculator.cs index 6d050014f2..20bd917c4f 100644 --- a/src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculator.cs +++ b/src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculator.cs @@ -5,13 +5,12 @@ using Altinn.App.Core.Models; using Altinn.App.Core.Models.Layout; using Altinn.Platform.Storage.Interface.Models; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using ComponentContext = Altinn.App.Core.Models.Expressions.ComponentContext; namespace Altinn.App.Core.Features.DataProcessing; -public class DataFieldValueCalculator +internal sealed class DataFieldValueCalculator { private static readonly JsonSerializerOptions _jsonSerializerOptions = new() { @@ -23,22 +22,24 @@ public class DataFieldValueCalculator private readonly IAppResources _appResourceService; private readonly ILayoutEvaluatorStateInitializer _layoutEvaluatorStateInitializer; private readonly IDataElementAccessChecker _dataElementAccessChecker; + private readonly Telemetry? _telemetry; public DataFieldValueCalculator( ILogger logger, ILayoutEvaluatorStateInitializer layoutEvaluatorStateInitializer, IAppResources appResourceService, - IServiceProvider serviceProvider + IDataElementAccessChecker dataElementAccessChecker ) { _logger = logger; _appResourceService = appResourceService; _layoutEvaluatorStateInitializer = layoutEvaluatorStateInitializer; - _dataElementAccessChecker = serviceProvider.GetRequiredService(); + _dataElementAccessChecker = dataElementAccessChecker; } public async Task Calculate(IInstanceDataAccessor dataAccessor, string taskId) { + using var activity = _telemetry?.StartCalculateActivity(dataAccessor.Instance.Id, taskId); foreach (var (dataType, dataElement) in dataAccessor.GetDataElementsWithFormDataForTask(taskId)) { if (await _dataElementAccessChecker.CanRead(dataAccessor.Instance, dataType) is false) @@ -68,6 +69,7 @@ string rawCalculationConfig ); DataElementIdentifier dataElementIdentifier = dataElement; var dataFieldCalculations = ParseDataFieldCalculationConfig(rawCalculationConfig, _logger); + var formDataWrapper = await dataAccessor.GetFormDataWrapper(dataElement); foreach (var (baseField, calculations) in dataFieldCalculations) { @@ -94,7 +96,6 @@ string rawCalculationConfig dataElementIdentifier: resolvedField.DataElementIdentifier ); var positionalArguments = new object[] { resolvedField.Field }; - var formDataWrapper = await dataAccessor.GetFormDataWrapper(dataElement); foreach (var calculation in calculations) { await RunCalculation( @@ -150,28 +151,6 @@ ILogger logger ) { using var calculationConfigDocument = JsonDocument.Parse(rawCalculationConfig); - var calculationDefinitions = new Dictionary(); - var hasDefinitions = calculationConfigDocument.RootElement.TryGetProperty( - "definitions", - out JsonElement definitionsObject - ); - if (hasDefinitions) - { - foreach (var definitionProperty in definitionsObject.EnumerateObject()) - { - var resolvedDefinition = ResolveCalculationDefinition( - definitionProperty, - calculationDefinitions, - logger - ); - if (resolvedDefinition == null) - { - logger.LogError("Calculation definition {Name} could not be resolved", definitionProperty.Name); - continue; - } - calculationDefinitions[definitionProperty.Name] = resolvedDefinition; - } - } var dataFieldCalculations = new Dictionary>(); var hasCalculations = calculationConfigDocument.RootElement.TryGetProperty( @@ -191,12 +170,7 @@ out JsonElement calculationsObject dataFieldCalculation = new List(); dataFieldCalculations[field] = dataFieldCalculation; } - var resolvedDataFieldCalculation = ResolveDataFieldCalculation( - field, - calculation, - calculationDefinitions, - logger - ); + var resolvedDataFieldCalculation = ResolveDataFieldCalculation(field, calculation, logger); if (resolvedDataFieldCalculation == null) { logger.LogError("Calculation for field {Field} could not be resolved", field); @@ -209,12 +183,7 @@ out JsonElement calculationsObject return dataFieldCalculations; } - private DataFieldCalculation? ResolveDataFieldCalculation( - string field, - JsonElement definition, - Dictionary resolvedDefinitions, - ILogger logger - ) + private DataFieldCalculation? ResolveDataFieldCalculation(string field, JsonElement definition, ILogger logger) { var rawDataFieldValueCalculation = new RawDataFieldValueCalculation(); @@ -226,18 +195,6 @@ ILogger logger logger.LogError("Could not resolve null reference for calculation for field {Field}", field); return null; } - - var reference = resolvedDefinitions.GetValueOrDefault(stringReference); - if (reference == null) - { - logger.LogError( - "Could not resolve reference {StringReference} for calculation for field {Field}", - stringReference, - field - ); - return null; - } - rawDataFieldValueCalculation.Condition = reference.Condition; } else { @@ -250,21 +207,6 @@ ILogger logger return null; } - if (dataFieldCalculationDefinition.Ref != null) - { - var reference = resolvedDefinitions.GetValueOrDefault(dataFieldCalculationDefinition.Ref); - if (reference == null) - { - logger.LogError( - "Could not resolve reference {ExpressionDefinitionRef} for calculation for field {Field}", - dataFieldCalculationDefinition.Ref, - field - ); - return null; - } - rawDataFieldValueCalculation.Condition = reference.Condition; - } - if (dataFieldCalculationDefinition.Condition != null) { rawDataFieldValueCalculation.Condition = dataFieldCalculationDefinition.Condition; @@ -284,48 +226,4 @@ ILogger logger return dataFieldCalculation; } - - private static RawDataFieldValueCalculation? ResolveCalculationDefinition( - JsonProperty definitionProperty, - Dictionary resolvedDefinitions, - ILogger logger - ) - { - var resolvedDefinition = new RawDataFieldValueCalculation(); - var rawDefinition = definitionProperty.Value.Deserialize(_jsonSerializerOptions); - if (rawDefinition == null) - { - logger.LogError("Calculation definition {Name} could not be parsed", definitionProperty.Name); - return null; - } - - if (rawDefinition.Ref != null) - { - var reference = resolvedDefinitions.GetValueOrDefault(rawDefinition.Ref); - if (reference == null) - { - logger.LogError( - "Could not resolve reference {RawDefinitionRef} for calculation {Name}", - rawDefinition.Ref, - definitionProperty.Name - ); - return null; - } - - resolvedDefinition.Condition = reference.Condition; - } - - if (rawDefinition.Condition != null) - { - resolvedDefinition.Condition = rawDefinition.Condition; - } - - if (resolvedDefinition.Condition == null) - { - logger.LogError("Calculation {Name} is missing condition", definitionProperty.Name); - return null; - } - - return resolvedDefinition; - } } diff --git a/src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculatorProcessor.cs b/src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculatorProcessor.cs index 2217b7283f..4d27b9b38b 100644 --- a/src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculatorProcessor.cs +++ b/src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculatorProcessor.cs @@ -1,22 +1,21 @@ using Altinn.App.Core.Models; -using Microsoft.Extensions.DependencyInjection; namespace Altinn.App.Core.Features.DataProcessing; /// /// Processing data fields values that is calculated by expressions provided in [modelName].calculation.json. /// -public class DataFieldValueCalculatorProcessor : IDataWriteProcessor +internal sealed class DataFieldValueCalculatorProcessor : IDataWriteProcessor { private readonly DataFieldValueCalculator _dataFieldValueCalculator; /// /// Initializes a new instance of the class. /// - /// - public DataFieldValueCalculatorProcessor(IServiceProvider serviceProvider) + /// + public DataFieldValueCalculatorProcessor(DataFieldValueCalculator dataFieldValueCalculator) { - _dataFieldValueCalculator = serviceProvider.GetRequiredService(); + _dataFieldValueCalculator = dataFieldValueCalculator; } /// diff --git a/src/Altinn.App.Core/Features/Telemetry/Telemetry.DataFieldValueCalculator.cs b/src/Altinn.App.Core/Features/Telemetry/Telemetry.DataFieldValueCalculator.cs new file mode 100644 index 0000000000..09c4593565 --- /dev/null +++ b/src/Altinn.App.Core/Features/Telemetry/Telemetry.DataFieldValueCalculator.cs @@ -0,0 +1,20 @@ +using System.Diagnostics; +using static Altinn.App.Core.Features.Telemetry.DataFieldValueCalculator; + +namespace Altinn.App.Core.Features; + +partial class Telemetry +{ + internal Activity? StartCalculateActivity(string instanceId, string taskId) + { + var activity = ActivitySource.StartActivity($"{Prefix}.Calculate"); + activity?.SetInstanceId(instanceId); + activity?.SetTaskId(taskId); + return activity; + } + + internal static class DataFieldValueCalculator + { + internal const string Prefix = "DataFieldValueCalculator"; + } +} diff --git a/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs b/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs index b89fbf503b..b6f344e09a 100644 --- a/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs +++ b/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs @@ -146,8 +146,8 @@ private static string JoinFieldKeyParts(string? currentKey, string? key) return currentKey + "." + key; } - private static string?[] GetResolvedKeysRecursive( - string?[] keyParts, + private static string[] GetResolvedKeysRecursive( + string[] keyParts, object? currentModel, Type currentType, int currentIndex = 0, diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionHelper.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionHelper.cs index fc122606ef..c2eb708765 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionHelper.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionHelper.cs @@ -21,6 +21,12 @@ internal static class ExpressionHelper string indexString = field[startIndex..endIndex]; if (int.TryParse(indexString, out int rowIndex)) { + if (count >= rowIndicesSpan.Length) + { + throw new InvalidOperationException( + $"Too many row indices in field: {field}. Max supported: {rowIndicesSpan.Length}" + ); + } rowIndicesSpan[count] = rowIndex; count++; index = endIndex; // Move index to the end of the current bracket diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs index a6291bcdfe..14d01c818c 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs @@ -516,9 +516,6 @@ public bool TryDeserialize(Type type, out object? result) case JsonValueKind.False or JsonValueKind.True when IsSupportedNumericType(underlyingType): result = Convert.ChangeType(Bool ? 1 : 0, underlyingType, CultureInfo.InvariantCulture); return true; - case JsonValueKind.False or JsonValueKind.True when underlyingType == typeof(string): - result = Bool ? "true" : "false"; - return true; case JsonValueKind.Number when IsSupportedNumericType(underlyingType): result = Convert.ChangeType(Number, underlyingType, CultureInfo.InvariantCulture); return true; diff --git a/src/Altinn.App.Core/Models/RawDataFieldValueCalculation.cs b/src/Altinn.App.Core/Models/RawDataFieldValueCalculation.cs index 7d1121e87a..68ace3238d 100644 --- a/src/Altinn.App.Core/Models/RawDataFieldValueCalculation.cs +++ b/src/Altinn.App.Core/Models/RawDataFieldValueCalculation.cs @@ -5,26 +5,21 @@ namespace Altinn.App.Core.Models; /// /// Resolved data field calculation /// -public class DataFieldCalculation +internal sealed class DataFieldCalculation { /// /// Condition to evaluate /// - public Expression Condition { get; set; } + public required Expression Condition { get; set; } } /// /// Raw value calculation expression from the calculaiton configuration file /// -public class RawDataFieldValueCalculation +internal sealed class RawDataFieldValueCalculation { /// /// Condition to evaluate /// public Expression? Condition { get; set; } - - /// - /// Reference to expression definitions - /// - public string? Ref { get; set; } } diff --git a/test/Altinn.App.Core.Tests/Features/DataProcessing/DataFieldValueCalculatorTests.cs b/test/Altinn.App.Core.Tests/Features/DataProcessing/DataFieldValueCalculatorTests.cs index 6ddc99ca64..50b9e61143 100644 --- a/test/Altinn.App.Core.Tests/Features/DataProcessing/DataFieldValueCalculatorTests.cs +++ b/test/Altinn.App.Core.Tests/Features/DataProcessing/DataFieldValueCalculatorTests.cs @@ -11,7 +11,6 @@ using Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; using Altinn.App.Core.Tests.TestUtils; using Altinn.Platform.Storage.Interface.Models; -using FluentAssertions; using Microsoft.Extensions.Logging.Testing; using Microsoft.Extensions.Options; using Moq; @@ -41,17 +40,14 @@ public DataFieldValueCalculatorTests(ITestOutputHelper output) var accessCheckerMock = new Mock(); accessCheckerMock.Setup(x => x.CanRead(It.IsAny(), It.IsAny())).ReturnsAsync(true); - var serviceProviderMock = new Mock(); - serviceProviderMock - .Setup(x => x.GetService(typeof(IDataElementAccessChecker))) - .Returns(accessCheckerMock.Object); + var dataElementAccessChecker = new Mock(); _output = output; _dataFieldValueCalculator = new DataFieldValueCalculator( _logger, _layoutInitializer.Object, _appResources.Object, - serviceProviderMock.Object + dataElementAccessChecker.Object ); } @@ -70,7 +66,7 @@ public async Task RunDataFieldCalculationTestsForBackend(string fileName, string foreach (var expected in testCase.Expects) { - result.Get(expected.Field).Should().Be(expected.Result.ToObject()); + Assert.Equal(expected.Result.ToObject(), result.Get(expected.Field)); } } @@ -82,7 +78,7 @@ public async Task RunDataFieldCalculationTestsForShared(string fileName, string foreach (var expected in testCase.Expects) { - result.Get(expected.Field).Should().Be(expected.Result.ToObject()); + Assert.Equal(expected.Result.ToObject(), result.Get(expected.Field)); } } @@ -100,12 +96,11 @@ public async Task ShouldLogWarningWhenTryingToSetUnsupportedDataType() "calculationConfig": { "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/validation/validation.schema.v1.json", "calculations": { - "form.unsupportedDataType": ["four-times-two"] - }, - "definitions": { - "four-times-two": { - "condition": ["language"] - } + "form.unsupportedDataType": [ + { + "condition": ["language"] + } + ] } }, "formData": { @@ -134,7 +129,7 @@ public async Task ShouldLogWarningWhenTryingToSetUnsupportedDataType() foreach (var expected in testCase.Expects) { - _logger.Collector.GetSnapshot().Select(x => x.Message).Should().Contain(expected.LogMessageWarning); + Assert.Contains(expected.LogMessageWarning, _logger.Collector.GetSnapshot().Select(x => x.Message)); } } diff --git a/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/backend/hidden-field.json b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/backend/hidden-field.json index de7f4b6e43..b7c5fe4be1 100644 --- a/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/backend/hidden-field.json +++ b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/backend/hidden-field.json @@ -1,5 +1,5 @@ { - "name": "Should set field when component is hidden", + "name": "Should not set field when component is hidden", "expects": [ { "field": "form.name", @@ -7,7 +7,7 @@ } ], "calculationConfig": { - "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/validation/validation.schema.v1.json", + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/calculation/calculation.schema.v1.json", "calculations": { "form.name": [ { @@ -23,7 +23,7 @@ }, "layouts": { "Page": { - "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/layout/layout.schema.v1.json", "data": { "layout": [ { diff --git a/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/backend/hidden-page.json b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/backend/hidden-page.json index 852dc7b332..401b4bfe33 100644 --- a/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/backend/hidden-page.json +++ b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/backend/hidden-page.json @@ -1,5 +1,5 @@ { - "name": "Should set field if field is on hidden page", + "name": "Should not set field if field is on hidden page", "expects": [ { "field": "form.name", @@ -7,14 +7,13 @@ } ], "calculationConfig": { - "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/validation/validation.schema.v1.json", + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/calculation/calculation.schema.v1.json", "calculations": { - "form.name": ["none-is-not-allowed"] - }, - "definitions": { - "none-is-not-allowed": { - "condition": ["equals", ["dataModel", ["argv", 0]], "none"] - } + "form.name": [ + { + "condition": ["equals", ["dataModel", ["argv", 0]], "none"] + } + ] } }, "formData": { diff --git a/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/shared/component-lookup-hidden.json b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/shared/component-lookup-hidden.json index 39ae5cd7fa..a7d71bd35c 100644 --- a/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/shared/component-lookup-hidden.json +++ b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/shared/component-lookup-hidden.json @@ -7,7 +7,7 @@ } ], "calculationConfig": { - "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/validation/validation.schema.v1.json", + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/calculation/calculation.schema.v1.json", "calculations": { "form.name": [ { @@ -18,12 +18,12 @@ }, "formData": { "form": { - "name": "feil" - } + "name": "feil" + } }, "layouts": { "Page": { - "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/layout/layout.schema.v1.json", "data": { "layout": [ { diff --git a/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/shared/single-expression-boolean.json b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/shared/single-expression-boolean.json index 14e78e4cc4..d4ad5b4698 100644 --- a/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/shared/single-expression-boolean.json +++ b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/shared/single-expression-boolean.json @@ -1,12 +1,6 @@ { "name": "Should set boolean value to field with supported data type", "expects": [ - { - "field": "form.nameResultString", "result": "true" - }, - { - "field": "form.emailResultString", "result": "false" - }, { "field": "form.nameResultBoolean", "result": true }, @@ -21,30 +15,35 @@ } ], "calculationConfig": { - "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/validation/validation.schema.v1.json", + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/calculation/calculation.schema.v1.json", "calculations": { - "form.nameResultString": ["name-is-equal-to-none"], - "form.emailResultString": ["email-is-equal-to-none"], - "form.nameResultBoolean": ["name-is-equal-to-none"], - "form.emailResultBoolean": ["email-is-equal-to-none"], - "form.nameResultNumber": ["name-is-equal-to-none"], - "form.emailResultNumber": ["email-is-equal-to-none"] - }, - "definitions": { - "name-is-equal-to-none": { - "condition": ["equals", ["dataModel", "form.name"], "none"] - }, - "email-is-equal-to-none": { - "condition": ["equals", ["dataModel", "form.email"], "none"] - } + + "form.nameResultBoolean": [ + { + "condition": ["equals", ["dataModel", "form.name"], "none"] + } + ], + "form.emailResultBoolean": [ + { + "condition": ["equals", ["dataModel", "form.email"], "none"] + } + ], + "form.nameResultNumber": [ + { + "condition": ["equals", ["dataModel", "form.name"], "none"] + } + ], + "form.emailResultNumber": [ + { + "condition": ["equals", ["dataModel", "form.email"], "none"] + } + ] } }, "formData": { "form": { "name": "none", "email": "email@address.com", - "nameResultString": "", - "emailResultString": "", "nameResultBoolean": false, "emailResultBoolean": true, "nameResultNumber": 0, diff --git a/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/single-expression-number.json b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/single-expression-number.json index 70d11e51be..840d9efc8e 100644 --- a/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/single-expression-number.json +++ b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/single-expression-number.json @@ -9,15 +9,18 @@ } ], "calculationConfig": { - "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/validation/validation.schema.v1.json", + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/calculation/calculation.schema.v1.json", "calculations": { - "form.fourTimesTwoString": ["four-times-two"], - "form.fourTimesTwoNumber": ["four-times-two"] - }, - "definitions": { - "four-times-two": { - "condition": ["multiply", 4, 2] - } + "form.fourTimesTwoString": [ + { + "condition": ["multiply", 4, 2] + } + ], + "form.fourTimesTwoNumber": [ + { + "condition": ["multiply", 4, 2] + } + ] } }, "formData": { diff --git a/test/Altinn.App.Core.Tests/Features/Validators/expression-validation-tests/shared/component-lookup-hidden.json b/test/Altinn.App.Core.Tests/Features/Validators/expression-validation-tests/shared/component-lookup-hidden.json index 051283f600..9ccf423881 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/expression-validation-tests/shared/component-lookup-hidden.json +++ b/test/Altinn.App.Core.Tests/Features/Validators/expression-validation-tests/shared/component-lookup-hidden.json @@ -41,7 +41,7 @@ }, "layouts": { "Page": { - "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/layout/layout.schema.v1.json", "data": { "layout": [ { 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 2fbe64b0bf..bae482bdc0 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 @@ -4,6 +4,7 @@ namespace Altinn.App.Core.Configuration { public class AppSettings { + public const string CALCULATION_CONFIG_FILENAME = "calculation.json"; public const string JSON_SCHEMA_FILENAME = "schema.json"; public const string VALIDATION_CONFIG_FILENAME = "validation.json"; public AppSettings() { } @@ -16,6 +17,7 @@ namespace Altinn.App.Core.Configuration [System.Obsolete("This is not used, and will be removed in the next major version")] public string BaseResourceFolderContainer { get; set; } public int CacheResourceLifeTimeInSeconds { get; set; } + public string CalculationConfigurationFileName { get; set; } public string ConfigurationFolder { get; set; } public string DefaultBootstrapUrl { get; set; } public bool DisableCsrfCheck { get; set; } @@ -2282,7 +2284,7 @@ namespace Altinn.App.Core.Helpers.DataModel public string AddIndicies(string field, System.ReadOnlySpan rowIndexes = default) { } public object? GetModelData(string field, System.ReadOnlySpan rowIndexes = default) { } public int? GetModelDataCount(string field, System.ReadOnlySpan rowIndexes = default) { } - public string[] GetResolvedKeys(string field) { } + public string[] GetResolvedKeys(string field, bool isCalculating = false) { } public void RemoveField(string field, Altinn.App.Core.Helpers.RowRemovalOption rowRemovalOption) { } } } @@ -2332,6 +2334,7 @@ namespace Altinn.App.Core.Implementation public Altinn.Platform.Storage.Interface.Models.Application GetApplication() { } public string? GetApplicationBPMNProcess() { } public string? GetApplicationXACMLPolicy() { } + public string? GetCalculationConfiguration(string dataTypeId) { } public string GetClassRefForLogicDataType(string dataType) { } public System.Threading.Tasks.Task GetFooter() { } [System.Obsolete("Use GetLayoutModelForTask instead")] @@ -2908,6 +2911,7 @@ namespace Altinn.App.Core.Internal.App [System.Obsolete("GetApplication is scheduled for removal. Use Altinn.App.Core.Internal.App.IAppMet" + "adata.GetApplicationXACMLPolicy instead", false)] string? GetApplicationXACMLPolicy(); + string? GetCalculationConfiguration(string dataTypeId); string GetClassRefForLogicDataType(string dataType); System.Threading.Tasks.Task GetFooter(); [System.Obsolete("Use GetLayoutModelForTask instead", false)] @@ -3115,6 +3119,7 @@ namespace Altinn.App.Core.Internal.Expressions [System.Obsolete("Use ComponentContext.IsHidden or ComponentContext.EvaluateExpression instead")] public static System.Threading.Tasks.Task EvaluateBooleanExpression(Altinn.App.Core.Internal.Expressions.LayoutEvaluatorState state, Altinn.App.Core.Models.Expressions.ComponentContext context, string property, bool defaultReturn) { } public static System.Threading.Tasks.Task EvaluateExpression(Altinn.App.Core.Internal.Expressions.LayoutEvaluatorState state, Altinn.App.Core.Models.Expressions.Expression expr, Altinn.App.Core.Models.Expressions.ComponentContext context, object?[]? positionalArguments = null) { } + public static System.Threading.Tasks.Task EvaluateExpressionToExpressionValue(Altinn.App.Core.Internal.Expressions.LayoutEvaluatorState state, Altinn.App.Core.Models.Expressions.Expression expr, Altinn.App.Core.Models.Expressions.ComponentContext context, object?[]? positionalArguments = null) { } } public class ExpressionEvaluatorTypeErrorException : Altinn.App.Core.Exceptions.AltinnException { @@ -3186,7 +3191,7 @@ namespace Altinn.App.Core.Internal.Expressions public string GetInstanceContext(string key) { } public string GetLanguage() { } public System.Threading.Tasks.Task GetModelData(Altinn.App.Core.Models.Layout.ModelBinding key, Altinn.App.Core.Models.DataElementIdentifier? defaultDataElementIdentifier, int[]? indexes) { } - public System.Threading.Tasks.Task GetResolvedKeys(Altinn.App.Core.Models.Layout.DataReference reference) { } + public System.Threading.Tasks.Task GetResolvedKeys(Altinn.App.Core.Models.Layout.DataReference reference, bool isCalculating = false) { } public System.TimeZoneInfo? GetTimeZone() { } public System.Threading.Tasks.Task RemoveDataField(Altinn.App.Core.Models.Layout.DataReference key, Altinn.App.Core.Helpers.RowRemovalOption rowRemovalOption) { } public System.Threading.Tasks.Task TranslateText(string textKey, Altinn.App.Core.Models.Expressions.ComponentContext context) { } From 1ea1ece10c5597c8617cd55ed16c667f648845ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olav=20S=C3=B8rlie?= Date: Tue, 10 Mar 2026 11:39:52 +0100 Subject: [PATCH 7/9] Fixed spacing --- .../shared/single-expression-boolean.json | 1 - 1 file changed, 1 deletion(-) diff --git a/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/shared/single-expression-boolean.json b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/shared/single-expression-boolean.json index d4ad5b4698..bf716a3152 100644 --- a/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/shared/single-expression-boolean.json +++ b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/shared/single-expression-boolean.json @@ -17,7 +17,6 @@ "calculationConfig": { "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/calculation/calculation.schema.v1.json", "calculations": { - "form.nameResultBoolean": [ { "condition": ["equals", ["dataModel", "form.name"], "none"] From 1f961e4effb5f0bea1d0f58dbdf8c3c76947676c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olav=20S=C3=B8rlie?= Date: Tue, 10 Mar 2026 11:42:50 +0100 Subject: [PATCH 8/9] Fixed DI --- .../Features/DataProcessing/DataFieldValueCalculator.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculator.cs b/src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculator.cs index 20bd917c4f..872f967100 100644 --- a/src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculator.cs +++ b/src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculator.cs @@ -28,13 +28,14 @@ public DataFieldValueCalculator( ILogger logger, ILayoutEvaluatorStateInitializer layoutEvaluatorStateInitializer, IAppResources appResourceService, - IDataElementAccessChecker dataElementAccessChecker - ) + IDataElementAccessChecker dataElementAccessChecker, + Telemetry? telemetry) { _logger = logger; _appResourceService = appResourceService; _layoutEvaluatorStateInitializer = layoutEvaluatorStateInitializer; _dataElementAccessChecker = dataElementAccessChecker; + _telemetry = telemetry; } public async Task Calculate(IInstanceDataAccessor dataAccessor, string taskId) From e9712ef2c649a1fe109bbe4bdc6a7d84a3bc284c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olav=20S=C3=B8rlie?= Date: Tue, 10 Mar 2026 13:24:51 +0100 Subject: [PATCH 9/9] Fixed unit tests --- .../Features/DataProcessing/DataFieldValueCalculator.cs | 3 ++- .../Features/DataProcessing/DataFieldValueCalculatorTests.cs | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculator.cs b/src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculator.cs index 872f967100..1b3b8de572 100644 --- a/src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculator.cs +++ b/src/Altinn.App.Core/Features/DataProcessing/DataFieldValueCalculator.cs @@ -29,7 +29,8 @@ public DataFieldValueCalculator( ILayoutEvaluatorStateInitializer layoutEvaluatorStateInitializer, IAppResources appResourceService, IDataElementAccessChecker dataElementAccessChecker, - Telemetry? telemetry) + Telemetry? telemetry + ) { _logger = logger; _appResourceService = appResourceService; diff --git a/test/Altinn.App.Core.Tests/Features/DataProcessing/DataFieldValueCalculatorTests.cs b/test/Altinn.App.Core.Tests/Features/DataProcessing/DataFieldValueCalculatorTests.cs index 50b9e61143..c11d747142 100644 --- a/test/Altinn.App.Core.Tests/Features/DataProcessing/DataFieldValueCalculatorTests.cs +++ b/test/Altinn.App.Core.Tests/Features/DataProcessing/DataFieldValueCalculatorTests.cs @@ -42,12 +42,15 @@ public DataFieldValueCalculatorTests(ITestOutputHelper output) var dataElementAccessChecker = new Mock(); + var telemetry = new TelemetrySink(); + _output = output; _dataFieldValueCalculator = new DataFieldValueCalculator( _logger, _layoutInitializer.Object, _appResources.Object, - dataElementAccessChecker.Object + dataElementAccessChecker.Object, + telemetry.Object ); }