/* ========================================================================
* 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__
""";
}
}