diff --git a/.github/workflows/check-label-added.yml b/.github/workflows/check-label-added.yml index 8ca645c974..cea2c9297f 100644 --- a/.github/workflows/check-label-added.yml +++ b/.github/workflows/check-label-added.yml @@ -1,6 +1,9 @@ name: "Label Check" on: pull_request: + branches: + - "main" + - "release/**" types: [opened, edited, labeled, unlabeled, synchronize] jobs: diff --git a/.github/workflows/dotnet-test.yml b/.github/workflows/dotnet-test.yml index f2eea3bd6d..5efa6d50d7 100644 --- a/.github/workflows/dotnet-test.yml +++ b/.github/workflows/dotnet-test.yml @@ -6,13 +6,17 @@ on: - "main" - "release/**" pull_request: - branches: - - "main" - - "release/**" + types: + - opened + - synchronize + - reopened + - edited workflow_dispatch: jobs: analyze: + # Only run on pull_request edit if the base branch was changed, to avoid running when only description or title was edited. + if: github.event.action != 'edited' || github.event.changes.base.ref != null strategy: matrix: os: [macos-latest, windows-latest, ubuntu-latest] diff --git a/src/Altinn.App.Core/Configuration/AppSettings.cs b/src/Altinn.App.Core/Configuration/AppSettings.cs index 0be2718283..108e99c838 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/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs index 5963d2d24a..79d8203e3d 100644 --- a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs @@ -189,6 +189,7 @@ IWebHostEnvironment env #pragma warning restore CS0618, CS0612 // Type or member is obsolete services.TryAddTransient(); services.TryAddTransient(); + services.TryAddTransient(); services.TryAddTransient(); services.TryAddTransient(); services.TryAddTransient(); @@ -196,6 +197,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/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/DataModelFieldCalculator.cs b/src/Altinn.App.Core/Features/DataProcessing/DataModelFieldCalculator.cs new file mode 100644 index 0000000000..08b174ba36 --- /dev/null +++ b/src/Altinn.App.Core/Features/DataProcessing/DataModelFieldCalculator.cs @@ -0,0 +1,198 @@ +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.Logging; +using ComponentContext = Altinn.App.Core.Models.Expressions.ComponentContext; + +namespace Altinn.App.Core.Features.DataProcessing; + +internal sealed class DataModelFieldCalculator +{ + private static readonly JsonSerializerOptions _jsonSerializerOptions = new() + { + ReadCommentHandling = JsonCommentHandling.Skip, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + private readonly ILogger _logger; + private readonly IAppResources _appResourceService; + private readonly IDataElementAccessChecker _dataElementAccessChecker; + private readonly Telemetry? _telemetry; + + public DataModelFieldCalculator( + ILogger logger, + IAppResources appResourceService, + IDataElementAccessChecker dataElementAccessChecker, + Telemetry? telemetry = null + ) + { + _logger = logger; + _appResourceService = appResourceService; + _dataElementAccessChecker = dataElementAccessChecker; + _telemetry = telemetry; + } + + 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) + { + continue; + } + + var calculationConfig = _appResourceService.GetCalculationConfiguration(dataType.Id); + if (!string.IsNullOrEmpty(calculationConfig)) + { + await CalculateFormData(dataAccessor, dataElement, calculationConfig); + } + } + } + + internal async Task CalculateFormData( + IInstanceDataAccessor dataAccessor, + DataElement dataElement, + string rawCalculationConfig + ) + { + DataElementIdentifier dataElementIdentifier = dataElement; + var dataModelFieldCalculations = ParseDataModelFieldCalculationConfig(rawCalculationConfig); + var formDataWrapper = await dataAccessor.GetFormDataWrapper(dataElement); + + foreach (var (baseField, calculation) in dataModelFieldCalculations) + { + var resolvedFields = formDataWrapper.GetResolvedKeys(baseField); + foreach (var resolvedField in resolvedFields) + { + var resolvedFieldReference = new DataReference() + { + Field = resolvedField, + DataElementIdentifier = dataElementIdentifier, + }; + var context = new ComponentContext( + dataAccessor, + component: null, + rowIndices: ExpressionHelper.GetRowIndices(resolvedField), + dataElementIdentifier: dataElementIdentifier + ); + var positionalArguments = new ExpressionValue[] { resolvedField }; + + await RunCalculation( + dataAccessor, + context, + formDataWrapper, + resolvedFieldReference, + positionalArguments, + calculation + ); + } + } + } + + private async Task RunCalculation( + IInstanceDataAccessor dataAccessor, + ComponentContext context, + IFormDataWrapper formDataWrapper, + DataReference resolvedField, + ExpressionValue[] positionalArguments, + DataModelFieldCalculation calculation + ) + { + try + { + var calculationResult = await ExpressionEvaluator.EvaluateExpressionToExpressionValue( + dataAccessor, + calculation.Expression, + context, + positionalArguments + ); + 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) + { + _logger.LogError(e, "Error while evaluating calculation for field {Field}", resolvedField.Field); + throw; + } + } + + private Dictionary ParseDataModelFieldCalculationConfig( + string rawCalculationConfig + ) + { + JsonDocument calculationConfigDocument; + try + { + calculationConfigDocument = JsonDocument.Parse( + rawCalculationConfig, + new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip } + ); + } + catch (JsonException e) + { + _logger.LogError(e, "Failed to parse calculation configuration JSON"); + return new Dictionary(); + } + using (calculationConfigDocument) + { + var dataModelFieldCalculations = new Dictionary(); + var hasCalculations = calculationConfigDocument.RootElement.TryGetProperty( + "calculations", + out JsonElement calculationsObject + ); + if (hasCalculations) + { + foreach (var calculationArray in calculationsObject.EnumerateObject()) + { + var field = calculationArray.Name; + var calculation = calculationArray.Value; + var resolvedDataModelFieldCalculation = ResolveDataModelFieldCalculation(field, calculation); + if (resolvedDataModelFieldCalculation == null) + { + _logger.LogError("Calculation for field {Field} could not be resolved", field); + continue; + } + dataModelFieldCalculations[field] = resolvedDataModelFieldCalculation; + } + } + return dataModelFieldCalculations; + } + } + + private DataModelFieldCalculation? ResolveDataModelFieldCalculation(string field, JsonElement definition) + { + var dataModelFieldCalculationDefinition = definition.Deserialize( + _jsonSerializerOptions + ); + if (dataModelFieldCalculationDefinition == null) + { + _logger.LogError("Calculation for field {Field} could not be parsed", field); + return null; + } + + if (dataModelFieldCalculationDefinition.Expression == null) + { + _logger.LogError("Calculation for field {Field} is missing expression", field); + return null; + } + + var dataModelFieldCalculation = new DataModelFieldCalculation + { + Expression = dataModelFieldCalculationDefinition.Expression.Value, + }; + + return dataModelFieldCalculation; + } +} diff --git a/src/Altinn.App.Core/Features/DataProcessing/DataModelFieldCalculatorProcessor.cs b/src/Altinn.App.Core/Features/DataProcessing/DataModelFieldCalculatorProcessor.cs new file mode 100644 index 0000000000..2491a5c484 --- /dev/null +++ b/src/Altinn.App.Core/Features/DataProcessing/DataModelFieldCalculatorProcessor.cs @@ -0,0 +1,37 @@ +using Altinn.App.Core.Models; + +namespace Altinn.App.Core.Features.DataProcessing; + +/// +/// Processing data model fields that is calculated by expressions provided in [modelName].calculation.json. +/// +internal sealed class DataModelFieldCalculatorProcessor : IDataWriteProcessor +{ + private readonly DataModelFieldCalculator _dataModelFieldCalculator; + + /// + /// Initializes a new instance of the class. + /// + /// + public DataModelFieldCalculatorProcessor(DataModelFieldCalculator dataModelFieldCalculator) + { + _dataModelFieldCalculator = dataModelFieldCalculator; + } + + /// + /// 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 _dataModelFieldCalculator.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, 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/Telemetry/Telemetry.DataFieldValueCalculator.cs b/src/Altinn.App.Core/Features/Telemetry/Telemetry.DataFieldValueCalculator.cs new file mode 100644 index 0000000000..f320c85535 --- /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.DataModelFieldCalculator; + +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 DataModelFieldCalculator + { + internal const string Prefix = "DataModelFieldCalculator"; + } +} diff --git a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs index 7f2b7c26cd..a2ece68c1c 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs @@ -25,7 +25,6 @@ public class ExpressionValidator : IValidator private readonly ILogger _logger; private readonly IAppResources _appResourceService; - private readonly ILayoutEvaluatorStateInitializer _layoutEvaluatorStateInitializer; private readonly IAppMetadata _appMetadata; private readonly IDataElementAccessChecker _dataElementAccessChecker; @@ -35,14 +34,12 @@ public class ExpressionValidator : IValidator public ExpressionValidator( ILogger logger, IAppResources appResourceService, - ILayoutEvaluatorStateInitializer layoutEvaluatorStateInitializer, IAppMetadata appMetadata, IServiceProvider serviceProvider ) { _logger = logger; _appResourceService = appResourceService; - _layoutEvaluatorStateInitializer = layoutEvaluatorStateInitializer; _appMetadata = appMetadata; _dataElementAccessChecker = serviceProvider.GetRequiredService(); } @@ -96,7 +93,7 @@ public async Task> Validate( var validationConfig = _appResourceService.GetValidationConfiguration(dataType.Id); if (!string.IsNullOrEmpty(validationConfig)) { - var issues = await ValidateFormData(dataElement, dataAccessor, validationConfig, taskId, language); + var issues = await ValidateFormData(dataElement, dataAccessor, validationConfig); validationIssues.AddRange(issues); } } @@ -108,19 +105,12 @@ public async Task> Validate( internal async Task> ValidateFormData( DataElement dataElement, IInstanceDataAccessor dataAccessor, - string rawValidationConfig, - string taskId, - string? language + string rawValidationConfig ) { - var evaluatorState = await _layoutEvaluatorStateInitializer.Init( - dataAccessor, - taskId, - gatewayAction: null, - language - ); + var formDataWrapper = await dataAccessor.GetFormDataWrapper(dataElement); var hiddenFields = await LayoutEvaluator.GetHiddenFieldsForRemoval( - evaluatorState, + dataAccessor.GetLayoutEvaluatorState(), evaluateRemoveWhenHidden: false ); @@ -130,9 +120,11 @@ internal async Task> ValidateFormData( foreach (var (baseField, validations) in expressionValidations) { - var resolvedFields = await evaluatorState.GetResolvedKeys( - new DataReference() { Field = baseField, DataElementIdentifier = dataElementIdentifier } - ); + var resolvedFields = await dataAccessor + .GetLayoutEvaluatorState() + .GetResolvedKeys( + new DataReference() { Field = baseField, DataElementIdentifier = dataElementIdentifier } + ); foreach (var resolvedField in resolvedFields) { if ( @@ -147,16 +139,17 @@ internal async Task> ValidateFormData( var context = new ComponentContext( dataAccessor, component: null, - rowIndices: GetRowIndices(resolvedField.Field), + rowIndices: ExpressionHelper.GetRowIndices(resolvedField.Field), dataElementIdentifier: resolvedField.DataElementIdentifier ); - var positionalArguments = new object[] { resolvedField.Field }; + var positionalArguments = new ExpressionValue[] { resolvedField.Field }; foreach (var validation in validations) { await RunValidation( - evaluatorState, + dataAccessor, validationIssues, resolvedField, + formDataWrapper, context, positionalArguments, validation @@ -168,66 +161,33 @@ 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, + IInstanceDataAccessor dataAccessor, List validationIssues, DataReference resolvedField, + IFormDataWrapper formDataWrapper, ComponentContext context, - object[] positionalArguments, + ExpressionValue[] positionalArguments, ExpressionValidation validation ) { try { - var validationResult = await ExpressionEvaluator.EvaluateExpression( - evaluatorState, + if (formDataWrapper.Get(resolvedField.Field) == null) + { + return; // Assume that the required validator will catch empty fields. + } + var validationResult = await ExpressionEvaluator.EvaluateExpressionToExpressionValue( + dataAccessor, validation.Condition, context, positionalArguments ); - switch (validationResult) + switch (validationResult.ValueKind) { - case true: - var message = await ExpressionEvaluator.EvaluateExpression( - evaluatorState, + case JsonValueKind.True: + var message = await ExpressionEvaluator.EvaluateExpressionToExpressionValue( + dataAccessor, validation.Message, context, positionalArguments @@ -238,13 +198,13 @@ ExpressionValidation validation Field = resolvedField.Field, DataElementId = resolvedField.DataElementIdentifier.Id, Severity = validation.Severity ?? ValidationIssueSeverity.Error, - CustomTextKey = message as string ?? "", - Code = message as string ?? "", + Code = message.ToStringForText(), + CustomTextKey = message.ToStringForText(), }; validationIssues.Add(validationIssue); break; - case false: + case JsonValueKind.False: break; default: throw new ArgumentException( diff --git a/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs b/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs index 6c2fa1b89a..df16083c49 100644 --- a/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs +++ b/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs @@ -67,7 +67,7 @@ ReadOnlySpan rowIndexes return currentModel; } - var (key, groupIndex) = ParseKeyPart(keys[index]); + var (key, groupIndex, _) = ParseKeyPart(keys[index]); var prop = Array.Find(currentModel.GetType().GetProperties(), p => IsPropertyWithJsonName(p, key)); var childModel = prop?.GetValue(currentModel); if (childModel is null) @@ -111,7 +111,10 @@ ReadOnlySpan rowIndexes } /// - /// Get all valid indexed keys for the field, depending on the number of rows in repeating groups + /// Get all valid indexed keys for the field, depending on the number of rows in repeating groups. + /// A collection in the middle of the path is always expanded over its rows. For a collection at + /// the end of the path, "group" refers to the collection itself, "group[]" enumerates every row, + /// and "group[n]" refers to a single row. /// /// /// GetResolvedKeys("data.bedrifter.styre.medlemmer") => @@ -120,6 +123,13 @@ ReadOnlySpan rowIndexes /// "data.bedrifter[1].styre.medlemmer" /// ... /// ] + /// GetResolvedKeys("data.bedrifter[].styre.medlemmer[]") => + /// [ + /// "data.bedrifter[0].styre.medlemmer[0]", + /// "data.bedrifter[0].styre.medlemmer[1]", + /// "data.bedrifter[1].styre.medlemmer[0]", + /// ... + /// ] /// public string[] GetResolvedKeys(string field) { @@ -129,7 +139,8 @@ public string[] GetResolvedKeys(string field) } var fieldParts = field.Split('.'); - return GetResolvedKeysRecursive(fieldParts, _dataModel); + return GetResolvedKeysRecursive(fieldParts, _dataModel, _dataModel.GetType(), currentIndex: 0, currentKey: "") + .ToArray(); } private static string JoinFieldKeyParts(string? currentKey, string? key) @@ -146,63 +157,101 @@ private static string JoinFieldKeyParts(string? currentKey, string? key) return currentKey + "." + key; } - private static string[] GetResolvedKeysRecursive( + private static IEnumerable GetResolvedKeysRecursive( string[] keyParts, - object currentModel, - int currentIndex = 0, - string currentKey = "" + object? currentModel, + Type currentType, + int currentIndex, + string currentKey ) { - 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 (key, groupIndex, emptyIndex) = ParseKeyPart(keyParts[currentIndex]); + var lookupType = currentModel?.GetType() ?? currentType; + var prop = Array.Find(lookupType.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; + bool isLastPart = currentIndex == keyParts.Length - 1; + + // Collection (but not string) + if (childType != typeof(string) && childType.IsAssignableTo(typeof(System.Collections.IEnumerable))) { - // childModel is a list - if (groupIndex is null) + // A bare collection as the last part of the path (e.g. "group") refers to the + // collection itself, not its rows, so we return the key without enumerating (just + // like a non-collection leaf). Use "group[]" to enumerate every row, or "group[0]" + // to refer to a single row. + if (isLastPart && groupIndex is null && !emptyIndex) + { + return [JoinFieldKeyParts(currentKey, key)]; + } + + // Indexing into, or descending through, the collection requires the instance. + var childModel = currentModel is not null ? prop.GetValue(currentModel) : null; + if (childModel is not System.Collections.IEnumerable childModelList) + { + // Null collection: there are no rows to descend into, so nothing resolves. + return []; + } + + if (groupIndex is not null) { - // Index not specified, recurse on all elements - int i = 0; - var resolvedKeys = new List(); - foreach (var child in childModelList) + var elementAt = GetElementAt(childModelList, groupIndex.Value); + if (elementAt is null) + return []; + return GetResolvedKeysRecursive( + keyParts, + elementAt, + elementAt.GetType(), + currentIndex + 1, + JoinFieldKeyParts(currentKey, $"{key}[{groupIndex.Value}]") + ); + } + + // Enumerate every row: either an unindexed collection in the middle of the path + // (descending towards the rest of the path), or "group[]" at the end. + var resolvedKeys = new List(); + int i = 0; + foreach (var child in childModelList) + { + if (child is not null) // null rows can't be set/resolved, skip them { - var newResolvedKeys = GetResolvedKeysRecursive( - keyParts, - child, - currentIndex + 1, - JoinFieldKeyParts(currentKey, key + "[" + i + "]") + resolvedKeys.AddRange( + GetResolvedKeysRecursive( + keyParts, + child, + child.GetType(), + currentIndex + 1, + JoinFieldKeyParts(currentKey, $"{key}[{i}]") + ) ); - resolvedKeys.AddRange(newResolvedKeys); - i++; } - return resolvedKeys.ToArray(); + i++; } - // Index specified, recurse on that element - return GetResolvedKeysRecursive( - keyParts, - childModel, - currentIndex + 1, - JoinFieldKeyParts(currentKey, key + "[" + groupIndex + "]") - ); + return resolvedKeys; } - // Otherwise, just recurse - return GetResolvedKeysRecursive(keyParts, childModel, currentIndex + 1, JoinFieldKeyParts(currentKey, key)); + // Non-collection: resolve the key (even if the value is null) ... + if (isLastPart) + { + return [JoinFieldKeyParts(currentKey, key)]; + } + // ... otherwise traverse, falling back to the declared type when the value is null + var childValue = currentModel is not null ? prop.GetValue(currentModel) : null; + return GetResolvedKeysRecursive( + keyParts, + childValue, + childType, + currentIndex + 1, + JoinFieldKeyParts(currentKey, key) + ); } private static object? GetElementAt(System.Collections.IEnumerable enumerable, int index) @@ -225,18 +274,24 @@ private static string[] GetResolvedKeysRecursive( TimeSpan.FromMilliseconds(2) ); - private static (string key, int? index) ParseKeyPart(string keyPart) + private static (string key, int? index, bool emptyIndex) ParseKeyPart(string keyPart) { if (keyPart.Length == 0) { throw new DataModelException("Tried to parse empty part of dataModel key"); } - if (keyPart.Last() != ']') + if (keyPart[^1] != ']') + { + return (keyPart, null, false); + } + // "group[]" refers to every row of the collection (as opposed to "group", which refers + // to the collection itself, or "group[n]", which refers to a single row). + if (keyPart.EndsWith("[]", StringComparison.Ordinal)) { - return (keyPart, null); + return (keyPart[..^2], null, true); } var match = _keyPartRegex.Match(keyPart); - return (match.Groups[1].Value, int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture)); + return (match.Groups[1].Value, int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture), false); } private static void AddIndexesRecursive( @@ -250,7 +305,7 @@ ReadOnlySpan indexes { return; } - var (key, groupIndex) = ParseKeyPart(keys[0]); + var (key, groupIndex, _) = ParseKeyPart(keys[0]); var prop = Array.Find(currentModelType.GetProperties(), p => IsPropertyWithJsonName(p, key)); if (prop is null) { @@ -358,7 +413,7 @@ public void RemoveField(string field, RowRemovalOption rowRemovalOption) { var fieldSplit = field.Split('.'); var keys = fieldSplit[0..^1]; - var (lastKey, lastGroupIndex) = ParseKeyPart(fieldSplit[^1]); + var (lastKey, lastGroupIndex, _) = ParseKeyPart(fieldSplit[^1]); var containingObject = GetModelDataRecursive(keys, 0, _dataModel, default); if (containingObject is null) 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/Data/IFormDataWrapper.cs b/src/Altinn.App.Core/Internal/Data/IFormDataWrapper.cs index 4ec781b23b..c556f28a7f 100644 --- a/src/Altinn.App.Core/Internal/Data/IFormDataWrapper.cs +++ b/src/Altinn.App.Core/Internal/Data/IFormDataWrapper.cs @@ -3,7 +3,6 @@ using Altinn.App.Core.Helpers; using Altinn.App.Core.Helpers.DataModel; using Altinn.App.Core.Internal.Expressions; -using Altinn.App.Core.Models.Layout; using Altinn.Platform.Storage.Interface.Models; namespace Altinn.App.Core.Internal.Data; @@ -287,26 +286,22 @@ private static int InvokeReturnIntOrError(MethodInfo info, object instance) } /// - /// Get a list of all possible keys for the given data model + /// Get a list of all possible keys for the given data model at the path /// /// - /// intro.fnr - /// group[0].name - /// group[0].age - /// group[1].name - /// group[1].age + /// group.name -> ["group[0].name", "group[1].name"] + /// group.age -> ["group[0].age", "group[1].age"] /// - public static DataReference[] GetResolvedKeys(this IFormDataWrapper formDataWrapper, DataReference reference) + public static string[] GetResolvedKeys(this IFormDataWrapper formDataWrapper, string path) { //TODO: write more efficient code that uses the formDataWrapper to resolve keys instead of reflection in DataModelWrapper + // The current implementation also does not throw exceptions when the path ends in an enumerable. + // When resolving "group" it is not clear if the result should be "group[0]", "group[1]", or just "group"." var data = formDataWrapper.BackingData(); #pragma warning disable CS0618 // Type or member is obsolete var dataModelWrapper = new DataModelWrapper(data); #pragma warning restore CS0618 // Type or member is obsolete - return dataModelWrapper - .GetResolvedKeys(reference.Field) - .Select(resolvedField => reference with { Field = resolvedField }) - .ToArray(); + return dataModelWrapper.GetResolvedKeys(path); } private static int GetMaxBufferLength(ReadOnlySpan path, ReadOnlySpan rowIndexes) diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs index 8e17ee1cfb..ea2b824440 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.Text.Json; using System.Text.RegularExpressions; +using Altinn.App.Core.Features; using Altinn.App.Core.Models; using Altinn.App.Core.Models.Expressions; using Altinn.App.Core.Models.Layout; @@ -51,6 +52,19 @@ bool defaultReturn } } + /// + /// Evaluate a from a given in a + /// + public static async Task EvaluateExpressionToExpressionValue( + IInstanceDataAccessor state, + Expression expr, + ComponentContext context, + ExpressionValue[]? positionalArguments = null + ) + { + return await EvaluateExpression_internal(state.GetLayoutEvaluatorState(), expr, context, positionalArguments); + } + /// /// 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..c2eb708765 --- /dev/null +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionHelper.cs @@ -0,0 +1,50 @@ +namespace Altinn.App.Core.Internal.Expressions; + +internal static class ExpressionHelper +{ + 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++) + { + 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)) + { + 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 + } + 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/Internal/Expressions/LayoutEvaluatorState.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs index e06f0b8dbc..a7bcc86a54 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs @@ -235,7 +235,9 @@ private static bool RowIndexMatch(int[]? searchRowIndexes, int[]? componentRowIn public async Task GetResolvedKeys(DataReference reference) { var data = await DataAccessor.GetFormDataWrapper(reference.DataElementIdentifier); - return data.GetResolvedKeys(reference); + return data.GetResolvedKeys(reference.Field) + .Select(resolvedField => reference with { Field = resolvedField }) + .ToArray(); } /// diff --git a/src/Altinn.App.Core/Models/RawDataModelFieldCalculation.cs b/src/Altinn.App.Core/Models/RawDataModelFieldCalculation.cs new file mode 100644 index 0000000000..31a5609ca3 --- /dev/null +++ b/src/Altinn.App.Core/Models/RawDataModelFieldCalculation.cs @@ -0,0 +1,25 @@ +using Altinn.App.Core.Models.Expressions; + +namespace Altinn.App.Core.Models; + +/// +/// Resolved data field calculation +/// +internal sealed class DataModelFieldCalculation +{ + /// + /// Expression to evaluate + /// + public required Expression Expression { get; set; } +} + +/// +/// Raw value calculation expression from the calculation configuration file +/// +internal sealed class RawDataModelFieldCalculation +{ + /// + /// Expression to evaluate + /// + public Expression? Expression { get; set; } +} diff --git a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_FailingValidator_ReturnsValidationErrors.verified.txt b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_FailingValidator_ReturnsValidationErrors.verified.txt index ad10b6bf5f..7c790e0dea 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_FailingValidator_ReturnsValidationErrors.verified.txt +++ b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_FailingValidator_ReturnsValidationErrors.verified.txt @@ -5,21 +5,6 @@ IdFormat: W3C, HasParent: true }, - { - Name: ApplicationMetadata.Service.GetLayoutModel, - IdFormat: W3C, - HasParent: true - }, - { - Name: ApplicationMetadata.Service.GetLayoutSet, - IdFormat: W3C, - HasParent: true - }, - { - Name: ApplicationMetadata.Service.GetLayoutSet, - IdFormat: W3C, - HasParent: true - }, { Name: ApplicationMetadata.Service.GetLayoutSet, IdFormat: W3C, @@ -50,31 +35,11 @@ IdFormat: W3C, HasParent: true }, - { - Name: ApplicationMetadata.Service.GetLayoutSets, - IdFormat: W3C, - HasParent: true - }, - { - Name: ApplicationMetadata.Service.GetLayoutSets, - IdFormat: W3C, - HasParent: true - }, { Name: ApplicationMetadata.Service.GetLayoutSetsForTask, IdFormat: W3C, HasParent: true }, - { - Name: ApplicationMetadata.Service.GetLayoutSetsForTask, - IdFormat: W3C, - HasParent: true - }, - { - Name: ApplicationMetadata.Service.GetLayoutSettingsForSet, - IdFormat: W3C, - HasParent: true - }, { Name: ApplicationMetadata.Service.GetLayoutSettingsForSet, IdFormat: W3C, diff --git a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_PdfFails_DataIsUnlocked.verified.txt b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_PdfFails_DataIsUnlocked.verified.txt index cf37d7c958..0e02cff039 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_PdfFails_DataIsUnlocked.verified.txt +++ b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_PdfFails_DataIsUnlocked.verified.txt @@ -5,21 +5,6 @@ IdFormat: W3C, HasParent: true }, - { - Name: ApplicationMetadata.Service.GetLayoutModel, - IdFormat: W3C, - HasParent: true - }, - { - Name: ApplicationMetadata.Service.GetLayoutSet, - IdFormat: W3C, - HasParent: true - }, - { - Name: ApplicationMetadata.Service.GetLayoutSet, - IdFormat: W3C, - HasParent: true - }, { Name: ApplicationMetadata.Service.GetLayoutSet, IdFormat: W3C, @@ -50,31 +35,11 @@ IdFormat: W3C, HasParent: true }, - { - Name: ApplicationMetadata.Service.GetLayoutSets, - IdFormat: W3C, - HasParent: true - }, - { - Name: ApplicationMetadata.Service.GetLayoutSets, - IdFormat: W3C, - HasParent: true - }, { Name: ApplicationMetadata.Service.GetLayoutSetsForTask, IdFormat: W3C, HasParent: true }, - { - Name: ApplicationMetadata.Service.GetLayoutSetsForTask, - IdFormat: W3C, - HasParent: true - }, - { - Name: ApplicationMetadata.Service.GetLayoutSettingsForSet, - IdFormat: W3C, - HasParent: true - }, { Name: ApplicationMetadata.Service.GetLayoutSettingsForSet, IdFormat: W3C, diff --git a/test/Altinn.App.Core.Tests/Features/DataProcessing/DataModelFieldCalculatorTests.cs b/test/Altinn.App.Core.Tests/Features/DataProcessing/DataModelFieldCalculatorTests.cs new file mode 100644 index 0000000000..43a310f8ae --- /dev/null +++ b/test/Altinn.App.Core.Tests/Features/DataProcessing/DataModelFieldCalculatorTests.cs @@ -0,0 +1,228 @@ +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 Microsoft.Extensions.Logging; +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 sealed class DataModelFieldCalculatorTests +{ + private readonly ITestOutputHelper _output; + private readonly DataModelFieldCalculator _dataModelFieldCalculator; + private readonly FakeLogger _logger = new(); + private readonly Mock _appResources = 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, + }; + + private DataElement _dataElement = null!; + private IInstanceDataAccessor _instanceDataAccessor = null!; + + public DataModelFieldCalculatorTests(ITestOutputHelper output) + { + var dataElementAccessChecker = new Mock(); + dataElementAccessChecker.Setup(x => x.CanRead(It.IsAny(), It.IsAny())).ReturnsAsync(true); + + var telemetry = new TelemetrySink(); + + _output = output; + _dataModelFieldCalculator = new DataModelFieldCalculator( + _logger, + _appResources.Object, + dataElementAccessChecker.Object, + telemetry.Object + ); + } + + private async Task LoadData(string fileName, string folder) + { + var data = await File.ReadAllTextAsync(Path.Join(folder, fileName)); + _output.WriteLine(data); + return JsonSerializer.Deserialize(data, _jsonSerializerOptions)!; + } + + [Fact] + public async Task ShouldLogErrorAndThrowWhenExpressionEvaluatorThrowsException() + { + var testCaseJson = """ + { + "name": "Should log error and throw when ExpressionEvaluator throws exception", + "expects": [ + { + "logMessage": "Error while evaluating calculation for field form.formDataWrapperThrows" + } + ], + "calculationConfig": { + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/calculation/calculation.schema.v1.json", + "calculations": { + "form.formDataWrapperThrows": { + "expression": ["noneExistingExpression"] + } + } + }, + "formData": { + "form": { + "formDataWrapperThrows": true + } + }, + "layouts": {} + } + """; + _output.WriteLine(testCaseJson); + var testCase = JsonSerializer.Deserialize( + testCaseJson, + _jsonSerializerOptions + )!; + + Setup(testCase); + + var exception = await Assert.ThrowsAsync(() => + _dataModelFieldCalculator.CalculateFormData( + _instanceDataAccessor, + _dataElement, + JsonSerializer.Serialize(testCase.CalculationConfig) + ) + ); + + Assert.Contains(testCase.Expects.First().LogMessage, _logger.Collector.GetSnapshot().Select(x => x.Message)); + Assert.Contains( + $"Function \"noneExistingExpression\" not implemented in backend [\"noneExistingExpression\"]", + exception.Message + ); + } + + [Theory] + [FileNamesInFolderData(["Features", "DataProcessing", "data-field-value-calculator-tests", "assert-logger"])] + public async Task RunDataModelFieldCalculationTestsThatAssertLogger(string fileName, string folder) + { + var (_, testCase) = await RunDataModelFieldCalculatorTest(fileName, folder); + + foreach (var expected in testCase.Expects) + { + Assert.Contains(expected.LogMessage, _logger.Collector.GetSnapshot().Select(x => x.Message)); + } + } + + [Theory] + [FileNamesInFolderData(["Features", "DataProcessing", "data-field-value-calculator-tests"])] + public async Task RunDataModelFieldCalculationTests(string fileName, string folder) + { + var (result, testCase) = await RunDataModelFieldCalculatorTest(fileName, folder); + + foreach (var expected in testCase.Expects) + { + if (expected.Result.HasValue) + { + Assert.Equal(expected.Result.Value.ToObject(), result.Get(expected.Field)); + Assert.Empty(_logger.Collector.GetSnapshot()); + } + else + { + Assert.Fail($"Expected result for field {expected.Field} not found"); + } + } + } + + private async Task<(IFormDataWrapper, DataModelFieldCalculatorTestModel)> RunDataModelFieldCalculatorTest( + string fileName, + string folder + ) + { + var testCase = await LoadData(fileName, folder); + + Setup(testCase); + + await _dataModelFieldCalculator.CalculateFormData( + _instanceDataAccessor, + _dataElement, + JsonSerializer.Serialize(testCase.CalculationConfig) + ); + + var formDataWrapper = await _instanceDataAccessor.GetFormDataWrapper(_dataElement); + + return (formDataWrapper, testCase); + } + + private void Setup(DataModelFieldCalculatorTestModel testCase) + { + var instance = new Instance() { Id = "1337/fa0678ad-960d-4307-aba2-ba29c9804c9d", AppId = "org/app" }; + var dataType = new DataType() { Id = "default" }; + + _dataElement = new DataElement { Id = "30844cc0-81af-4429-9f9e-035d78f1f9da", DataType = "default" }; + 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) + ); + _instanceDataAccessor = DynamicClassBuilder.DataAccessorFromJsonDocument( + instance, + translationService, + componentModel, + new FrontEndSettings(), + testCase.FormData, + gatewayAction: null, + language: null, + _dataElement + ); + + _appResources + .Setup(ar => ar.GetTexts("org", "app", "nb")) + .ReturnsAsync( + testCase.TextResources is null + ? null + : new TextResource { Language = "nb", Resources = testCase.TextResources } + ); + } + + private record DataModelFieldCalculatorTestModel + { + [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; } + } + + private record Expected + { + public string? Field { get; set; } + + public ExpressionValue? Result { get; set; } + + public string? LogMessage { get; set; } + } +} diff --git a/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/assert-logger/parse-none-existing-definition.json b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/assert-logger/parse-none-existing-definition.json new file mode 100644 index 0000000000..83b9d63ce1 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/assert-logger/parse-none-existing-definition.json @@ -0,0 +1,23 @@ +{ + "name": "Should log error when trying to parse none existing definition", + "expects": [ + { + "logMessage": "Calculation for field form.noneExistingExpression could not be parsed" + }, + { + "logMessage": "Calculation for field form.noneExistingExpression could not be resolved" + } + ], + "calculationConfig": { + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/calculation/calculation.schema.v1.json", + "calculations": { + "form.noneExistingExpression": null + } + }, + "formData": { + "form": { + "noneExistingExpression": true + } + }, + "layouts": {} +} diff --git a/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/assert-logger/parse-none-existing-expression.json b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/assert-logger/parse-none-existing-expression.json new file mode 100644 index 0000000000..f4fc211966 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/assert-logger/parse-none-existing-expression.json @@ -0,0 +1,23 @@ +{ + "name": "Should log error when trying to parse none existing expression", + "expects": [ + { + "logMessage": "Calculation for field form.noneExistingExpression is missing expression" + }, + { + "logMessage": "Calculation for field form.noneExistingExpression could not be resolved" + } + ], + "calculationConfig": { + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/calculation/calculation.schema.v1.json", + "calculations": { + "form.noneExistingExpression": {} + } + }, + "formData": { + "form": { + "noneExistingExpression": true + } + }, + "layouts": {} +} diff --git a/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/assert-logger/unsupported-data-type.json b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/assert-logger/unsupported-data-type.json new file mode 100644 index 0000000000..12ed160ff2 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/assert-logger/unsupported-data-type.json @@ -0,0 +1,30 @@ +{ + "name": "Should log warning when trying to set field with unsupported data type", + "expects": [ + { + "logMessage": "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/calculation/calculation.schema.v1.json", + "calculations": { + "form.unsupportedDataType": { + "expression": ["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": [ + ] + } + } + } +} diff --git a/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/calculate-in-group.json b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/calculate-in-group.json new file mode 100644 index 0000000000..517e217a95 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/calculate-in-group.json @@ -0,0 +1,54 @@ +{ + "name": "Should set multiple values when resolving keys in a datamodel array", + "expects": [ + { + "field": "form.children[0].total", + "result": 200 + }, + { + "field": "form.children[1].total", + "result": 220 + } + ], + "calculationConfig": { + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/calculation/calculation.schema.v1.json", + "calculations": { + "form.children.total": { + "expression": ["multiply", ["dataModel", "form.children.price"], ["dataModel", "form.children.quantity"]] + } + } + }, + "formData": { + "form": { + "children": [ + { + "price": 100, + "quantity": 2, + "total": 0 + }, + { + "price": 5, + "quantity": 44, + "total": 0 + } + ] + } + }, + "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" + }, + "hidden": true + } + ] + } + } + } +} diff --git a/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/component-lookup-hidden.json b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/component-lookup-hidden.json new file mode 100644 index 0000000000..0b1f445060 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/component-lookup-hidden.json @@ -0,0 +1,39 @@ +{ + "name": "Should set field when looking up hidden component", + "expects": [ + { + "field": "form.name", + "result": "nyVerdi" + } + ], + "calculationConfig": { + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/calculation/calculation.schema.v1.json", + "calculations": { + "form.name": { + "expression": "nyVerdi" + } + } + }, + "formData": { + "form": { + "name": "feil" + } + }, + "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" + }, + "hidden": true + } + ] + } + } + } +} diff --git a/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/hidden-field.json b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/hidden-field.json new file mode 100644 index 0000000000..5c5626ca3e --- /dev/null +++ b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/hidden-field.json @@ -0,0 +1,39 @@ +{ + "name": "Should set field when component is hidden", + "expects": [ + { + "field": "form.name", + "result": "nyVerdi" + } + ], + "calculationConfig": { + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/calculation/calculation.schema.v1.json", + "calculations": { + "form.name": { + "expression": "nyVerdi" + } + } + }, + "formData": { + "form": { + "name": "feil" + } + }, + "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" + }, + "hidden": true + } + ] + } + } + } +} diff --git a/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/hidden-page.json b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/hidden-page.json new file mode 100644 index 0000000000..0ecb878aa8 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/hidden-page.json @@ -0,0 +1,39 @@ +{ + "name": "Should set field if field is on hidden page", + "expects": [ + { + "field": "form.name", + "result": "newValue" + } + ], + "calculationConfig": { + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/calculation/calculation.schema.v1.json", + "calculations": { + "form.name": { + "expression": "newValue" + } + } + }, + "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/single-expression-boolean.json b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/single-expression-boolean.json new file mode 100644 index 0000000000..46577b9b21 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/single-expression-boolean.json @@ -0,0 +1,67 @@ +{ + "name": "Should set boolean value to field with supported data type", + "expects": [ + { + "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/calculation/calculation.schema.v1.json", + "calculations": { + "form.nameResultBoolean": { + "expression": ["equals", ["dataModel", "form.name"], "none"] + }, + "form.emailResultBoolean": { + "expression": ["equals", ["dataModel", "form.email"], "none"] + }, + "form.nameResultNumber": { + "expression": ["equals", ["dataModel", "form.name"], "none"] + }, + "form.emailResultNumber": { + "expression": ["equals", ["dataModel", "form.email"], "none"] + } + } + }, + "formData": { + "form": { + "name": "none", + "email": "email@address.com", + "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/single-expression-number.json b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/single-expression-number.json new file mode 100644 index 0000000000..4dba27e298 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Features/DataProcessing/data-field-value-calculator-tests/single-expression-number.json @@ -0,0 +1,37 @@ +{ + "name": "Should set number value to field with supported data type. TODO: Move when #1651 is merged", + "expects": [ + { + "field": "form.fourTimesTwoString", "result": "8" + }, + { + "field": "form.fourTimesTwoNumber", "result": 8 + } + ], + "calculationConfig": { + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/calculation/calculation.schema.v1.json", + "calculations": { + "form.fourTimesTwoString": { + "expression": ["multiply", 4, 2] + }, + "form.fourTimesTwoNumber": { + "expression": ["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": [ + ] + } + } + } +} diff --git a/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs index b7ca3e7edb..3d7eabf428 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs @@ -1,11 +1,9 @@ using System.Text.Json; using System.Text.Json.Serialization; using Altinn.App.Core.Configuration; -using Altinn.App.Core.Features; using Altinn.App.Core.Features.Validation.Default; using Altinn.App.Core.Internal.App; 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; @@ -31,7 +29,6 @@ public class ExpressionValidatorTests private readonly IOptions _frontendSettings = Microsoft.Extensions.Options.Options.Create( new FrontEndSettings() ); - private readonly Mock _layoutInitializer = new(MockBehavior.Strict); private static readonly JsonSerializerOptions _jsonSerializerOptions = new() { WriteIndented = true, @@ -52,7 +49,6 @@ public ExpressionValidatorTests(ITestOutputHelper output) _validator = new ExpressionValidator( _logger.Object, _appResources.Object, - _layoutInitializer.Object, _appMetadata.Object, serviceProviderMock.Object ); @@ -113,12 +109,6 @@ private async Task RunExpressionValidationTest(string fileName, string folder) var evaluatorState = dataAccessor.GetLayoutEvaluatorState(); Assert.NotNull(evaluatorState); - _layoutInitializer - .Setup(init => - init.Init(It.IsAny(), "Task_1", It.IsAny(), It.IsAny()) - ) - .ReturnsAsync(evaluatorState); - _appResources .Setup(ar => ar.GetTexts("org", "app", "nb")) .ReturnsAsync( @@ -130,9 +120,7 @@ testCase.TextResources is null var validationIssues = await _validator.ValidateFormData( dataElement, dataAccessor, - JsonSerializer.Serialize(testCase.ValidationConfig), - "Task_1", - null + JsonSerializer.Serialize(testCase.ValidationConfig) ); var result = validationIssues.Select(i => new 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/Features/Validators/expression-validation-tests/shared/ignore-value-null.json b/test/Altinn.App.Core.Tests/Features/Validators/expression-validation-tests/shared/ignore-value-null.json new file mode 100644 index 0000000000..29c898019b --- /dev/null +++ b/test/Altinn.App.Core.Tests/Features/Validators/expression-validation-tests/shared/ignore-value-null.json @@ -0,0 +1,74 @@ +{ + "name": "Should ignore validation when value is null", + "expects": [], + "validationConfig": { + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/validation/validation.schema.v1.json", + "validations": { + "personer.navn": [ + { + "message": "Always invalid rule", + "severity": "error", + "condition": true + } + ] + } + }, + "formData": { + "personer": [ + { + "altinnRowId": "person0", + "navn": null, + "utenNavn": false + }, + { + "altinnRowId": "person1", + "navn": null, + "utenNavn": true + }, + { + "altinnRowId": "person2", + "navn": null, + "utenNavn": false + } + ] + }, + "layouts": { + "Page": { + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/layout/layout.schema.v1.json", + "data": { + "layout": [ + { + "id": "personer", + "type": "RepeatingGroup", + "dataModelBindings": { + "group": "personer" + }, + "children": [ + "person-navn", + "uten-navn" + ] + }, + { + "id": "person-navn", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "personer.navn" + }, + "hidden": ["equals", ["component", "uten-navn"], true] + }, + { + "id": "uten-navn", + "type": "Checkboxes", + "dataModelBindings": { + "simpleBinding": "personer.utenNavn" + }, + "options": [ + { "label": "Ja", "value": true }, + { "label": "Nei", "value": false } + ] + } + ] + } + } + } +} 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 31dccaa87e..e535065731 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; } @@ -2154,7 +2156,7 @@ namespace Altinn.App.Core.Features.Validation.Default } public class ExpressionValidator : Altinn.App.Core.Features.IValidator { - public ExpressionValidator(Microsoft.Extensions.Logging.ILogger logger, Altinn.App.Core.Internal.App.IAppResources appResourceService, Altinn.App.Core.Internal.Expressions.ILayoutEvaluatorStateInitializer layoutEvaluatorStateInitializer, Altinn.App.Core.Internal.App.IAppMetadata appMetadata, System.IServiceProvider serviceProvider) { } + public ExpressionValidator(Microsoft.Extensions.Logging.ILogger logger, Altinn.App.Core.Internal.App.IAppResources appResourceService, Altinn.App.Core.Internal.App.IAppMetadata appMetadata, System.IServiceProvider serviceProvider) { } public string TaskId { get; } public string ValidationSource { get; } public System.Threading.Tasks.Task HasRelevantChanges(Altinn.App.Core.Features.IInstanceDataAccessor dataAccessor, string taskId, Altinn.App.Core.Models.DataElementChanges changes) { } @@ -2421,6 +2423,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")] @@ -2984,6 +2987,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)] @@ -3232,6 +3236,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.Features.IInstanceDataAccessor state, Altinn.App.Core.Models.Expressions.Expression expr, Altinn.App.Core.Models.Expressions.ComponentContext context, Altinn.App.Core.Internal.Expressions.ExpressionValue[]? positionalArguments = null) { } } public class ExpressionEvaluatorTypeErrorException : Altinn.App.Core.Exceptions.AltinnException { diff --git a/test/Altinn.App.SourceGenerator.Integration.Tests/UnitTest/TestGetResolvedKeys.cs b/test/Altinn.App.SourceGenerator.Integration.Tests/UnitTest/TestGetResolvedKeys.cs new file mode 100644 index 0000000000..3cc916e136 --- /dev/null +++ b/test/Altinn.App.SourceGenerator.Integration.Tests/UnitTest/TestGetResolvedKeys.cs @@ -0,0 +1,183 @@ +using System.Collections.Generic; +using Altinn.App.Core.Helpers.DataModel; +using Altinn.App.Core.Internal.Data; +using Altinn.App.Core.Models.Layout; +using Altinn.App.SourceGenerator.Integration.Tests.Models; +using Altinn.Platform.Storage.Interface.Models; +using Xunit; + +namespace Altinn.App.SourceGenerator.Integration.Tests.UnitTest; + +public class TestGetResolvedKeys() +{ + private readonly DataElement _dataElement = new() + { + Id = "00000000-0000-0000-0000-000000000000", + DataType = "model", + }; + private readonly DataType _dataType = new() { Id = "model" }; + + /// + /// Create a shared instance of Skjema for testing. + /// + private readonly Skjema _skjema = new Skjema() + { + Skjemanummer = "1243", + Skjemaversjon = "x4", + Skjemainnhold = + [ + new SkjemaInnhold() + { + Navn = "navn", + Alder = 42, + Deltar = true, + }, + new SkjemaInnhold() + { + Navn = "navn2", + Alder = 43, + Deltar = false, + Adresse = new() { Gate = "gate", Postnummer = 1234 }, + TidligereAdresse = + [ + new() { Gate = "gate1", Postnummer = 1235 }, + new() + { + Gate = "gate2", + Postnummer = 1236, + Tags = ["tag1", "tag2"], + }, + ], + }, + ], + EierAdresse = null, + }; + + [Theory] + [InlineData("skjemanummer", new[] { "skjemanummer" })] + [InlineData("skjemainnhold.navn", new[] { "skjemainnhold[0].navn", "skjemainnhold[1].navn" })] + [InlineData("skjemainnhold.alder", new[] { "skjemainnhold[0].alder", "skjemainnhold[1].alder" })] + [InlineData("skjemainnhold[0].alder", new[] { "skjemainnhold[0].alder" })] + [InlineData("skjemainnhold[1].alder", new[] { "skjemainnhold[1].alder" })] + [InlineData("skjemainnhold.deltar", new[] { "skjemainnhold[0].deltar", "skjemainnhold[1].deltar" })] + [InlineData("skjemainnhold.adresse", new[] { "skjemainnhold[0].adresse", "skjemainnhold[1].adresse" })] + [InlineData( + "skjemainnhold.adresse.gate", + new[] { "skjemainnhold[0].adresse.gate", "skjemainnhold[1].adresse.gate" } + )] + [InlineData( + "skjemainnhold.adresse.postnummer", + new[] { "skjemainnhold[0].adresse.postnummer", "skjemainnhold[1].adresse.postnummer" } + )] + [InlineData( + "skjemainnhold.tidligere-adresse.gate", + new[] { "skjemainnhold[1].tidligere-adresse[0].gate", "skjemainnhold[1].tidligere-adresse[1].gate" } + )] + [InlineData("skjemainnhold[0].tidligere-adresse.gate", new string[] { })] + [InlineData( + "skjemainnhold.tidligere-adresse.postnummer", + new[] { "skjemainnhold[1].tidligere-adresse[0].postnummer", "skjemainnhold[1].tidligere-adresse[1].postnummer" } + )] + [InlineData("eierAdresse", new[] { "eierAdresse" })] + [InlineData("eierAdresse.gate", new[] { "eierAdresse.gate" })] + [InlineData("doesnotexist", new string[] { })] + [InlineData("skjemainnhold.doesnotexist", new string[] { })] + // An unindexed collection as the last part of the path refers to the collection itself, + // so it resolves to the (indexed) collection key without enumerating its rows. The + // collection reference is returned even when the collection is null (cf. skjemainnhold[0]). + [InlineData("skjemainnhold", new string[] { "skjemainnhold" })] + [InlineData( + "skjemainnhold.tidligere-adresse", + new string[] { "skjemainnhold[0].tidligere-adresse", "skjemainnhold[1].tidligere-adresse" } + )] + [InlineData( + "skjemainnhold.tidligere-adresse.tags", + new string[] { "skjemainnhold[1].tidligere-adresse[0].tags", "skjemainnhold[1].tidligere-adresse[1].tags" } + )] + // An explicit index refers to a single row, which is resolved as usual. + [InlineData( + // skjemainnhold[0].tidligere-adresse is null, so the index does not resolve + "skjemainnhold.tidligere-adresse[1]", + new string[] { "skjemainnhold[1].tidligere-adresse[1]" } + )] + [InlineData("skjemainnhold[0]", new string[] { "skjemainnhold[0]" })] + // "group[]" enumerates every row of the collection at the end of the path. + [InlineData("skjemainnhold[]", new[] { "skjemainnhold[0]", "skjemainnhold[1]" })] + // "[]" in the middle of the path is equivalent to a bare collection (both expand over rows). + [InlineData("skjemainnhold[].navn", new[] { "skjemainnhold[0].navn", "skjemainnhold[1].navn" })] + [InlineData( + // skjemainnhold[0].tidligere-adresse is null, so it contributes no rows + "skjemainnhold.tidligere-adresse[]", + new[] { "skjemainnhold[1].tidligere-adresse[0]", "skjemainnhold[1].tidligere-adresse[1]" } + )] + [InlineData( + "skjemainnhold[1].tidligere-adresse[]", + new[] { "skjemainnhold[1].tidligere-adresse[0]", "skjemainnhold[1].tidligere-adresse[1]" } + )] + [InlineData( + // tidligere-adresse[0].tags is null, so only tidligere-adresse[1].tags contributes rows + "skjemainnhold[].tidligere-adresse[].tags[]", + new[] { "skjemainnhold[1].tidligere-adresse[1].tags[0]", "skjemainnhold[1].tidligere-adresse[1].tags[1]" } + )] + public void TestResolvedKeys(string field, string[] expectedKeys) + { + // Test old reflection based implementation + var modelWrapper = new DataModelWrapper(_skjema); + var resolvedKeysReflection = modelWrapper.GetResolvedKeys(field); + Assert.Equal(expectedKeys, resolvedKeysReflection); + + // Test formDataWrapper + var dataWrapper = FormDataWrapperFactory.Create(_skjema, _dataType, _dataElement); + var resolvedKeys = dataWrapper.GetResolvedKeys(field); + Assert.Equal(expectedKeys, resolvedKeys); + } + + /// + /// A null collection and an empty collection must resolve identically - the result must + /// depend only on the path, not on whether the (empty) collection was instantiated. + /// + /// A bare collection at the end of the path resolves to the collection itself (so the key + /// is returned regardless of contents), while descending through or indexing into an + /// empty/null collection finds no rows and resolves to nothing. + /// + [Theory] + // Top level collection (Skjemainnhold) + [InlineData("skjemainnhold", new[] { "skjemainnhold" })] + [InlineData("skjemainnhold[]", new string[] { })] + [InlineData("skjemainnhold.navn", new string[] { })] + [InlineData("skjemainnhold[0].navn", new string[] { })] + public void NullAndEmptyTopLevelCollection_ResolveTheSame(string field, string[] expectedKeys) + { + AssertResolvedKeys(new Skjema() { Skjemainnhold = null }, field, expectedKeys); + AssertResolvedKeys(new Skjema() { Skjemainnhold = [] }, field, expectedKeys); + } + + /// + /// Same as , but for a nested + /// collection (TidligereAdresse) inside a populated parent row. + /// + [Theory] + [InlineData("skjemainnhold[0].tidligere-adresse", new[] { "skjemainnhold[0].tidligere-adresse" })] + [InlineData("skjemainnhold[0].tidligere-adresse[]", new string[] { })] + [InlineData("skjemainnhold[0].tidligere-adresse.gate", new string[] { })] + [InlineData("skjemainnhold[0].tidligere-adresse[1].gate", new string[] { })] + public void NullAndEmptyNestedCollection_ResolveTheSame(string field, string[] expectedKeys) + { + AssertResolvedKeys(BuildNested(null), field, expectedKeys); + AssertResolvedKeys(BuildNested([]), field, expectedKeys); + + static Skjema BuildNested(List? tidligereAdresse) => + new() { Skjemainnhold = [new SkjemaInnhold() { Navn = "navn", TidligereAdresse = tidligereAdresse }] }; + } + + private void AssertResolvedKeys(Skjema skjema, string field, string[] expectedKeys) + { + // Test old reflection based implementation + var modelWrapper = new DataModelWrapper(skjema); + Assert.Equal(expectedKeys, modelWrapper.GetResolvedKeys(field)); + + // Test formDataWrapper + var dataWrapper = FormDataWrapperFactory.Create(skjema, _dataType, _dataElement); + Assert.Equal(expectedKeys, dataWrapper.GetResolvedKeys(field)); + } +}