/* ======================================================================== * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * * Permission is hereby granted, free of charge, to any person * obtaining a copy of this software and associated documentation * files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following * conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. * * The complete license agreement can be found here: * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using System.Xml; using Microsoft.Extensions.Logging; using NUnit.Framework; using Opc.Ua.Export; using Opc.Ua.SourceGeneration; using Opc.Ua.Tests; namespace Opc.Ua.Schema.Model.Tests { /// /// Unit tests for the importer. The tests /// use the small, self-contained CrossModelTypes.NodeSet2.xml resource /// so that the deterministic surface (node set detection, namespace loading and /// the constructor side effects) can be verified without pulling in the full UA /// base model. /// [TestFixture] [Category("ModelDesign")] [SetCulture("en-us")] [SetUICulture("en-us")] public class NodeSetToModelDesignTests { private const string OpcUaNamespaceUri = "http://opcfoundation.org/UA/"; private const string CrossModelNamespaceUri = "http://test.org/UA/CrossModel/Types"; private const string SameNamedArgumentsNamespaceUri = "http://test.org/UA/SameNamedMethodArguments/"; private const string NodeSetResource = "CrossModelTypes.NodeSet2.xml"; private const string SameNamedArgumentsResource = "SameNamedMethodArguments.NodeSet2.xml"; private const string DesignResource = "TestDataDesign.xml"; private const string ViewImportNodeSet = """ http://test.org/UA/ViewImport/ i=45 i=40 i=35 HierarchicalReferences i=33 Organizes i=33 BaseObjectType i=58 Views ns=1;s=Views_Operations ns=1;s=Views_Engineering i=58 Operations i=87 Engineering i=87 """; private const string ExtendedIdNodeSetTemplate = """ http://test.org/UA/ExtendedId/ i=45 i=40 i=35 HierarchicalReferences i=33 Organizes i=33 BaseObjectType i=58 ExtendedId i=85 i=58 """; private VirtualFileSystem m_fileSystem; [SetUp] public void SetUp() { m_fileSystem = new VirtualFileSystem(); } [TearDown] public void TearDown() { m_fileSystem?.Dispose(); m_fileSystem = null!; } [Test] public void IsNodeSetReturnsTrueForNodeSet() { bool result = NodeSetToModelDesign.IsNodeSet(m_fileSystem, ResourcePath(NodeSetResource)); Assert.That(result, Is.True); } [Test] public void IsNodeSetReturnsFalseForDesignFile() { bool result = NodeSetToModelDesign.IsNodeSet(m_fileSystem, ResourcePath(DesignResource)); Assert.That(result, Is.False); } [Test] public void IsNodeSetReturnsFalseForNonXmlContent() { const string path = "memory://garbage.txt"; m_fileSystem.Add(path, Encoding.UTF8.GetBytes("just some text without any markup")); bool result = NodeSetToModelDesign.IsNodeSet(m_fileSystem, path); Assert.That(result, Is.False); } [Test] public void IsNodeSetNullFileSystemThrowsArgumentNullException() { ArgumentNullException ex = Assert.Throws( () => NodeSetToModelDesign.IsNodeSet(null!, ResourcePath(NodeSetResource))); Assert.That(ex.ParamName, Is.EqualTo("fileSystem")); } [Test] public void IsNodeSetNullFilePathThrowsArgumentNullException() { ArgumentNullException ex = Assert.Throws( () => NodeSetToModelDesign.IsNodeSet(m_fileSystem, null!)); Assert.That(ex.ParamName, Is.EqualTo("filePath")); } [Test] public void LoadNamespacesReturnsModelAndDependencyNamespaces() { List namespaces = NodeSetToModelDesign.LoadNamespaces( m_fileSystem, ResourcePath(NodeSetResource)); Assert.That(namespaces, Has.Count.EqualTo(2)); Namespace opcUa = namespaces.Single(x => x.Value == OpcUaNamespaceUri); Namespace crossModel = namespaces.Single(x => x.Value == CrossModelNamespaceUri); Assert.That(opcUa.Name, Is.EqualTo("OpcUa")); Assert.That(opcUa.Prefix, Is.EqualTo("Opc.Ua")); Assert.That(crossModel.Name, Is.EqualTo("testorgUACrossModelTypes")); Assert.That(crossModel.Prefix, Is.EqualTo("test.org.UA.CrossModel.Types")); } [Test] public void LoadNamespacesNullFileSystemThrowsArgumentNullException() { ArgumentNullException ex = Assert.Throws( () => NodeSetToModelDesign.LoadNamespaces(null!, ResourcePath(NodeSetResource))); Assert.That(ex.ParamName, Is.EqualTo("fileSystem")); } [Test] public void LoadNamespacesNullFilePathThrowsArgumentNullException() { ArgumentNullException ex = Assert.Throws( () => NodeSetToModelDesign.LoadNamespaces(m_fileSystem, null!)); Assert.That(ex.ParamName, Is.EqualTo("filePath")); } [Test] public void ConstructorRegistersNamespacesInSettings() { var settings = new NodeSetReaderSettings(); NodeSetToModelDesign importer = new( m_fileSystem, ResourcePath(NodeSetResource), settings, CreateTelemetry()); Assert.That(importer, Is.Not.Null); Assert.That(settings.NamespaceUris.Count, Is.EqualTo(2)); Assert.That(settings.NamespaceUris.GetString(0), Is.EqualTo(OpcUaNamespaceUri)); Assert.That(settings.NamespaceUris.GetString(1), Is.EqualTo(CrossModelNamespaceUri)); Assert.That(settings.NamespaceTables, Has.Count.EqualTo(1)); Assert.That(settings.NamespaceTables.ContainsKey(CrossModelNamespaceUri), Is.True); Assert.That(settings.NamespaceTables[CrossModelNamespaceUri], Is.EqualTo([CrossModelNamespaceUri])); } [Test] public void ConstructorNullFilePathThrowsArgumentNullException() { ArgumentNullException ex = Assert.Throws( () => new NodeSetToModelDesign( m_fileSystem, null!, new NodeSetReaderSettings(), CreateTelemetry())); Assert.That(ex.ParamName, Is.EqualTo("filePath")); } [Test] public void ConstructorNullSettingsThrowsArgumentNullException() { ArgumentNullException ex = Assert.Throws( () => new NodeSetToModelDesign( m_fileSystem, ResourcePath(NodeSetResource), null!, CreateTelemetry())); Assert.That(ex.ParamName, Is.EqualTo("settings")); } [Test] public void ConstructorNullFileSystemThrowsArgumentNullException() { ArgumentNullException ex = Assert.Throws( () => new NodeSetToModelDesign( null!, ResourcePath(NodeSetResource), new NodeSetReaderSettings(), CreateTelemetry())); Assert.That(ex.ParamName, Is.EqualTo("fileSystem")); } [Test] public void ImportWithoutBaseModelThrowsInvalidDataException() { var settings = new NodeSetReaderSettings(); NodeSetToModelDesign importer = new( m_fileSystem, ResourcePath(NodeSetResource), settings, CreateTelemetry()); InvalidDataException ex = Assert.Throws( () => importer.Import("Test", "CrossModel")); Assert.That(ex.Message, Does.Contain("WidgetType")); } [Test] public void ImportPreservesSameNamedInputAndOutputArgumentNames() { ITelemetryContext telemetry = CreateTelemetry(); string path = ResourcePath(SameNamedArgumentsResource); var nodesets = new NodesetFileCollection( [(path, new NodesetFileOptions())], [], m_fileSystem, telemetry); List designFiles = nodesets.GetDesignFileListForModel( SameNamedArgumentsNamespaceUri, out _)!; Assert.That(designFiles, Is.Not.Null); IFileSystem fileSystem = typeof(Generators).Assembly .AsFileSystem("Opc.Ua.SourceGeneration.Design") .WithFallback(m_fileSystem); IModelDesign model = fileSystem.OpenModelDesign( new DesignFileCollection { Targets = designFiles }, [], telemetry, useAllowSubtypes: false); MethodDesign method = model.GetNodeDesigns() .OfType() .Single(x => x.SymbolicName?.Name == "RoundTripMethodType"); string[] expectedInputNames = [ "Foo", "foo", "Class", "VersionId", "Await", "Ct", "cT", "CancellationToken", "Context", "ObjectId", "Method", "InputArguments", "Results", "_result", "_foo", "_", "Nameof", "__arglist", "__makeref", "__reftype", "__refvalue" ]; string[] expectedOutputNames = [ "VersionId", "class", "Changed", "OutputArguments", "ServiceResult", "Quote\"Name", "Back\\Slash", "Line\nBreak", "Δelta雪", "Foo", "RoundTripMethodStateResult", "Next\u0085Line", "Line\u2028Separator", "Paragraph\u2029Separator" ]; Assert.Multiple(() => { Assert.That(method.InputArguments, Has.Length.EqualTo(expectedInputNames.Length)); Assert.That( method.InputArguments.Select(argument => argument.Name), Is.EqualTo(expectedInputNames)); Assert.That(method.OutputArguments, Has.Length.EqualTo(expectedOutputNames.Length)); Assert.That( method.OutputArguments.Select(argument => argument.Name), Is.EqualTo(expectedOutputNames)); }); } /// /// Verifies importing views as top-level nodes keeps their organizing folder references. /// [Test] public void ImportPreservesFolderOrganizesReferencesToViews() { const string path = "memory://ViewImport.NodeSet2.xml"; m_fileSystem.Add(path, Encoding.UTF8.GetBytes(ViewImportNodeSet)); var settings = new NodeSetReaderSettings(); NodeSetToModelDesign importer = new( m_fileSystem, path, settings, CreateTelemetry()); ModelDesign model = importer.Import("ViewImport", "ViewImport"); ObjectDesign viewsFolder = model.Items .OfType() .Single(x => x.SymbolicName?.Name == "Views"); string[] expectedViews = ["Operations", "Engineering"]; Assert.Multiple(() => { Assert.That(model.Items.OfType().Select(x => x.SymbolicName?.Name), Is.EquivalentTo( expectedViews)); Assert.That(viewsFolder.Children?.Items, Is.Null.Or.Empty); Assert.That(viewsFolder.References, Has.Length.EqualTo(2)); Assert.That(viewsFolder.References.Select(x => x.ReferenceType.Name), Is.All.EqualTo("Organizes")); Assert.That(viewsFolder.References.Select(x => x.IsInverse), Is.All.False); Assert.That(viewsFolder.References.Select(x => x.TargetId.Name), Is.EquivalentTo( expectedViews)); }); } /// /// Verifies that Guid identifiers (OPC 10000-3 5.2.2) are imported into the /// model design instead of being dropped. /// [TestCase("09087e75-8e5e-499b-954f-f2a9603db28a")] [TestCase("00000000-0000-0000-0000-000000000000")] public void ImportGuidNodeIdKeepsIdentifier(string guid) { ObjectDesign node = ImportNodeWithIdentifier("ns=1;g=" + guid); Assert.Multiple(() => { Assert.That(node.GuidIdSpecified, Is.True); Assert.That(node.GuidId, Is.EqualTo(Guid.Parse(guid))); Assert.That(node.NumericIdSpecified, Is.False); Assert.That(node.StringId, Is.Null); Assert.That(node.OpaqueId, Is.Null); }); } /// /// Verifies that opaque identifiers (OPC 10000-3 5.2.2) are imported into the /// model design instead of being dropped. /// [Test] public void ImportOpaqueNodeIdKeepsIdentifier() { const string base64 = "M/RbKBsRVkePCePcx24oRA=="; ObjectDesign node = ImportNodeWithIdentifier("ns=1;b=" + base64); Assert.Multiple(() => { Assert.That(node.OpaqueId, Is.EqualTo(Convert.FromBase64String(base64))); Assert.That(node.NumericIdSpecified, Is.False); Assert.That(node.GuidIdSpecified, Is.False); Assert.That(node.StringId, Is.Null); }); } private ObjectDesign ImportNodeWithIdentifier(string nodeId) { const string path = "memory://ExtendedId.NodeSet2.xml"; m_fileSystem.Add( path, Encoding.UTF8.GetBytes( ExtendedIdNodeSetTemplate.Replace( "__NODEID__", nodeId, StringComparison.Ordinal))); var settings = new NodeSetReaderSettings(); NodeSetToModelDesign importer = new( m_fileSystem, path, settings, CreateTelemetry()); ModelDesign model = importer.Import("ExtendedId", "ExtendedId"); return model.Items .OfType() .Single(x => x.SymbolicName?.Name == "ExtendedId"); } [Test] public void TypeSymbolicNameUsesNodeIdNamespace() { var symbolicId = new XmlQualifiedName( "GroundControlPointDataType", "http://opcfoundation.org/UA/GPOS/"); var symbolicName = new XmlQualifiedName( "GroundControlPointDataType", "http://opcfoundation.org/UA/RSL/"); XmlQualifiedName normalized = NodeSetToModelDesign.NormalizeSymbolicNameNamespace( new UADataType(), symbolicId, symbolicName)!; Assert.That(normalized!.Name, Is.EqualTo(symbolicName.Name)); Assert.That(normalized.Namespace, Is.EqualTo(symbolicId.Namespace)); } [Test] public void InstanceSymbolicNameKeepsBrowseNameNamespace() { var symbolicId = new XmlQualifiedName( "Position", "http://opcfoundation.org/UA/GPOS/"); var symbolicName = new XmlQualifiedName( "Position", "http://opcfoundation.org/UA/RSL/"); XmlQualifiedName normalized = NodeSetToModelDesign.NormalizeSymbolicNameNamespace( new UAVariable(), symbolicId, symbolicName)!; Assert.That(normalized, Is.SameAs(symbolicName)); Assert.That(normalized, Is.SameAs(symbolicName)); } /// /// Regression: a NodeSet without a <Models> entry crashed with an /// unexplained NullReferenceException inside the constructor. It is /// reported as the malformed input it is. /// [Test] public void ConstructorNodeSetWithoutModelsThrowsInvalidDataException() { const string path = "memory://NoModels.NodeSet2.xml"; m_fileSystem.Add(path, Encoding.UTF8.GetBytes(NoModelsNodeSet)); InvalidDataException ex = Assert.Throws( () => new NodeSetToModelDesign( m_fileSystem, path, new NodeSetReaderSettings(), CreateTelemetry())); Assert.That(ex.Message, Does.Contain("Models")); } /// /// Regression: an instance node carrying neither a ParentNodeId nor a /// <References> element - both optional in the schema - crashed the /// import with a NullReferenceException. Such a node has no type /// definition either, so it is reported as the malformed input it is. /// [Test] public void ImportInstanceWithoutParentOrReferencesReportsTheNode() { const string path = "memory://NoReferences.NodeSet2.xml"; m_fileSystem.Add(path, Encoding.UTF8.GetBytes(NoReferencesNodeSet)); NodeSetToModelDesign importer = new( m_fileSystem, path, new NodeSetReaderSettings(), CreateTelemetry()); InvalidDataException ex = Assert.Throws( () => importer.Import("NoRefs", "NoRefs")); Assert.That(ex.Message, Does.Contain("Orphan")); } /// /// An instance with references but no ParentNodeId and no inverse /// hierarchical reference imports as a top-level node. /// [Test] public void ImportInstanceWithoutParentImportsAsTopLevelNode() { const string path = "memory://NoParent.NodeSet2.xml"; m_fileSystem.Add(path, Encoding.UTF8.GetBytes(NoParentNodeSet)); NodeSetToModelDesign importer = new( m_fileSystem, path, new NodeSetReaderSettings(), CreateTelemetry()); ModelDesign? model = null; Assert.DoesNotThrow(() => model = importer.Import("NoParent", "NoParent")); Assert.That( model!.Items.Select(x => x.SymbolicName?.Name), Does.Contain("Orphan")); } /// /// Regression: AccessRestrictions="0" means "no restrictions", but /// the mapping fell through its switch and returned EncryptionRequired, /// so an unrestricted node was imported as an encrypted one. /// [Test] public void ImportZeroAccessRestrictionsLeavesThemUnspecified() { const string path = "memory://ZeroRestrictions.NodeSet2.xml"; m_fileSystem.Add(path, Encoding.UTF8.GetBytes(ZeroAccessRestrictionsNodeSet)); NodeSetToModelDesign importer = new( m_fileSystem, path, new NodeSetReaderSettings(), CreateTelemetry()); ModelDesign model = importer.Import("Restrictions", "Restrictions"); NodeDesign node = model.Items .Single(x => x.SymbolicName?.Name == "Unrestricted"); Assert.That( node.AccessRestrictionsSpecified, Is.False, "an empty mask carries no restriction the design schema can express"); Assert.That( node.AccessRestrictions, Is.Not.EqualTo(AccessRestrictions.EncryptionRequired)); } /// /// A real restriction mask is still imported. /// [Test] public void ImportEncryptionAccessRestrictionsIsPreserved() { const string path = "memory://EncryptionRestrictions.NodeSet2.xml"; m_fileSystem.Add( path, Encoding.UTF8.GetBytes( ZeroAccessRestrictionsNodeSet.Replace( "AccessRestrictions=\"0\"", "AccessRestrictions=\"2\"", StringComparison.Ordinal))); NodeSetToModelDesign importer = new( m_fileSystem, path, new NodeSetReaderSettings(), CreateTelemetry()); ModelDesign model = importer.Import("Restrictions", "Restrictions"); NodeDesign node = model.Items .Single(x => x.SymbolicName?.Name == "Unrestricted"); Assert.That(node.AccessRestrictionsSpecified, Is.True); Assert.That( node.AccessRestrictions, Is.EqualTo(AccessRestrictions.EncryptionRequired)); } /// /// Regression: making the empty mask unspecified must not also drop a /// real restriction that happens to carry a bit the design schema cannot /// name. A mask of EncryptionRequired plus a reserved bit matches no case /// in the switch, and returning "unspecified" there would publish an /// encryption-required node with no protection at all. /// [Test] public void ImportUnknownAccessRestrictionBitStaysRestricted() { const string path = "memory://UnknownRestrictions.NodeSet2.xml"; // 0x02 EncryptionRequired | 0x10 (reserved / vendor bit). m_fileSystem.Add( path, Encoding.UTF8.GetBytes( ZeroAccessRestrictionsNodeSet.Replace( "AccessRestrictions=\"0\"", "AccessRestrictions=\"18\"", StringComparison.Ordinal))); NodeSetToModelDesign importer = new( m_fileSystem, path, new NodeSetReaderSettings(), CreateTelemetry()); ModelDesign model = importer.Import("Restrictions", "Restrictions"); NodeDesign node = model.Items .Single(x => x.SymbolicName?.Name == "Unrestricted"); Assert.That( node.AccessRestrictionsSpecified, Is.True, "an unrecognised combination must stay fail-closed"); Assert.That( node.AccessRestrictions, Is.EqualTo(AccessRestrictions.SessionWithEncryptionRequired)); } private const string NoModelsNodeSet = """ http://test.org/UA/NoModels/ BaseObjectType """; /// /// Regression: the non-hierarchical fallback was repointed from the child /// (where it never matched) to the parent, which made it reachable - and /// it ran before the child's own inverse hierarchical reference was /// considered. A parent that also declares a non-hierarchical forward /// reference to its child therefore had the child classified as /// non-hierarchical and dropped out of its Children entirely. /// [Test] public void ImportChildWithNonHierarchicalParentReferenceIsStillLinked() { const string path = "memory://MixedRefs.NodeSet2.xml"; m_fileSystem.Add(path, Encoding.UTF8.GetBytes(MixedReferencesNodeSet)); NodeSetToModelDesign importer = new( m_fileSystem, path, new NodeSetReaderSettings(), CreateTelemetry()); ModelDesign model = importer.Import("MixedRefs", "MixedRefs"); NodeDesign parent = model.Items .Single(x => x.SymbolicName?.Name == "ParentType"); Assert.That(parent.Children?.Items, Is.Not.Null, "the child must be linked"); Assert.That( parent.Children.Items.Select(x => x.SymbolicName?.Name), Does.Contain("Child"), "the child's inverse HasComponent outranks the parent's " + "non-hierarchical forward reference"); } [TestCase("HasComponent", "i=47", true)] [TestCase("HasComponent", "i=47", false)] [TestCase("HasOrderedComponent", "i=49", true)] [TestCase("HasOrderedComponent", "i=49", false)] [TestCase("HasAddIn", "i=17603", true)] [TestCase("HasAddIn", "i=17603", false)] [TestCase("Organizes", "i=35", true)] [TestCase("Organizes", "i=35", false)] [TestCase("CustomComponent", "ns=1;s=ExtraReference", true)] [TestCase("CustomComponent", "ns=1;s=ExtraReference", false)] public void ImportHierarchicalReferencesPreservesNonChildLinks( string referenceType, string referenceTypeId, bool forward) { const string path = "memory://HierarchicalReferences.NodeSet2.xml"; string extraReference = referenceType == "HasComponent" ? string.Empty : $$""" {{referenceType}} {{(referenceType == "Organizes" ? "i=33" : "i=47")}} """; string nodeSet = MixedReferencesNodeSet.Replace( "", $$""" {{extraReference}} Source i=58 ns=1;s=ParentType ns=1;s=Child """, StringComparison.Ordinal); m_fileSystem.Add(path, Encoding.UTF8.GetBytes(nodeSet)); var settings = new NodeSetReaderSettings(); NodeSetToModelDesign importer = new(m_fileSystem, path, settings, CreateTelemetry()); importer.Import("MixedRefs", "MixedRefs"); NodeDesign parent = settings.NodesById[new NodeId("ParentType", 1)]; NodeDesign source = settings.NodesById[new NodeId("Source", 1)]; NodeDesign target = settings.NodesById[new NodeId("Child", 1)]; Assert.That(source.References, Is.Not.Null, "The non-child hierarchical reference was dropped."); Assert.Multiple(() => { Assert.That(source.References, Has.Length.EqualTo(1)); Assert.That(source.Parent, Is.SameAs(parent)); Assert.That(target.Parent, Is.SameAs(parent)); Assert.That(parent.Children.Items, Has.Length.EqualTo(2)); Assert.That(source.Children?.Items, Is.Null.Or.Empty); }); Reference reference = source.References.Single(); Assert.Multiple(() => { Assert.That(reference.ReferenceType.Name, Is.EqualTo(referenceType)); Assert.That(reference.IsInverse, Is.EqualTo(!forward)); Assert.That(reference.TargetId, Is.EqualTo(target.SymbolicId)); Assert.That(reference.TargetNode, Is.SameAs(target)); Assert.That(reference.SourceNode, Is.SameAs(source)); }); } [Test] public void ImportHierarchicalReferencesOnlySuppressesExactChildRelationship() { const string path = "memory://ParallelReferences.NodeSet2.xml"; string nodeSet = MixedReferencesNodeSet.Replace( """ns=1;s=Child""", """ ns=1;s=Child ns=1;s=Child """, StringComparison.Ordinal).Replace( """ns=1;s=ParentType""", """ ns=1;s=ParentType ns=1;s=ParentType """, StringComparison.Ordinal).Replace( "", """ HasOrderedComponent i=47 """, StringComparison.Ordinal); m_fileSystem.Add(path, Encoding.UTF8.GetBytes(nodeSet)); var settings = new NodeSetReaderSettings(); NodeSetToModelDesign importer = new(m_fileSystem, path, settings, CreateTelemetry()); importer.Import("MixedRefs", "MixedRefs"); NodeDesign parent = settings.NodesById[new NodeId("ParentType", 1)]; var child = (InstanceDesign)settings.NodesById[new NodeId("Child", 1)]; Assert.Multiple(() => { Assert.That(child.Parent, Is.SameAs(parent)); Assert.That(child.ReferenceType.Name, Is.EqualTo("HasOrderedComponent")); Assert.That(parent.Children.Items, Has.Length.EqualTo(1)); Assert.That(parent.Children.Items[0], Is.SameAs(child)); Assert.That(parent.References, Is.Not.Null); Assert.That(child.References, Is.Not.Null); }); Reference forward = parent.References.Single(); Reference inverse = child.References.Single(); Assert.Multiple(() => { Assert.That(forward.ReferenceType.Name, Is.EqualTo("HasComponent")); Assert.That(forward.IsInverse, Is.False); Assert.That(forward.TargetId, Is.EqualTo(child.SymbolicId)); Assert.That(inverse.ReferenceType, Is.EqualTo(forward.ReferenceType)); Assert.That(inverse.IsInverse, Is.True); Assert.That(inverse.TargetId, Is.EqualTo(parent.SymbolicId)); }); } [TestCase(false)] [TestCase(true)] public void ImportHierarchicalReferencesKeepsImplicitChildren(bool inferParent) { const string path = "memory://ImplicitReferences.NodeSet2.xml"; string nodeSet = MixedReferencesNodeSet.Replace( """ns=1;s=Child""", """ns=1;s=Child""", StringComparison.Ordinal); if (inferParent) { nodeSet = nodeSet.Replace( "ParentNodeId=\"ns=1;s=ParentType\"", string.Empty, StringComparison.Ordinal); } m_fileSystem.Add(path, Encoding.UTF8.GetBytes(nodeSet)); var settings = new NodeSetReaderSettings(); NodeSetToModelDesign importer = new(m_fileSystem, path, settings, CreateTelemetry()); importer.Import("MixedRefs", "MixedRefs"); NodeDesign parent = settings.NodesById[new NodeId("ParentType", 1)]; var child = (InstanceDesign)settings.NodesById[new NodeId("Child", 1)]; Assert.Multiple(() => { Assert.That(child.Parent, Is.SameAs(parent)); Assert.That(child.ReferenceType, Is.EqualTo(new XmlQualifiedName("HasComponent", OpcUaNamespaceUri))); Assert.That(parent.Children.Items, Has.Length.EqualTo(1)); Assert.That(parent.Children.Items[0], Is.SameAs(child)); Assert.That(parent.References, Is.Null.Or.Empty); Assert.That(child.References, Is.Null.Or.Empty); }); } private const string MixedReferencesNodeSet = """ http://test.org/UA/MixedRefs/ i=45 i=40 i=47 i=9006 HierarchicalReferences i=33 HasComponent i=33 NonHierarchicalReferences i=32 HasCondition i=32 BaseObjectType i=58 ParentType i=58 ns=1;s=Child Child i=58 ns=1;s=ParentType """; private const string NoReferencesNodeSet = """ http://test.org/UA/NoRefs/ i=45 i=40 HierarchicalReferences i=33 BaseObjectType i=58 Orphan """; private const string NoParentNodeSet = """ http://test.org/UA/NoParent/ i=45 i=40 HierarchicalReferences i=33 BaseObjectType i=58 Orphan i=58 """; private const string ZeroAccessRestrictionsNodeSet = """ http://test.org/UA/Restrictions/ i=45 i=40 HierarchicalReferences i=33 BaseObjectType i=58 Unrestricted i=58 """; private static ITelemetryContext CreateTelemetry() { return NUnitTelemetryContext.Create(logLevel: LogLevel.Error); } private static string ResourcePath(string fileName) { return Path.Combine(TestContext.CurrentContext.TestDirectory, "Resources", fileName); } } }