/* ======================================================================== * 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.Diagnostics; using System.Globalization; using System.IO; using System.Linq; using System.Text; using Microsoft.Extensions.Logging; using NUnit.Framework; using Opc.Ua.SourceGeneration; using Opc.Ua.Tests; namespace Opc.Ua.Schema.Model.Tests { /// /// Regression tests for the NodeSet2 to ModelDesign conversion of /// . Each test imports a small, /// self-contained NodeSet that declares the handful of base nodes it /// needs, so the conversion can be checked without the full UA model. /// [TestFixture] [Category("ModelDesign")] [SetCulture("en-us")] [SetUICulture("en-us")] public class NodeSetToModelDesignImportTests { private const string TestNamespaceUri = "http://test.org/UA/Import/"; private VirtualFileSystem m_fileSystem; [SetUp] public void SetUp() { m_fileSystem = new VirtualFileSystem(); } [TearDown] public void TearDown() { m_fileSystem?.Dispose(); m_fileSystem = null!; } /// /// A NodeSet that declares a model but no nodes deserializes with a /// null Items array; AssignSymbolicIds threw a NullReferenceException. /// [Test] public void ImportNodeSetWithoutNodesYieldsEmptyModel() { const string path = "memory://Empty.NodeSet2.xml"; m_fileSystem.Add( path, Encoding.UTF8.GetBytes( """ http://test.org/UA/Import/ """)); ModelDesign design = new NodeSetToModelDesign( m_fileSystem, path, new NodeSetReaderSettings(), NUnitTelemetryContext.Create(logLevel: LogLevel.Error)) .Import("Import", "Import"); Assert.That(design.TargetNamespace, Is.EqualTo(TestNamespaceUri)); Assert.That(design.Items ?? [], Is.Empty); Assert.That( new NodeSetToModelDesign( m_fileSystem, path, new NodeSetReaderSettings(), NUnitTelemetryContext.Create(logLevel: LogLevel.Error)) .GetImportedSymbols(TestNamespaceUri), Is.Empty); } /// /// N-13: a minified NodeSet (declaration and root on one line) was not /// recognized because every line starting with "<?" was skipped. /// [Test] public void IsNodeSetDetectsRootOnXmlDeclarationLine() { const string path = "memory://Minified.NodeSet2.xml"; m_fileSystem.Add( path, Encoding.UTF8.GetBytes( "")); Assert.That(NodeSetToModelDesign.IsNodeSet(m_fileSystem, path), Is.True); } /// /// N-13: a line inside a multi-line comment that starts with '<' was /// taken as the root element. /// [Test] public void IsNodeSetSkipsMultiLineComment() { const string path = "memory://Commented.NodeSet2.xml"; m_fileSystem.Add( path, Encoding.UTF8.GetBytes( """ """)); Assert.That(NodeSetToModelDesign.IsNodeSet(m_fileSystem, path), Is.True); } /// /// N-13: a document whose root is not UANodeSet is still rejected. /// [Test] public void IsNodeSetRejectsOtherRootOnDeclarationLine() { const string path = "memory://Design.xml"; m_fileSystem.Add( path, Encoding.UTF8.GetBytes( "")); Assert.That(NodeSetToModelDesign.IsNodeSet(m_fileSystem, path), Is.False); } /// /// N-1: an instance without ParentNodeId whose inverse HasComponent /// points into another namespace (e.g. the ns=0 Server object) was /// assigned that parent, then neither linked nor emitted top-level, so /// it and its subtree vanished from the model. /// [Test] public void ImportInstanceWithInferredParentInOtherNamespaceIsTopLevel() { ModelDesign model = Import( """ MyDiagnostics i=58 i=2253 Counter i=58 ns=1;i=5001 """, out _); NodeDesign diagnostics = model.Items.SingleOrDefault( x => x.SymbolicName?.Name == "MyDiagnostics")!; Assert.That(diagnostics, Is.Not.Null, "the node must not be dropped"); Assert.That( diagnostics.Children?.Items?.Select(x => x.SymbolicName.Name), Is.EqualTo(s_counter), "the subtree must be kept"); } /// /// N-1: when a second inverse HasComponent names an owner in the node's /// own namespace that owner is used. /// [Test] public void ImportInstanceInfersParentInOwnNamespace() { ModelDesign model = Import( """ Owner i=58 Shared i=58 i=2253 ns=1;i=5001 """, out _); NodeDesign owner = model.Items.Single(x => x.SymbolicName?.Name == "Owner"); Assert.That( owner.Children?.Items?.Select(x => x.SymbolicName.Name), Is.EqualTo(s_shared)); } private static readonly string[] s_counter = ["Counter"]; private static readonly string[] s_shared = ["Shared"]; private static readonly string[] s_optionB = ["B"]; private static readonly string[] s_optionLast = ["Last"]; private static readonly decimal[] s_bigOptionMasks = [1m, 18446744073709551616m, 39614081257132168796771975168m]; private static readonly decimal[] s_flagMasks = [1m, 9223372036854775808m]; private static readonly string[] s_flagBitMasks = ["00000001", "8000000000000000"]; /// /// N-2: a node whose only link to its ParentNodeId is a /// non-hierarchical reference kept the ParentNodeId without being /// linked, so it was neither a child nor a top-level item. /// [Test] public void ImportChildLinkedOnlyNonHierarchicallyIsTopLevel() { ModelDesign model = Import( """ Machine i=58 Alarm i=58 ns=1;i=5001 """, out _); NodeDesign alarm = model.Items.SingleOrDefault(x => x.SymbolicName?.Name == "Alarm")!; Assert.That(alarm, Is.Not.Null, "the node must not be dropped"); Assert.That(alarm.References, Is.Not.Null); Reference reference = alarm.References.Single(); Assert.Multiple(() => { Assert.That(reference.ReferenceType.Name, Is.EqualTo("HasCondition")); Assert.That(reference.IsInverse, Is.True); Assert.That(reference.TargetId.Name, Is.EqualTo("Machine")); }); } /// /// N-3: ValueRank 2 without ArrayDimensions collapsed to /// OneOrMoreDimensions (0) because the rank is recovered from the /// number of ArrayDimensions entries. /// [TestCase(2, null, "0,0")] [TestCase(3, null, "0,0,0")] [TestCase(2, "3,4", "3,4")] [TestCase(1, null, "")] public void ImportMultiDimensionalValueRankKeepsRank( int valueRank, string? arrayDimensions, string expected) { string dimensions = arrayDimensions == null ? string.Empty : $" ArrayDimensions=\"{arrayDimensions}\""; Import( $""" Matrix i=62 """, out NodeSetReaderSettings settings); var variable = (VariableDesign)settings.NodesById[new NodeId(6010u, 1)]; Assert.That(variable.ArrayDimensions, Is.EqualTo(expected)); } /// /// N-11: a fractional MinimumSamplingInterval was truncated, turning /// 0.5 ms into 0 ("continuous"). /// [TestCase("0.5", 1)] [TestCase("100", 100)] [TestCase("-1", -1)] [TestCase("0", 0)] [TestCase("12.5", 13)] // N3b (OPC 10000-3 5.6.2): only -1 and 0 are defined special values; // NaN and other negatives were imported as 0 ("continuous") and are // now -1 ("indeterminate"). [TestCase("-0.5", -1)] [TestCase("-5", -1)] [TestCase("NaN", -1)] [TestCase("-INF", -1)] public void ImportMinimumSamplingIntervalRoundsUp(string interval, int expected) { Import( $""" Fast i=62 """, out NodeSetReaderSettings settings); var variable = (VariableDesign)settings.NodesById[new NodeId(6011u, 1)]; Assert.That(variable.MinimumSamplingInterval, Is.EqualTo(expected)); } /// /// N-10: the first DisplayName was used regardless of its locale. /// [Test] public void ImportLocalizedTextPrefersEnglishTranslation() { Import( """ Drehzahl Speed Die Drehzahl The speed i=58 """, out NodeSetReaderSettings settings); NodeDesign node = settings.NodesById[new NodeId(5001u, 1)]; Assert.Multiple(() => { Assert.That(node.DisplayName.Value, Is.EqualTo("Speed")); Assert.That(node.Description.Value, Is.EqualTo("The speed")); }); } /// /// N-10: an empty first translation hid a later, non-empty one. /// [Test] public void ImportLocalizedTextSkipsEmptyFirstEntry() { Import( """ Drehzahl i=58 """, out NodeSetReaderSettings settings); NodeDesign node = settings.NodesById[new NodeId(5001u, 1)]; Assert.That(node.DisplayName?.Value, Is.EqualTo("Drehzahl")); } /// /// N-14 / A2-6: an OptionSet field without a Value (default -1) or /// with a bit position a numeric OptionSet cannot hold produced the /// sign-bit (or a wrapped) mask. It is reported and left out; the /// rest of the type (and of the NodeSet) is still imported instead of /// the whole import being aborted. /// [TestCase("")] [TestCase(" Value=\"64\"")] [TestCase(" Value=\"-2\"")] public void ImportOptionSetFieldWithInvalidBitIsSkipped(string value) { Import( $""" MyFlags i=24 """, out NodeSetReaderSettings settings); var dataType = (DataTypeDesign)settings.NodesById[new NodeId(3001u, 1)]; Assert.That(dataType.Fields.Select(x => x.Name), Is.EqualTo(s_optionB)); Assert.That(dataType.Fields[0].Identifier, Is.EqualTo(2m)); } /// /// N6b (OPC 10000-3 5.8.2, 8.52): a numeric OptionSet can only use the /// bits of its base integer type. Bit 8 of a Byte OptionSet and bit 40 /// of a UInt32 OptionSet were accepted (up to 63 for every numeric /// OptionSet) although they cannot be encoded. /// [TestCase(3u, "Byte", 7, 8)] [TestCase(2u, "SByte", 7, 8)] [TestCase(5u, "UInt16", 15, 16)] [TestCase(7u, "UInt32", 31, 40)] [TestCase(9u, "UInt64", 63, 64)] public void ImportNumericOptionSetBitIsLimitedToTheBaseTypeWidth( uint baseTypeId, string baseTypeName, int lastValidBit, int invalidBit) { Import( $""" {baseTypeName} i=24 NarrowFlags i={baseTypeId} """, out NodeSetReaderSettings settings); var dataType = (DataTypeDesign)settings.NodesById[new NodeId(3003u, 1)]; Assert.That(dataType.Fields.Select(x => x.Name), Is.EqualTo(s_optionLast)); } /// /// A2-6: a subtype of the OptionSet structure (i=12755) keeps its bits /// in a ByteString, so bit positions of 64 and above are valid. The /// 0..63 check aborted the import of the whole NodeSet. /// [Test] public void ImportStructureOptionSetAcceptsBitsBeyond63() { Import( """ Structure i=24 OptionSet i=22 BigOptions i=12755 """, out NodeSetReaderSettings settings); var dataType = (DataTypeDesign)settings.NodesById[new NodeId(3002u, 1)]; Assert.That(dataType.IsOptionSet, Is.True); Assert.That( dataType.Fields.Select(x => x.Identifier), Is.EqualTo(s_bigOptionMasks)); } /// /// A subtype of the OptionSet structure has no upper bit (OPC 10000-3 /// 8.40); bits of 96 and above were rejected because the decimal /// design mask cannot hold them. The bit position is now kept /// explicitly. /// [Test] public void ImportStructureOptionSetAcceptsBitsBeyond95() { Import( """ Structure i=24 OptionSet i=22 HugeOptions i=12755 """, out NodeSetReaderSettings settings); var dataType = (DataTypeDesign)settings.NodesById[new NodeId(3004u, 1)]; Assert.That(dataType.Fields.Select(x => x.Name), Is.EqualTo(s_hugeOptionNames)); Assert.That(dataType.Fields[1].OptionSetBit, Is.EqualTo(130)); Assert.That(dataType.Fields[1].BitMask, Is.EqualTo("4" + new string('0', 32))); Assert.That(dataType.Fields[1].TryGetOptionSetBit(out int bit), Is.True); Assert.That(bit, Is.EqualTo(130)); Assert.That(dataType.Fields[0].Identifier, Is.EqualTo(2m)); Assert.That( ModelDesignExtensions.TryGetOptionSetBitFromBitMask(dataType.Fields[1].BitMask, out bit), Is.True); Assert.That(bit, Is.EqualTo(130)); } private static readonly string[] s_hugeOptionNames = ["B1", "B130"]; /// /// N-14 / A2-2: valid bit positions still map onto their masks. Bit 63 /// was computed with a signed shift and imported as a negative mask, /// which the generator emitted as a negative UInt64 enum constant /// (CS0031) and published as EnumField value 0. /// [Test] public void ImportOptionSetFieldBitMask() { Import( """ MyFlags i=24 """, out NodeSetReaderSettings settings); var dataType = (DataTypeDesign)settings.NodesById[new NodeId(3001u, 1)]; Assert.That( dataType.Fields.Select(x => x.Identifier), Is.EqualTo(s_flagMasks)); Assert.That( dataType.Fields.Select(x => x.BitMask), Is.EqualTo(s_flagBitMasks)); } /// /// ALT-4: a type whose own name ends in "_<identifier>" was taken /// for a de-duplicated one and got the suffix a second time /// ("Point_7" with i=7 became "Point_7_7"). /// [Test] public void ImportTypeNameEndingInItsIdentifierIsNotSuffixed() { Import( """ Point_7 i=24 Motor_Kind i=58 """, out NodeSetReaderSettings settings); Assert.Multiple(() => { NodeDesign point = settings.NodesById[new NodeId(7u, 1)]; Assert.That(point.SymbolicId.Name, Is.EqualTo("Point_7")); Assert.That(point.SymbolicName.Name, Is.EqualTo("Point_7")); NodeDesign motor = settings.NodesById[new NodeId("Kind", 1)]; Assert.That(motor.SymbolicId.Name, Is.EqualTo("Motor_Kind")); Assert.That(motor.SymbolicName.Name, Is.EqualTo("Motor_Kind")); }); } /// /// B-7: a NodeSet2 file that is not well formed (here: whitespace /// before the XML declaration) is still recognised as a NodeSet, so it /// fails in isolation instead of falling into the ModelDesign pass. /// [TestCase( "\r\n")] [TestCase( "\r\n\r\n")] public void IsNodeSetDetectsMalformedNodeSet(string content) { const string path = "memory://Malformed.NodeSet2.xml"; m_fileSystem.Add(path, Encoding.UTF8.GetBytes(content)); Assert.That(NodeSetToModelDesign.IsNodeSet(m_fileSystem, path), Is.True); } /// /// B-7: a malformed document with another root is still rejected. /// [Test] public void IsNodeSetRejectsMalformedModelDesign() { const string path = "memory://Malformed.xml"; m_fileSystem.Add( path, Encoding.UTF8.GetBytes(" ")); Assert.That(NodeSetToModelDesign.IsNodeSet(m_fileSystem, path), Is.False); } /// /// N-8: the collision suffix was the raw identifier, so Guid, opaque or /// dotted string identifiers produced SymbolicIds (and type class /// names) that are not valid C# identifiers. /// [TestCase( "ns=1;g=6f1c2b3a-0000-0000-0000-000000000001", "ns=1;g=6f1c2b3a-0000-0000-0000-000000000002", "ns=1;g=6f1c2b3a-0000-0000-0000-000000000003", "ns=1;g=6f1c2b3a-0000-0000-0000-000000000004")] [TestCase("ns=1;s=Line1.Status", "ns=1;s=Line2.Status", "ns=1;s=Line1.Type", "ns=1;s=Line2.Type")] [TestCase("ns=1;s=Moteuré1", "ns=1;s=Moteuré2", "ns=1;s=Typä1", "ns=1;s=Typä2")] [TestCase( "ns=1;b=M/RbKBsRVkePCePcx24oRA==", "ns=1;b=M+RbKBsRVkePCePcx24oRA==", "ns=1;b=N/RbKBsRVkePCePcx24oRA==", "ns=1;b=N+RbKBsRVkePCePcx24oRA==")] public void ImportCollisionSuffixIsValidIdentifier( string first, string second, string firstType, string secondType) { Import( $""" Status i=58 Status i=58 StatusType i=58 StatusType i=58 """, out NodeSetReaderSettings settings); NodeDesign[] nodes = [ .. settings.NodesById.Values.Where( x => x.SymbolicId.Namespace == TestNamespaceUri) ]; Assert.That(nodes, Has.Length.EqualTo(4)); Assert.That( nodes.Select(x => x.SymbolicId.Name).Distinct().Count(), Is.EqualTo(4), "the symbolic ids must be unique"); Assert.Multiple(() => { foreach (NodeDesign node in nodes) { Assert.That(node.SymbolicId.Name, Does.Match(kIdentifierPattern)); Assert.That(node.SymbolicName.Name, Does.Match(kIdentifierPattern)); if (node is TypeDesign type) { Assert.That(type.ClassName, Does.Match(kIdentifierPattern)); } } }); } /// /// N-8: a numeric collision suffix is unchanged. /// [Test] public void ImportNumericCollisionSuffixIsUnchanged() { Import( """ Status i=58 Status i=58 """, out NodeSetReaderSettings settings); Assert.Multiple(() => { Assert.That(settings.NodesById[new NodeId(5001u, 1)].SymbolicId.Name, Is.EqualTo("Status")); Assert.That(settings.NodesById[new NodeId(5002u, 1)].SymbolicId.Name, Is.EqualTo("Status_5002")); }); } /// /// N-6: a DataType whose SymbolicId collides gets a suffix on its /// SymbolicName, but its encodings were named after the bare name, so /// code generation could not find them and emitted NodeId.Null /// encoding ids. /// [TestCase(true)] [TestCase(false)] public void ImportEncodingOfDeduplicatedDataTypeFollowsItsName(bool inverseHasEncoding) { string inverse = inverseHasEncoding ? """ns=1;i=3005""" : string.Empty; Import( $""" MotorStatus i=58 Default Binary i=76 {inverse} MotorStatus i=24 ns=1;i=5005 """, out NodeSetReaderSettings settings); NodeDesign dataType = settings.NodesById[new NodeId(3005u, 1)]; NodeDesign encoding = settings.NodesById[new NodeId(5005u, 1)]; Assert.Multiple(() => { Assert.That(dataType.SymbolicName.Name, Is.EqualTo("MotorStatus_3005")); Assert.That( encoding.SymbolicId.Name, Is.EqualTo(dataType.SymbolicName.Name + "_Encoding_DefaultBinary")); }); } /// /// N-7: a leading digit of a browse name was replaced, so "1Axis" and /// "2Axis" both became "xAxis" and the generator failed on two nodes /// with the same symbolic name but different browse names. Clashing /// names keep the digit; a lone leading-digit name is unchanged. /// [Test] public void ImportLeadingDigitBrowseNamesDoNotCollide() { Import( """ GantryType i=58 ns=1;i=6001 ns=1;i=6002 ns=1;i=6003 1Axis i=58 2Axis i=58 3DModel i=58 """, out NodeSetReaderSettings settings); Assert.Multiple(() => { Assert.That(settings.NodesById[new NodeId(6001u, 1)].SymbolicName.Name, Is.EqualTo("n1Axis")); Assert.That(settings.NodesById[new NodeId(6002u, 1)].SymbolicName.Name, Is.EqualTo("n2Axis")); Assert.That( settings.NodesById[new NodeId(6003u, 1)].SymbolicName.Name, Is.EqualTo("xDModel"), "a leading-digit name that does not clash keeps its existing symbolic name"); Assert.That( settings.NodesById[new NodeId(6001u, 1)].SymbolicId.Name, Is.EqualTo("GantryType_n1Axis")); }); } /// /// N-5: a child of a de-duplicated parent was renamed while being linked /// after it had been registered under its assigned id, so NodesByQName /// had no entry for the emitted id and the ids reported to identifier /// sidecars (GetImportedSymbols) disagreed with the generated ones. /// [Test] public void ImportChildOfDeduplicatedParentKeepsReportedSymbolicId() { const string nodes = """ Parameters i=58 Parameters i=58 Speed i=58 ns=1;i=5001 Speed i=58 ns=1;i=5002 Unit i=58 ns=1;i=6002 """; Import(nodes, out NodeSetReaderSettings settings); var reported = new NodeSetToModelDesign( m_fileSystem, "memory://Import.NodeSet2.xml", new NodeSetReaderSettings(), NUnitTelemetryContext.Create(logLevel: LogLevel.Error)) .GetImportedSymbols(TestNamespaceUri) .ToDictionary(x => x.NumericId, x => x.SymbolicName); Assert.Multiple(() => { foreach (uint id in new uint[] { 5001, 5002, 6001, 6002, 7002 }) { NodeDesign node = settings.NodesById[new NodeId(id, 1)]; Assert.That(node.SymbolicId.Name, Is.EqualTo(reported[id]), $"node {id}"); Assert.That( settings.NodesByQName!.TryGetValue(node.SymbolicId, out NodeDesign? registered) && ReferenceEquals(registered, node), Is.True, $"node {id} must be registered under its emitted id"); } }); Assert.That( settings.NodesById[new NodeId(6002u, 1)].Parent, Is.SameAs(settings.NodesById[new NodeId(5002u, 1)])); } /// /// N-4: the server table started empty, so the first remote server of /// the NodeSet landed on index 0 (the local server) and a value's /// svr=1 was mapped to the wrong (or no) server. /// [TestCase(1u)] [TestCase(2u)] public void ImportValueKeepsRemoteServerIndex(uint serverIndex) { Import( $""" Remote i=62 svr={serverIndex};nsu=urn:remote:ns;i=5 """, out NodeSetReaderSettings settings, serverUris: ["urn:remote:one", "urn:remote:two"]); var variable = (VariableDesign)settings.NodesById[new NodeId(6020u, 1)]; Assert.That(variable.DecodedValue, Is.InstanceOf()); var value = (ExpandedNodeId)variable.DecodedValue; Assert.That(value.ServerIndex, Is.EqualTo(serverIndex)); } /// /// N-12: the XML fallback for method arguments (used when the typed /// decode yields nothing) concatenated the ArrayDimensions entries, /// turning dimensions 2 and 3 into "23". /// [Test] public void ImportArgumentXmlFallbackSeparatesArrayDimensions() { Import( """ Compute ns=1;i=7002 InputArguments i=68 ns=1;i=7001 ns=1;i=9999 Matrix i=11 2 2 3 """, out NodeSetReaderSettings settings); var method = (MethodDesign)settings.NodesById[new NodeId(7001u, 1)]; Assert.That(method.InputArguments, Has.Length.EqualTo(1)); Assert.Multiple(() => { Assert.That(method.InputArguments[0].Name, Is.EqualTo("Matrix")); Assert.That(method.InputArguments[0].ArrayDimensions, Is.EqualTo("2,3")); }); } /// /// N-9: several passes scanned the whole NodeSet per node (parent /// lookup parsing every NodeId, symbolic id de-duplication over all /// ids), which made large instance NodeSets effectively hang. /// [Test] public void ImportLargeInstanceNodeSetScalesLinearly() { const int parents = 10000; var nodes = new StringBuilder(); for (int ii = 0; ii < parents; ii++) { uint parentId = 100000u + (uint)ii; uint childId = 200000u + (uint)ii; nodes.Append(FormattableString.Invariant( $""" Device i=58 Status i=58 ns=1;i={parentId} """)); } var stopwatch = Stopwatch.StartNew(); ModelDesign model = Import(nodes.ToString(), out _); stopwatch.Stop(); Assert.That(model.Items.Count(x => x.HasChildren), Is.EqualTo(parents)); Assert.That( stopwatch.Elapsed, Is.LessThan(TimeSpan.FromSeconds(15)), "importing tens of thousands of instances must not be quadratic"); } /// /// A child's CLR property must not have the enclosing node-state class name. /// OPC 40100-1 ResultType declares precisely such a ResultState child. /// [Test] public void ImportDisambiguatesChildFromEnclosingStateClass() { ModelDesign model = Import( """ ResultType i=58 ns=1;i=1002 ResultState i=58 ns=1;i=1001 """, out _); var type = (ObjectTypeDesign)model.Items.Single(node => node.SymbolicName.Name == "ResultType"); Assert.That(type.ClassName, Is.EqualTo("ResultObject")); Assert.That(type.Children.Items.Single().SymbolicName.Name, Is.EqualTo("ResultState")); } private ModelDesign Import( string nodes, out NodeSetReaderSettings settings, string[]? serverUris = null) { const string path = "memory://Import.NodeSet2.xml"; string serverUriTable = serverUris == null ? string.Empty : "" + string.Concat(serverUris.Select(x => $"{x}")) + ""; string nodeSet = BaseNodeSet .Replace("__SERVERURIS__", serverUriTable, StringComparison.Ordinal) .Replace("__NODES__", nodes, StringComparison.Ordinal); m_fileSystem.Add(path, Encoding.UTF8.GetBytes(nodeSet)); settings = new NodeSetReaderSettings(); var importer = new NodeSetToModelDesign( m_fileSystem, path, settings, NUnitTelemetryContext.Create(logLevel: LogLevel.Error)); return importer.Import("Import", "Import"); } private const string kIdentifierPattern = "^[A-Za-z_][A-Za-z0-9_]*$"; /// /// The base nodes the tests rely on. Root types declare themselves as /// their own supertype, as the other importer tests do. /// private const string BaseNodeSet = """ http://test.org/UA/Import/ __SERVERURIS__ i=45 i=40 i=47 i=46 i=9006 i=38 HierarchicalReferences i=33 HasComponent i=33 HasProperty i=33 NonHierarchicalReferences i=32 HasCondition i=32 HasEncoding i=32 BaseObjectType i=58 DataTypeEncodingType i=58 BaseDataType i=24 Double i=24 ExpandedNodeId i=24 Argument i=24 BaseVariableType i=62 PropertyType i=62 __NODES__ """; } }