mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-20 16:52:07 +08:00
9b034f8291
# Description Nullable reference types are now enabled once, in `common.props`, and every project in the repository builds warning-clean with them. **Configuration** - `<Nullable>enable</Nullable>` lives in `common.props`. All per-project `<Nullable>` settings are removed: 178 project files (enable, annotations, and the `disable` in the three Aot test projects). - 419 per-file `#nullable enable`/`restore` directives and the 4 `#nullable disable` directives are removed. - Kept on purpose: - The 5 `<auto-generated>` schema files (`UANodeSet.cs`, `UA Type Dictionary.cs`, `OPCBinarySchema.cs`, `SecuredApplication.cs`, `PInvokeHelper.cs`) keep `#nullable enable`. The compiler ignores the project setting in generated code, so the directive is required there. - `tests/Opc.Ua.Interop.LegacyPeer` keeps `disable`. It builds against the 1.5.378 packages and does not import the repository props. - `docs/DeveloperGuide.md` and `.github/copilot-instructions.md` now describe the central setting. **Migration** (the 43 unset and 13 annotations-only projects, roughly 10k diagnostics) - **Production code: annotated at the declarations.** Covers `tools/Opc.Ua.SourceGeneration.Core`, `src/Opc.Ua.Lds.Server` and the LDS sample. - Members, parameters and returns are nullable where null actually flows. - Null-check helpers carry `[NotNullWhen]` (`IsNull`, `IsValidXmlName`, `IsMethodTypeDesign`). - `CoreUtils.Format`, `Utils.Format`, `ITemplateWriter.WriteLine` take `params object?[]`. `Template.AddReplacement` takes `string?` (rendering already skips null replacements). - `IRegisteredServerStore.RegisterAsync` returns `Task<RegistrationEntry?>`: it returns null when a registration is removed. - Dereferences backed by an invariant (for example, the validated model) use `!`, so runtime behaviour is unchanged. - **Behavioural changes in production code** (small, deliberate): - `NodeSetToModelDesign` throws `InvalidOperationException` when the file is not a NodeSet. Before, it threw a NullReferenceException on the next line. - `ModelDesignValidator` starts its lookup tables empty instead of null. A redundant `if (m_nodes == null) return;` guard is gone. - **Test code: minimal annotations.** - `?` on values that can be null, `!` where the test asserts non-null. - `= null!` for fixture members assigned in SetUp. - `TryGetStructure<T>(out T? x)` where the inferred `T?` broke the `IEncodeable` constraint. - A few redundant `== null` checks on structs (`NodeId`, `ArrayOf<T>`) folded into the adjacent `IsNull`/`Count` checks. **Notes for reviewers** - The diff is large (~1,500 files) but almost entirely mechanical: `?`, `!` and directive removal. The non-annotation changes are listed above. - Known follow-up, not done here: `IModelDesign.Namespaces/TargetNamespace/NamespaceUris` stay non-nullable even though the validator returns null before it loads a dictionary. Making them nullable would ripple through every generator call site. ## Related Issues - Fixes #4439 ## Checklist - [ ] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [ ] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received. The "no new warnings" box covers net10.0 and net48: full non-incremental builds of `UA.slnx`, `tools/SourceGeneration.slnx`, `tools/MigrationAnalyzer.slnx` and the Aot test projects finished with 0 warnings and 0 errors under the repository's warnings-as-errors settings. net9.0 and net8.0 also had no nullable diagnostics, but they were not rebuilt after the final fixes. Tests have not been run locally yet. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1247 lines
55 KiB
C#
1247 lines
55 KiB
C#
/* ========================================================================
|
|
* 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
|
|
{
|
|
/// <summary>
|
|
/// Regression tests for the NodeSet2 to ModelDesign conversion of
|
|
/// <see cref="NodeSetToModelDesign"/>. 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.
|
|
/// </summary>
|
|
[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!;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A NodeSet that declares a model but no nodes deserializes with a
|
|
/// null Items array; AssignSymbolicIds threw a NullReferenceException.
|
|
/// </summary>
|
|
[Test]
|
|
public void ImportNodeSetWithoutNodesYieldsEmptyModel()
|
|
{
|
|
const string path = "memory://Empty.NodeSet2.xml";
|
|
m_fileSystem.Add(
|
|
path,
|
|
Encoding.UTF8.GetBytes(
|
|
"""
|
|
<?xml version="1.0" encoding="utf-8"?>
|
|
<UANodeSet xmlns="http://opcfoundation.org/UA/2011/03/UANodeSet.xsd">
|
|
<NamespaceUris>
|
|
<Uri>http://test.org/UA/Import/</Uri>
|
|
</NamespaceUris>
|
|
<Models>
|
|
<Model ModelUri="http://test.org/UA/Import/" Version="1.0.0" />
|
|
</Models>
|
|
</UANodeSet>
|
|
"""));
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// N-13: a minified NodeSet (declaration and root on one line) was not
|
|
/// recognized because every line starting with "<?" was skipped.
|
|
/// </summary>
|
|
[Test]
|
|
public void IsNodeSetDetectsRootOnXmlDeclarationLine()
|
|
{
|
|
const string path = "memory://Minified.NodeSet2.xml";
|
|
m_fileSystem.Add(
|
|
path,
|
|
Encoding.UTF8.GetBytes(
|
|
"<?xml version=\"1.0\" encoding=\"utf-8\"?><UANodeSet " +
|
|
"xmlns=\"http://opcfoundation.org/UA/2011/03/UANodeSet.xsd\"></UANodeSet>"));
|
|
|
|
Assert.That(NodeSetToModelDesign.IsNodeSet(m_fileSystem, path), Is.True);
|
|
}
|
|
|
|
/// <summary>
|
|
/// N-13: a line inside a multi-line comment that starts with '<' was
|
|
/// taken as the root element.
|
|
/// </summary>
|
|
[Test]
|
|
public void IsNodeSetSkipsMultiLineComment()
|
|
{
|
|
const string path = "memory://Commented.NodeSet2.xml";
|
|
m_fileSystem.Add(
|
|
path,
|
|
Encoding.UTF8.GetBytes(
|
|
"""
|
|
<?xml version="1.0" encoding="utf-8"?>
|
|
<!--
|
|
<ModelDesign> is not the root of this file.
|
|
-->
|
|
<UANodeSet xmlns="http://opcfoundation.org/UA/2011/03/UANodeSet.xsd">
|
|
</UANodeSet>
|
|
"""));
|
|
|
|
Assert.That(NodeSetToModelDesign.IsNodeSet(m_fileSystem, path), Is.True);
|
|
}
|
|
|
|
/// <summary>
|
|
/// N-13: a document whose root is not UANodeSet is still rejected.
|
|
/// </summary>
|
|
[Test]
|
|
public void IsNodeSetRejectsOtherRootOnDeclarationLine()
|
|
{
|
|
const string path = "memory://Design.xml";
|
|
m_fileSystem.Add(
|
|
path,
|
|
Encoding.UTF8.GetBytes(
|
|
"<?xml version=\"1.0\"?><!-- <UANodeSet> --><ModelDesign></ModelDesign>"));
|
|
|
|
Assert.That(NodeSetToModelDesign.IsNodeSet(m_fileSystem, path), Is.False);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[Test]
|
|
public void ImportInstanceWithInferredParentInOtherNamespaceIsTopLevel()
|
|
{
|
|
ModelDesign model = Import(
|
|
"""
|
|
<UAObject NodeId="ns=1;i=5001" BrowseName="1:MyDiagnostics">
|
|
<DisplayName>MyDiagnostics</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasTypeDefinition">i=58</Reference>
|
|
<Reference ReferenceType="HasComponent" IsForward="false">i=2253</Reference>
|
|
</References>
|
|
</UAObject>
|
|
<UAObject NodeId="ns=1;i=5002" BrowseName="1:Counter" ParentNodeId="ns=1;i=5001">
|
|
<DisplayName>Counter</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasTypeDefinition">i=58</Reference>
|
|
<Reference ReferenceType="HasComponent" IsForward="false">ns=1;i=5001</Reference>
|
|
</References>
|
|
</UAObject>
|
|
""",
|
|
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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// N-1: when a second inverse HasComponent names an owner in the node's
|
|
/// own namespace that owner is used.
|
|
/// </summary>
|
|
[Test]
|
|
public void ImportInstanceInfersParentInOwnNamespace()
|
|
{
|
|
ModelDesign model = Import(
|
|
"""
|
|
<UAObject NodeId="ns=1;i=5001" BrowseName="1:Owner">
|
|
<DisplayName>Owner</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasTypeDefinition">i=58</Reference>
|
|
</References>
|
|
</UAObject>
|
|
<UAObject NodeId="ns=1;i=5002" BrowseName="1:Shared">
|
|
<DisplayName>Shared</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasTypeDefinition">i=58</Reference>
|
|
<Reference ReferenceType="HasComponent" IsForward="false">i=2253</Reference>
|
|
<Reference ReferenceType="HasComponent" IsForward="false">ns=1;i=5001</Reference>
|
|
</References>
|
|
</UAObject>
|
|
""",
|
|
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"];
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[Test]
|
|
public void ImportChildLinkedOnlyNonHierarchicallyIsTopLevel()
|
|
{
|
|
ModelDesign model = Import(
|
|
"""
|
|
<UAObject NodeId="ns=1;i=5001" BrowseName="1:Machine">
|
|
<DisplayName>Machine</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasTypeDefinition">i=58</Reference>
|
|
</References>
|
|
</UAObject>
|
|
<UAObject NodeId="ns=1;i=6001" BrowseName="1:Alarm" ParentNodeId="ns=1;i=5001">
|
|
<DisplayName>Alarm</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasTypeDefinition">i=58</Reference>
|
|
<Reference ReferenceType="HasCondition" IsForward="false">ns=1;i=5001</Reference>
|
|
</References>
|
|
</UAObject>
|
|
""",
|
|
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"));
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// N-3: ValueRank 2 without ArrayDimensions collapsed to
|
|
/// OneOrMoreDimensions (0) because the rank is recovered from the
|
|
/// number of ArrayDimensions entries.
|
|
/// </summary>
|
|
[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(
|
|
$"""
|
|
<UAVariable NodeId="ns=1;i=6010" BrowseName="1:Matrix" DataType="i=11"
|
|
ValueRank="{valueRank}"{dimensions}>
|
|
<DisplayName>Matrix</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasTypeDefinition">i=62</Reference>
|
|
</References>
|
|
</UAVariable>
|
|
""",
|
|
out NodeSetReaderSettings settings);
|
|
|
|
var variable = (VariableDesign)settings.NodesById[new NodeId(6010u, 1)];
|
|
|
|
Assert.That(variable.ArrayDimensions, Is.EqualTo(expected));
|
|
}
|
|
|
|
/// <summary>
|
|
/// N-11: a fractional MinimumSamplingInterval was truncated, turning
|
|
/// 0.5 ms into 0 ("continuous").
|
|
/// </summary>
|
|
[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(
|
|
$"""
|
|
<UAVariable NodeId="ns=1;i=6011" BrowseName="1:Fast" DataType="i=11"
|
|
MinimumSamplingInterval="{interval}">
|
|
<DisplayName>Fast</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasTypeDefinition">i=62</Reference>
|
|
</References>
|
|
</UAVariable>
|
|
""",
|
|
out NodeSetReaderSettings settings);
|
|
|
|
var variable = (VariableDesign)settings.NodesById[new NodeId(6011u, 1)];
|
|
|
|
Assert.That(variable.MinimumSamplingInterval, Is.EqualTo(expected));
|
|
}
|
|
|
|
/// <summary>
|
|
/// N-10: the first DisplayName was used regardless of its locale.
|
|
/// </summary>
|
|
[Test]
|
|
public void ImportLocalizedTextPrefersEnglishTranslation()
|
|
{
|
|
Import(
|
|
"""
|
|
<UAObject NodeId="ns=1;i=5001" BrowseName="1:Speed">
|
|
<DisplayName Locale="de-DE">Drehzahl</DisplayName>
|
|
<DisplayName Locale="en">Speed</DisplayName>
|
|
<Description Locale="de-DE">Die Drehzahl</Description>
|
|
<Description Locale="en-US">The speed</Description>
|
|
<References>
|
|
<Reference ReferenceType="HasTypeDefinition">i=58</Reference>
|
|
</References>
|
|
</UAObject>
|
|
""",
|
|
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"));
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// N-10: an empty first translation hid a later, non-empty one.
|
|
/// </summary>
|
|
[Test]
|
|
public void ImportLocalizedTextSkipsEmptyFirstEntry()
|
|
{
|
|
Import(
|
|
"""
|
|
<UAObject NodeId="ns=1;i=5001" BrowseName="1:Speed">
|
|
<DisplayName Locale="en"></DisplayName>
|
|
<DisplayName Locale="de-DE">Drehzahl</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasTypeDefinition">i=58</Reference>
|
|
</References>
|
|
</UAObject>
|
|
""",
|
|
out NodeSetReaderSettings settings);
|
|
|
|
NodeDesign node = settings.NodesById[new NodeId(5001u, 1)];
|
|
|
|
Assert.That(node.DisplayName?.Value, Is.EqualTo("Drehzahl"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[TestCase("")]
|
|
[TestCase(" Value=\"64\"")]
|
|
[TestCase(" Value=\"-2\"")]
|
|
public void ImportOptionSetFieldWithInvalidBitIsSkipped(string value)
|
|
{
|
|
Import(
|
|
$"""
|
|
<UADataType NodeId="ns=1;i=3001" BrowseName="1:MyFlags">
|
|
<DisplayName>MyFlags</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasSubtype" IsForward="false">i=24</Reference>
|
|
</References>
|
|
<Definition Name="1:MyFlags" IsOptionSet="true">
|
|
<Field Name="A"{value} />
|
|
<Field Name="B" Value="1" />
|
|
</Definition>
|
|
</UADataType>
|
|
""",
|
|
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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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(
|
|
$"""
|
|
<UADataType NodeId="i={baseTypeId}" BrowseName="{baseTypeName}">
|
|
<DisplayName>{baseTypeName}</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasSubtype" IsForward="false">i=24</Reference>
|
|
</References>
|
|
</UADataType>
|
|
<UADataType NodeId="ns=1;i=3003" BrowseName="1:NarrowFlags">
|
|
<DisplayName>NarrowFlags</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasSubtype" IsForward="false">i={baseTypeId}</Reference>
|
|
</References>
|
|
<Definition Name="1:NarrowFlags" IsOptionSet="true">
|
|
<Field Name="Last" Value="{lastValidBit}" />
|
|
<Field Name="TooWide" Value="{invalidBit}" />
|
|
</Definition>
|
|
</UADataType>
|
|
""",
|
|
out NodeSetReaderSettings settings);
|
|
|
|
var dataType = (DataTypeDesign)settings.NodesById[new NodeId(3003u, 1)];
|
|
|
|
Assert.That(dataType.Fields.Select(x => x.Name), Is.EqualTo(s_optionLast));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[Test]
|
|
public void ImportStructureOptionSetAcceptsBitsBeyond63()
|
|
{
|
|
Import(
|
|
"""
|
|
<UADataType NodeId="i=22" BrowseName="Structure" IsAbstract="true">
|
|
<DisplayName>Structure</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasSubtype" IsForward="false">i=24</Reference>
|
|
</References>
|
|
</UADataType>
|
|
<UADataType NodeId="i=12755" BrowseName="OptionSet">
|
|
<DisplayName>OptionSet</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasSubtype" IsForward="false">i=22</Reference>
|
|
</References>
|
|
</UADataType>
|
|
<UADataType NodeId="ns=1;i=3002" BrowseName="1:BigOptions">
|
|
<DisplayName>BigOptions</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasSubtype" IsForward="false">i=12755</Reference>
|
|
</References>
|
|
<Definition Name="1:BigOptions">
|
|
<Field Name="B0" Value="0" />
|
|
<Field Name="B64" Value="64" />
|
|
<Field Name="B95" Value="95" />
|
|
</Definition>
|
|
</UADataType>
|
|
""",
|
|
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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[Test]
|
|
public void ImportStructureOptionSetAcceptsBitsBeyond95()
|
|
{
|
|
Import(
|
|
"""
|
|
<UADataType NodeId="i=22" BrowseName="Structure" IsAbstract="true">
|
|
<DisplayName>Structure</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasSubtype" IsForward="false">i=24</Reference>
|
|
</References>
|
|
</UADataType>
|
|
<UADataType NodeId="i=12755" BrowseName="OptionSet">
|
|
<DisplayName>OptionSet</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasSubtype" IsForward="false">i=22</Reference>
|
|
</References>
|
|
</UADataType>
|
|
<UADataType NodeId="ns=1;i=3004" BrowseName="1:HugeOptions">
|
|
<DisplayName>HugeOptions</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasSubtype" IsForward="false">i=12755</Reference>
|
|
</References>
|
|
<Definition Name="1:HugeOptions">
|
|
<Field Name="B1" Value="1" />
|
|
<Field Name="B130" Value="130" />
|
|
</Definition>
|
|
</UADataType>
|
|
""",
|
|
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"];
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[Test]
|
|
public void ImportOptionSetFieldBitMask()
|
|
{
|
|
Import(
|
|
"""
|
|
<UADataType NodeId="ns=1;i=3001" BrowseName="1:MyFlags">
|
|
<DisplayName>MyFlags</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasSubtype" IsForward="false">i=24</Reference>
|
|
</References>
|
|
<Definition Name="1:MyFlags" IsOptionSet="true">
|
|
<Field Name="A" Value="0" />
|
|
<Field Name="B" Value="63" />
|
|
</Definition>
|
|
</UADataType>
|
|
""",
|
|
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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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").
|
|
/// </summary>
|
|
[Test]
|
|
public void ImportTypeNameEndingInItsIdentifierIsNotSuffixed()
|
|
{
|
|
Import(
|
|
"""
|
|
<UADataType NodeId="ns=1;i=7" BrowseName="1:Point_7">
|
|
<DisplayName>Point_7</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasSubtype" IsForward="false">i=24</Reference>
|
|
</References>
|
|
</UADataType>
|
|
<UAObjectType NodeId="ns=1;s=Kind" BrowseName="1:Motor_Kind">
|
|
<DisplayName>Motor_Kind</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasSubtype" IsForward="false">i=58</Reference>
|
|
</References>
|
|
</UAObjectType>
|
|
""",
|
|
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"));
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[TestCase(
|
|
"\r\n<?xml version=\"1.0\"?><UANodeSet " +
|
|
"xmlns=\"http://opcfoundation.org/UA/2011/03/UANodeSet.xsd\"></UANodeSet>")]
|
|
[TestCase(
|
|
"<?xml version=\"1.0\"?>\r\n<!-- <ModelDesign> -->\r\n<ua:UANodeSet><Broken></ua:UANodeSet>")]
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// B-7: a malformed document with another root is still rejected.
|
|
/// </summary>
|
|
[Test]
|
|
public void IsNodeSetRejectsMalformedModelDesign()
|
|
{
|
|
const string path = "memory://Malformed.xml";
|
|
m_fileSystem.Add(
|
|
path,
|
|
Encoding.UTF8.GetBytes(" <?xml version=\"1.0\"?><opc:ModelDesign><UANodeSet>"));
|
|
|
|
Assert.That(NodeSetToModelDesign.IsNodeSet(m_fileSystem, path), Is.False);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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(
|
|
$"""
|
|
<UAObject NodeId="{first}" BrowseName="1:Status">
|
|
<DisplayName>Status</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasTypeDefinition">i=58</Reference>
|
|
</References>
|
|
</UAObject>
|
|
<UAObject NodeId="{second}" BrowseName="1:Status">
|
|
<DisplayName>Status</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasTypeDefinition">i=58</Reference>
|
|
</References>
|
|
</UAObject>
|
|
<UAObjectType NodeId="{firstType}"
|
|
BrowseName="1:StatusType">
|
|
<DisplayName>StatusType</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasSubtype" IsForward="false">i=58</Reference>
|
|
</References>
|
|
</UAObjectType>
|
|
<UAObjectType NodeId="{secondType}"
|
|
BrowseName="1:StatusType">
|
|
<DisplayName>StatusType</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasSubtype" IsForward="false">i=58</Reference>
|
|
</References>
|
|
</UAObjectType>
|
|
""",
|
|
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));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// N-8: a numeric collision suffix is unchanged.
|
|
/// </summary>
|
|
[Test]
|
|
public void ImportNumericCollisionSuffixIsUnchanged()
|
|
{
|
|
Import(
|
|
"""
|
|
<UAObject NodeId="ns=1;i=5001" BrowseName="1:Status">
|
|
<DisplayName>Status</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasTypeDefinition">i=58</Reference>
|
|
</References>
|
|
</UAObject>
|
|
<UAObject NodeId="ns=1;i=5002" BrowseName="1:Status">
|
|
<DisplayName>Status</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasTypeDefinition">i=58</Reference>
|
|
</References>
|
|
</UAObject>
|
|
""",
|
|
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"));
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[TestCase(true)]
|
|
[TestCase(false)]
|
|
public void ImportEncodingOfDeduplicatedDataTypeFollowsItsName(bool inverseHasEncoding)
|
|
{
|
|
string inverse = inverseHasEncoding
|
|
? """<Reference ReferenceType="HasEncoding" IsForward="false">ns=1;i=3005</Reference>"""
|
|
: string.Empty;
|
|
Import(
|
|
$"""
|
|
<UAObject NodeId="ns=1;i=5001" BrowseName="1:MotorStatus">
|
|
<DisplayName>MotorStatus</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasTypeDefinition">i=58</Reference>
|
|
</References>
|
|
</UAObject>
|
|
<UAObject NodeId="ns=1;i=5005" BrowseName="Default Binary">
|
|
<DisplayName>Default Binary</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasTypeDefinition">i=76</Reference>
|
|
{inverse}
|
|
</References>
|
|
</UAObject>
|
|
<UADataType NodeId="ns=1;i=3005" BrowseName="1:MotorStatus">
|
|
<DisplayName>MotorStatus</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasSubtype" IsForward="false">i=24</Reference>
|
|
<Reference ReferenceType="HasEncoding">ns=1;i=5005</Reference>
|
|
</References>
|
|
</UADataType>
|
|
""",
|
|
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"));
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[Test]
|
|
public void ImportLeadingDigitBrowseNamesDoNotCollide()
|
|
{
|
|
Import(
|
|
"""
|
|
<UAObjectType NodeId="ns=1;i=1001" BrowseName="1:GantryType">
|
|
<DisplayName>GantryType</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasSubtype" IsForward="false">i=58</Reference>
|
|
<Reference ReferenceType="HasComponent">ns=1;i=6001</Reference>
|
|
<Reference ReferenceType="HasComponent">ns=1;i=6002</Reference>
|
|
<Reference ReferenceType="HasComponent">ns=1;i=6003</Reference>
|
|
</References>
|
|
</UAObjectType>
|
|
<UAObject NodeId="ns=1;i=6001" BrowseName="1:1Axis" ParentNodeId="ns=1;i=1001">
|
|
<DisplayName>1Axis</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasTypeDefinition">i=58</Reference>
|
|
</References>
|
|
</UAObject>
|
|
<UAObject NodeId="ns=1;i=6002" BrowseName="1:2Axis" ParentNodeId="ns=1;i=1001">
|
|
<DisplayName>2Axis</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasTypeDefinition">i=58</Reference>
|
|
</References>
|
|
</UAObject>
|
|
<UAObject NodeId="ns=1;i=6003" BrowseName="1:3DModel" ParentNodeId="ns=1;i=1001">
|
|
<DisplayName>3DModel</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasTypeDefinition">i=58</Reference>
|
|
</References>
|
|
</UAObject>
|
|
""",
|
|
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"));
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[Test]
|
|
public void ImportChildOfDeduplicatedParentKeepsReportedSymbolicId()
|
|
{
|
|
const string nodes = """
|
|
<UAObject NodeId="ns=1;i=5001" BrowseName="1:Parameters">
|
|
<DisplayName>Parameters</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasTypeDefinition">i=58</Reference>
|
|
</References>
|
|
</UAObject>
|
|
<UAObject NodeId="ns=1;i=5002" BrowseName="1:Parameters">
|
|
<DisplayName>Parameters</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasTypeDefinition">i=58</Reference>
|
|
</References>
|
|
</UAObject>
|
|
<UAObject NodeId="ns=1;i=6001" BrowseName="1:Speed" ParentNodeId="ns=1;i=5001">
|
|
<DisplayName>Speed</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasTypeDefinition">i=58</Reference>
|
|
<Reference ReferenceType="HasComponent" IsForward="false">ns=1;i=5001</Reference>
|
|
</References>
|
|
</UAObject>
|
|
<UAObject NodeId="ns=1;i=6002" BrowseName="1:Speed" ParentNodeId="ns=1;i=5002">
|
|
<DisplayName>Speed</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasTypeDefinition">i=58</Reference>
|
|
<Reference ReferenceType="HasComponent" IsForward="false">ns=1;i=5002</Reference>
|
|
</References>
|
|
</UAObject>
|
|
<UAObject NodeId="ns=1;i=7002" BrowseName="1:Unit" ParentNodeId="ns=1;i=6002">
|
|
<DisplayName>Unit</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasTypeDefinition">i=58</Reference>
|
|
<Reference ReferenceType="HasComponent" IsForward="false">ns=1;i=6002</Reference>
|
|
</References>
|
|
</UAObject>
|
|
""";
|
|
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)]));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[TestCase(1u)]
|
|
[TestCase(2u)]
|
|
public void ImportValueKeepsRemoteServerIndex(uint serverIndex)
|
|
{
|
|
Import(
|
|
$"""
|
|
<UAVariable NodeId="ns=1;i=6020" BrowseName="1:Remote" DataType="i=18">
|
|
<DisplayName>Remote</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasTypeDefinition">i=62</Reference>
|
|
</References>
|
|
<Value>
|
|
<uax:ExpandedNodeId>
|
|
<uax:Identifier>svr={serverIndex};nsu=urn:remote:ns;i=5</uax:Identifier>
|
|
</uax:ExpandedNodeId>
|
|
</Value>
|
|
</UAVariable>
|
|
""",
|
|
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<ExpandedNodeId>());
|
|
var value = (ExpandedNodeId)variable.DecodedValue;
|
|
Assert.That(value.ServerIndex, Is.EqualTo(serverIndex));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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".
|
|
/// </summary>
|
|
[Test]
|
|
public void ImportArgumentXmlFallbackSeparatesArrayDimensions()
|
|
{
|
|
Import(
|
|
"""
|
|
<UAMethod NodeId="ns=1;i=7001" BrowseName="1:Compute">
|
|
<DisplayName>Compute</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasProperty">ns=1;i=7002</Reference>
|
|
</References>
|
|
</UAMethod>
|
|
<UAVariable NodeId="ns=1;i=7002" BrowseName="InputArguments" ParentNodeId="ns=1;i=7001"
|
|
DataType="i=296" ValueRank="1">
|
|
<DisplayName>InputArguments</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasTypeDefinition">i=68</Reference>
|
|
<Reference ReferenceType="HasProperty" IsForward="false">ns=1;i=7001</Reference>
|
|
</References>
|
|
<Value>
|
|
<uax:ListOfExtensionObject>
|
|
<uax:ExtensionObject>
|
|
<uax:TypeId>
|
|
<uax:Identifier>ns=1;i=9999</uax:Identifier>
|
|
</uax:TypeId>
|
|
<uax:Body>
|
|
<uax:Argument>
|
|
<uax:Name>Matrix</uax:Name>
|
|
<uax:DataType>
|
|
<uax:Identifier>i=11</uax:Identifier>
|
|
</uax:DataType>
|
|
<uax:ValueRank>2</uax:ValueRank>
|
|
<uax:ArrayDimensions>
|
|
<uax:UInt32>2</uax:UInt32>
|
|
<uax:UInt32>3</uax:UInt32>
|
|
</uax:ArrayDimensions>
|
|
</uax:Argument>
|
|
</uax:Body>
|
|
</uax:ExtensionObject>
|
|
</uax:ListOfExtensionObject>
|
|
</Value>
|
|
</UAVariable>
|
|
""",
|
|
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"));
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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(
|
|
$"""
|
|
<UAObject NodeId="ns=1;i={parentId}" BrowseName="1:Device{ii}">
|
|
<DisplayName>Device</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasTypeDefinition">i=58</Reference>
|
|
</References>
|
|
</UAObject>
|
|
<UAObject NodeId="ns=1;i={childId}" BrowseName="1:Status" ParentNodeId="ns=1;i={parentId}">
|
|
<DisplayName>Status</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasTypeDefinition">i=58</Reference>
|
|
<Reference ReferenceType="HasComponent" IsForward="false">ns=1;i={parentId}</Reference>
|
|
</References>
|
|
</UAObject>
|
|
|
|
"""));
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
private ModelDesign Import(
|
|
string nodes,
|
|
out NodeSetReaderSettings settings,
|
|
string[]? serverUris = null)
|
|
{
|
|
const string path = "memory://Import.NodeSet2.xml";
|
|
string serverUriTable = serverUris == null
|
|
? string.Empty
|
|
: "<ServerUris>" +
|
|
string.Concat(serverUris.Select(x => $"<Uri>{x}</Uri>")) +
|
|
"</ServerUris>";
|
|
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_]*$";
|
|
|
|
/// <summary>
|
|
/// The base nodes the tests rely on. Root types declare themselves as
|
|
/// their own supertype, as the other importer tests do.
|
|
/// </summary>
|
|
private const string BaseNodeSet = """
|
|
<?xml version="1.0" encoding="utf-8"?>
|
|
<UANodeSet xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
|
|
xmlns:uax="http://opcfoundation.org/UA/2008/02/Types.xsd"
|
|
xmlns="http://opcfoundation.org/UA/2011/03/UANodeSet.xsd">
|
|
<NamespaceUris>
|
|
<Uri>http://test.org/UA/Import/</Uri>
|
|
</NamespaceUris>
|
|
__SERVERURIS__
|
|
<Models>
|
|
<Model ModelUri="http://test.org/UA/Import/"
|
|
PublicationDate="2026-08-12T00:00:00Z"
|
|
Version="1.0.0" />
|
|
</Models>
|
|
<Aliases>
|
|
<Alias Alias="HasSubtype">i=45</Alias>
|
|
<Alias Alias="HasTypeDefinition">i=40</Alias>
|
|
<Alias Alias="HasComponent">i=47</Alias>
|
|
<Alias Alias="HasProperty">i=46</Alias>
|
|
<Alias Alias="HasCondition">i=9006</Alias>
|
|
<Alias Alias="HasEncoding">i=38</Alias>
|
|
</Aliases>
|
|
<UAReferenceType NodeId="i=33" BrowseName="HierarchicalReferences" IsAbstract="true">
|
|
<DisplayName>HierarchicalReferences</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasSubtype" IsForward="false">i=33</Reference>
|
|
</References>
|
|
</UAReferenceType>
|
|
<UAReferenceType NodeId="i=47" BrowseName="HasComponent">
|
|
<DisplayName>HasComponent</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasSubtype" IsForward="false">i=33</Reference>
|
|
</References>
|
|
</UAReferenceType>
|
|
<UAReferenceType NodeId="i=46" BrowseName="HasProperty">
|
|
<DisplayName>HasProperty</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasSubtype" IsForward="false">i=33</Reference>
|
|
</References>
|
|
</UAReferenceType>
|
|
<UAReferenceType NodeId="i=32" BrowseName="NonHierarchicalReferences" IsAbstract="true">
|
|
<DisplayName>NonHierarchicalReferences</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasSubtype" IsForward="false">i=32</Reference>
|
|
</References>
|
|
</UAReferenceType>
|
|
<UAReferenceType NodeId="i=9006" BrowseName="HasCondition">
|
|
<DisplayName>HasCondition</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasSubtype" IsForward="false">i=32</Reference>
|
|
</References>
|
|
</UAReferenceType>
|
|
<UAReferenceType NodeId="i=38" BrowseName="HasEncoding">
|
|
<DisplayName>HasEncoding</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasSubtype" IsForward="false">i=32</Reference>
|
|
</References>
|
|
</UAReferenceType>
|
|
<UAObjectType NodeId="i=58" BrowseName="BaseObjectType">
|
|
<DisplayName>BaseObjectType</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasSubtype" IsForward="false">i=58</Reference>
|
|
</References>
|
|
</UAObjectType>
|
|
<UAObjectType NodeId="i=76" BrowseName="DataTypeEncodingType">
|
|
<DisplayName>DataTypeEncodingType</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasSubtype" IsForward="false">i=58</Reference>
|
|
</References>
|
|
</UAObjectType>
|
|
<UADataType NodeId="i=24" BrowseName="BaseDataType" IsAbstract="true">
|
|
<DisplayName>BaseDataType</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasSubtype" IsForward="false">i=24</Reference>
|
|
</References>
|
|
</UADataType>
|
|
<UADataType NodeId="i=11" BrowseName="Double">
|
|
<DisplayName>Double</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasSubtype" IsForward="false">i=24</Reference>
|
|
</References>
|
|
</UADataType>
|
|
<UADataType NodeId="i=18" BrowseName="ExpandedNodeId">
|
|
<DisplayName>ExpandedNodeId</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasSubtype" IsForward="false">i=24</Reference>
|
|
</References>
|
|
</UADataType>
|
|
<UADataType NodeId="i=296" BrowseName="Argument">
|
|
<DisplayName>Argument</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasSubtype" IsForward="false">i=24</Reference>
|
|
</References>
|
|
</UADataType>
|
|
<UAVariableType NodeId="i=62" BrowseName="BaseVariableType" IsAbstract="true" ValueRank="-2">
|
|
<DisplayName>BaseVariableType</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasSubtype" IsForward="false">i=62</Reference>
|
|
</References>
|
|
</UAVariableType>
|
|
<UAVariableType NodeId="i=68" BrowseName="PropertyType" ValueRank="-2">
|
|
<DisplayName>PropertyType</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="HasSubtype" IsForward="false">i=62</Reference>
|
|
</References>
|
|
</UAVariableType>
|
|
__NODES__
|
|
</UANodeSet>
|
|
""";
|
|
}
|
|
}
|