diff --git a/source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/SubmodelRepository/SemanticIdHandlerTests.cs b/source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/SubmodelRepository/SemanticIdHandlerTests.cs index 457c508..fd96d9c 100644 --- a/source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/SubmodelRepository/SemanticIdHandlerTests.cs +++ b/source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/SubmodelRepository/SemanticIdHandlerTests.cs @@ -557,6 +557,31 @@ public void FillOutTemplate_SubmodelOfComplexData_ReturnsSubmodelWithValues() AssertContactInfo(complexData1, 2, "Test1 John Doe"); } + [Fact] + public void FillOutTemplate_SubmodelOfComplexData_ReturnsSubmodelWithValue() + { + var complexData = TestData.ComplexData; + var submodel = TestData.CreateSubmodelWithComplexData(); + submodel.SubmodelElements?.Add(complexData); + var values = TestData.CreateSubmodelWithInValidComplexDataTreeNode(); + + var submodelWithValues = (Submodel)_sut.FillOutTemplate(submodel, values); + + Equal(2, submodelWithValues.SubmodelElements!.Count); + Equal("ComplexData0", submodelWithValues.SubmodelElements[0].IdShort); + Equal("ComplexData1", submodelWithValues.SubmodelElements[1].IdShort); + var complexData0 = GetSubmodelElementCollection(submodelWithValues, 0); + var complexData1 = GetSubmodelElementCollection(submodelWithValues, 1); + Equal(3, complexData1.Value!.Count); + AssertMultiLanguageProperty(complexData0, "Test Example Manufacturer", "Test Beispiel Hersteller"); + AssertMultiLanguageProperty(complexData1, "Test1 Example Manufacturer", "Test1 Beispiel Hersteller"); + AssertModelType(complexData0, 1, "22.47"); + AssertModelType(complexData1, 1, "22.47"); + AssertContactList(complexData0, 2, "Test John Doe", "Test Example Model"); + AssertContactInfo(complexData0, 3, "Test John Doe"); + AssertContactInfo(complexData1, 2, "Test1 John Doe"); + } + [Fact] public void FillOutTemplate_ShouldNotChangeAnyThing_WhenReferenceElementHasNullValue() { diff --git a/source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/SubmodelRepository/SubmodelTemplateServiceTests.cs b/source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/SubmodelRepository/SubmodelTemplateServiceTests.cs index 3a38608..f41ebbc 100644 --- a/source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/SubmodelRepository/SubmodelTemplateServiceTests.cs +++ b/source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/SubmodelRepository/SubmodelTemplateServiceTests.cs @@ -189,22 +189,64 @@ public async Task GetSubmodelTemplateAsync_WithListIndexPath_ReturnsSubmodelWith } [Fact] - public async Task GetSubmodelTemplateAsync_ThrowsNotFoundException_WhenListIndexIsOutOfRange() + public async Task GetSubmodelTemplateAsync_Supports_UrlEncoded_ListIndex() { var submodel = TestData.CreateSubmodelWithModel3DList(); - const string Path = "Model3D[5].ModelFile1"; + const string Path = "Model3D%5B0%5D.ModelDataFile"; + _mappingProvider.GetTemplateId(SubmodelId).Returns(TemplateId); _templateProvider.GetSubmodelTemplateAsync(TemplateId, Arg.Any()) .Returns(submodel); - await Assert.ThrowsAsync(() => _sut.GetSubmodelTemplateAsync(SubmodelId, Path, CancellationToken.None)); + var result = await _sut.GetSubmodelTemplateAsync(SubmodelId, Path, CancellationToken.None); + + Assert.NotNull(result); } [Fact] - public async Task GetSubmodelTemplateAsync_ThrowsNotFoundException_WhenListElementNotFound() + public async Task GetSubmodelTemplateAsync_Throws_When_ListIndex_IsNegative() { var submodel = TestData.CreateSubmodelWithModel3DList(); - const string Path = "NonExistentList[0].ModelFile1"; + const string Path = "Model3D[-1]"; + + _mappingProvider.GetTemplateId(SubmodelId).Returns(TemplateId); + _templateProvider.GetSubmodelTemplateAsync(TemplateId, Arg.Any()) + .Returns(submodel); + + await Assert.ThrowsAsync( + () => _sut.GetSubmodelTemplateAsync(SubmodelId, Path, CancellationToken.None)); + } + + [Fact] + public async Task GetSubmodelTemplateAsync_ReturnsSubmodel_WhenTypeValueListElementIsSubmodelCollection_AndListIndexExceedsAvailableElements() + { + var expectedSubmodel = TestData.CreateSubmodelWithModel3DList(); + var submodel = TestData.CreateSubmodelWithModel3DList(); + const string Path = "Model3D[5].ModelDataFile"; + _mappingProvider.GetTemplateId(SubmodelId).Returns(TemplateId); + _templateProvider.GetSubmodelTemplateAsync(TemplateId, Arg.Any()) + .Returns(submodel); + + var result = await _sut.GetSubmodelTemplateAsync(SubmodelId, Path, CancellationToken.None); + + Assert.Equal(GetSemanticId(expectedSubmodel), GetSemanticId(result)); + + var list = result.SubmodelElements?.FirstOrDefault() as SubmodelElementList; + Assert.NotNull(list); + Assert.Single(list.Value!); + var collection = list.Value![0] as SubmodelElementCollection; + Assert.Single(collection!.Value!); + var file = collection!.Value!.FirstOrDefault() as File; + Assert.NotNull(file); + Assert.Equal("ModelDataFile", file.IdShort); + Assert.Equal("https://localhost/ModelDataFile.glb", file.Value); + } + + [Fact] + public async Task GetSubmodelTemplateAsync_ThrowsInternalDataProcessingException_WhenTypeValueListElementIsSubmodelProperty_AndListIndexExceedsAvailableElements() + { + var submodel = TestData.CreateSubmodelWithPropertyInsideList(); + const string Path = "listProperty[2]"; _mappingProvider.GetTemplateId(SubmodelId).Returns(TemplateId); _templateProvider.GetSubmodelTemplateAsync(TemplateId, Arg.Any()) .Returns(submodel); @@ -280,5 +322,4 @@ public async Task GetSubmodelTemplateAsync_WithIdShortPath_ThrowsSubmodelElement await Assert.ThrowsAsync( () => _sut.GetSubmodelTemplateAsync(SubmodelId, "SomePath", CancellationToken.None)); } - } diff --git a/source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/SubmodelRepository/TestData.cs b/source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/SubmodelRepository/TestData.cs index b3732d2..46c1190 100644 --- a/source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/SubmodelRepository/TestData.cs +++ b/source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/SubmodelRepository/TestData.cs @@ -2,6 +2,8 @@ using AasCore.Aas3_0; +using Microsoft.VisualStudio.TestPlatform.ObjectModel; + using File = AasCore.Aas3_0.File; using Key = AasCore.Aas3_0.Key; using Range = AasCore.Aas3_0.Range; @@ -632,6 +634,41 @@ public static Submodel CreateSubmodelWithoutExtraElementsNested() ); } + public static SubmodelElementList CreateElementListWithProperty() + { + return new SubmodelElementList( + idShort: "listProperty", + semanticId: new Reference( + ReferenceTypes.ExternalReference, + [ + new Key(KeyTypes.SubmodelElementList, + "http://example.com/idta/digital-nameplate/list-property") + ] + ), + typeValueListElement: AasSubmodelElements.Property, + value: [ + CreateContactName() + ] + ); + } + + public static Submodel CreateSubmodelWithPropertyInsideList() + { + return new Submodel( + id: "http://example.com/idta/digital-nameplate", + idShort: "DigitalNameplate", + semanticId: new Reference( + ReferenceTypes.ExternalReference, + [ + new Key(KeyTypes.Submodel, "http://example.com/idta/digital-nameplate/semantic-id") + ] + ), + submodelElements: [ + CreateElementListWithProperty() + ] + ); + } + public static Submodel CreateSubmodelWithoutExtraElements() { return new Submodel( @@ -720,6 +757,45 @@ public static Submodel CreateSubmodelWithModel3DList() ] ); + public static readonly SubmodelElementCollection InValidComplexData = new( + idShort: "ComplexData", + semanticId: new Reference( + ReferenceTypes.ExternalReference, + [ + new Key(KeyTypes.SubmodelElementList, "http://example.com/idta/digital-nameplate/complex-data") + ] + ), + qualifiers: + [ + new Qualifier( + type: "ExternalReference", + valueType: DataTypeDefXsd.String, + value: "OneToMany") + ], + value: [ + CreateManufacturerName(), + new Property( + idShort: "ModelType", + valueType: DataTypeDefXsd.String, + value: "", // left intentionally empty for FillOut tests + semanticId: new Reference( + ReferenceTypes.ExternalReference, + [ + new Key(KeyTypes.Property, "http://example.com/idta/digital-nameplate/model-type") + ] + ), + qualifiers: + [ + new Qualifier( + type: "ExternalReference", + valueType: DataTypeDefXsd.String, + value: "ZeroToOne") + ]), + CreateContactList(), + CreateContactInformation(), + ] +); + public static readonly SemanticTreeNode SubmodelTreeNode = CreateSubmodelTreeNode(); public static SemanticTreeNode CreateSubmodelTreeNode() @@ -778,6 +854,41 @@ public static SemanticTreeNode CreateSubmodelWithComplexDataTreeNode() return semanticTreeNode; } + public static SemanticTreeNode CreateSubmodelWithInValidComplexDataTreeNode() + { + var semanticTreeNode = new SemanticBranchNode("http://example.com/idta/digital-nameplate/semantic-id", Cardinality.Unknown); + + var complexDataBranchNode1 = new SemanticBranchNode("http://example.com/idta/digital-nameplate/complex-data", Cardinality.ZeroToMany); + + var complexDataBranchNode2 = new SemanticBranchNode("http://example.com/idta/digital-nameplate/complex-data", Cardinality.ZeroToMany); + + semanticTreeNode.AddChild(complexDataBranchNode1); + + semanticTreeNode.AddChild(complexDataBranchNode2); + + complexDataBranchNode1.AddChild(CreateManufacturerNameTreeNode()); + + complexDataBranchNode1.AddChild(CreateModelTypeTreeNode()); + + complexDataBranchNode1.AddChild(CreateContactListTreeNode()); + + complexDataBranchNode1.AddChild(CreateContactInformationTreeNode()); + + complexDataBranchNode2.AddChild(CreateManufacturerNameTreeNode("1")); + + complexDataBranchNode2.AddChild(CreateModelTypeTreeNode()); + + complexDataBranchNode2.AddChild(CreateContactListTreeNode("1")); + + complexDataBranchNode2.AddChild(CreateContactListTreeNode("2")); + + complexDataBranchNode2.AddChild(new SemanticLeafNode("http://example.com/idta/digital-nameplate/contact-list", $"Test InValid Contact List", DataType.String, Cardinality.One)); + + complexDataBranchNode2.AddChild(CreateContactInformationTreeNode("1")); + + return semanticTreeNode; + } + public static SemanticTreeNode CreateManufacturerNameTreeNode(string testObject = "") { var manufacturerName = new SemanticBranchNode("http://example.com/idta/digital-nameplate/manufacturer-name", Cardinality.One); diff --git a/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/SubmodelRepository/SemanticIdHandler.cs b/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/SubmodelRepository/SemanticIdHandler.cs index 646b923..cca443c 100644 --- a/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/SubmodelRepository/SemanticIdHandler.cs +++ b/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/SubmodelRepository/SemanticIdHandler.cs @@ -526,6 +526,15 @@ private void FillOutSubmodelElementValue(List elements, Semant continue; } + if (!AreAllNodesOfSameType(semanticTreeNodes, out _)) + { + logger.LogWarning("Mixed node types found for element '{IdShort}' with SemanticId '{SemanticId}'. Expected all nodes to be either SemanticBranchNode or SemanticLeafNode. Removing element.", + element.IdShort, + ExtractSemanticId(element)); + _ = elements.Remove(element); + continue; + } + if (semanticTreeNodes.Count > 1 && element is not Property && element is not ReferenceElement) { _ = elements.Remove(element); @@ -548,6 +557,25 @@ private void FillOutSubmodelElementValue(List elements, Semant } } + private static bool AreAllNodesOfSameType(List nodes, out Type? nodeType) + { + if (nodes.Count == 0) + { + nodeType = null; + return true; + } + + var firstNodeType = nodes[0].GetType(); + nodeType = firstNodeType; + + if (firstNodeType != typeof(SemanticBranchNode) && firstNodeType != typeof(SemanticLeafNode)) + { + return false; + } + + return nodes.All(node => node.GetType() == firstNodeType); + } + private void HandleSingleSemanticTreeNode(ISubmodelElement element, SemanticTreeNode node) => FillOutTemplate(element, node); private void FillOutMultiLanguageProperty(MultiLanguageProperty mlp, SemanticTreeNode values) @@ -800,7 +828,7 @@ private static IEnumerable FindNodeBySemanticId(SemanticTreeNo /// e.g. "element[3]" -> matches Group1= "element", Group2 = "3" /// Pattern: ^(.+?)\[(\d+)\]$ /// - [GeneratedRegex(@"^(.+?)\[(\d+)\]$")] + [GeneratedRegex(@"^(.+?)(?:\[(\d+)\]|%5B(\d+)%5D)$")] private static partial Regex SubmodelElementListIndex(); /// @@ -821,19 +849,27 @@ private static IEnumerable FindNodeBySemanticId(SemanticTreeNo return submodelElements?.FirstOrDefault(e => e.IdShort == idShort); } - private static bool TryParseIdShortWithBracketIndex(string segment, out string idShortWithoutIndex, out int index) + private static bool TryParseIdShortWithBracketIndex(string idShort, out string idShortWithoutIndex, out int index) { - var match = SubmodelElementListIndex().Match(segment); - if (match.Success) + var match = SubmodelElementListIndex().Match(idShort); + if (!match.Success) { - idShortWithoutIndex = match.Groups[1].Value; - index = int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture); - return true; + idShortWithoutIndex = string.Empty; + index = -1; + return false; + } + + idShortWithoutIndex = match.Groups[1].Value; + var indexGroup = match.Groups[2].Success ? match.Groups[2] : match.Groups[3]; + if (!indexGroup.Success) + { + idShortWithoutIndex = string.Empty; + index = -1; + return false; } - idShortWithoutIndex = string.Empty; - index = -1; - return false; + index = int.Parse(indexGroup.Value, CultureInfo.InvariantCulture); + return true; } private ISubmodelElement GetElementFromListByIndex(IEnumerable? elements, string idShortWithoutIndex, int index) diff --git a/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/SubmodelRepository/SubmodelTemplateService.cs b/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/SubmodelRepository/SubmodelTemplateService.cs index ab72948..385d5ca 100644 --- a/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/SubmodelRepository/SubmodelTemplateService.cs +++ b/source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/SubmodelRepository/SubmodelTemplateService.cs @@ -5,6 +5,7 @@ using AAS.TwinEngine.DataEngine.ApplicationLogic.Exceptions.Base; using AAS.TwinEngine.DataEngine.ApplicationLogic.Exceptions.Infrastructure; using AAS.TwinEngine.DataEngine.ApplicationLogic.Services.AasEnvironment.Providers; +using AAS.TwinEngine.DataEngine.DomainModel.SubmodelRepository; using AasCore.Aas3_0; @@ -131,16 +132,24 @@ private static ISubmodelElement FindMatchingElement(IEnumerable elements, string idShort) @@ -157,7 +166,20 @@ private static SubmodelElementList GetListElementByIdShort(List= list.Value?.Count) + if (index < 0) + { + throw new InternalDataProcessingException(); + } + + if (list.TypeValueListElement is AasSubmodelElements.SubmodelElementCollection or AasSubmodelElements.SubmodelElementList && list.Value?.Count > 0) + { + if (GetCardinality(list.Value.FirstOrDefault()!) is Cardinality.OneToMany or Cardinality.ZeroToMany) + { + return list.Value.FirstOrDefault()!; + } + } + + if (index >= list.Value?.Count) { throw new InternalDataProcessingException(); } @@ -183,7 +205,7 @@ private static SubmodelElementList GetListElementByIdShort(List matches Group1= "element", Group2 = "3" /// Pattern: ^(.+?)\[(\d+)\]$ /// - [GeneratedRegex(@"^(.+?)\[(\d+)\]$")] + [GeneratedRegex(@"^(.+?)(?:\[(\d+)\]|%5B(\d+)%5D)$")] private static partial Regex SubmodelElementListIndex(); /// @@ -201,4 +223,17 @@ private static void ValidateSubmodelId(string submodelId) throw new InternalDataProcessingException(); } } + + private static Cardinality GetCardinality(ISubmodelElement element) + { + var qualifierValue = element.Qualifiers?.FirstOrDefault()?.Value; + if (qualifierValue is null) + { + return Cardinality.Unknown; + } + + return Enum.TryParse(qualifierValue, ignoreCase: true, out var result) + ? result + : Cardinality.Unknown; + } }