mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-21 00:56:28 +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>
1148 lines
56 KiB
C#
1148 lines
56 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.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.Tests;
|
|
|
|
namespace Opc.Ua.Core.Tests.Stack.Schema
|
|
{
|
|
/// <summary>
|
|
/// Tests for the UANodeSet helper.
|
|
/// </summary>
|
|
[TestFixture]
|
|
[Category("UANodeSet")]
|
|
[SetCulture("en-us")]
|
|
[SetUICulture("en-us")]
|
|
[Parallelizable]
|
|
public class UANodeSetHelpersTests
|
|
{
|
|
/// <summary>
|
|
/// A NodeSet writes a Variable's value as the typed element alone, but
|
|
/// the Variant XML encoding nests that element inside a <c>Value</c>
|
|
/// element. Importing the bare element found no <c>Value</c> to begin
|
|
/// and silently produced a null Variant, so every value in every
|
|
/// imported NodeSet was lost — browsable nodes that read back empty.
|
|
/// </summary>
|
|
[Test]
|
|
public void ImportRestoresVariableValues()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
const string nodeSetXml = @"
|
|
<UANodeSet xmlns='http://opcfoundation.org/UA/2011/03/UANodeSet.xsd'>
|
|
<NamespaceUris>
|
|
<Uri>urn:test:values</Uri>
|
|
</NamespaceUris>
|
|
<Aliases>
|
|
<Alias Alias='String'>i=12</Alias>
|
|
<Alias Alias='Boolean'>i=1</Alias>
|
|
</Aliases>
|
|
<UAVariable NodeId='ns=1;s=Text' BrowseName='1:Text' DataType='String'>
|
|
<DisplayName>Text</DisplayName>
|
|
<Value>
|
|
<uax:String xmlns:uax='http://opcfoundation.org/UA/2008/02/Types.xsd'>hello</uax:String>
|
|
</Value>
|
|
</UAVariable>
|
|
<UAVariable NodeId='ns=1;s=Flag' BrowseName='1:Flag' DataType='Boolean'>
|
|
<DisplayName>Flag</DisplayName>
|
|
<Value>
|
|
<uax:Boolean xmlns:uax='http://opcfoundation.org/UA/2008/02/Types.xsd'>true</uax:Boolean>
|
|
</Value>
|
|
</UAVariable>
|
|
</UANodeSet>";
|
|
|
|
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(nodeSetXml));
|
|
Export.UANodeSet nodeSet = Export.UANodeSet.Read(stream)!;
|
|
var context = new SystemContext(telemetry) { NamespaceUris = new NamespaceTable() };
|
|
foreach (string namespaceUri in nodeSet!.NamespaceUris!)
|
|
{
|
|
context.NamespaceUris.Append(namespaceUri);
|
|
}
|
|
var imported = new NodeStateCollection();
|
|
|
|
nodeSet.Import(context, imported);
|
|
|
|
BaseVariableState text = imported.OfType<BaseVariableState>()
|
|
.Single(node => node.BrowseName.Name == "Text");
|
|
BaseVariableState flag = imported.OfType<BaseVariableState>()
|
|
.Single(node => node.BrowseName.Name == "Flag");
|
|
Assert.That(text.WrappedValue.IsNull, Is.False, "The String value must survive the import.");
|
|
Assert.That(text.WrappedValue.GetString(), Is.EqualTo("hello"));
|
|
Assert.That(flag.WrappedValue.IsNull, Is.False, "The Boolean value must survive the import.");
|
|
Assert.That(flag.WrappedValue.GetBoolean(), Is.True);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The value element was copied with ImportNode(deep), which recurses
|
|
/// once per element level, so a deeply nested XmlElement value in a
|
|
/// NodeSet exhausted the stack. The copy no longer recurses and the
|
|
/// decoder bounds the element depth.
|
|
/// </summary>
|
|
[Test]
|
|
public void ImportRejectsDeeplyNestedXmlElementValue()
|
|
{
|
|
string nested = string.Concat(Enumerable.Repeat("<a>", 100_000)) +
|
|
string.Concat(Enumerable.Repeat("</a>", 100_000));
|
|
ServiceResultException ex = Assert.Throws<ServiceResultException>(
|
|
() => ImportXmlElementValue(nested));
|
|
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadEncodingLimitsExceeded));
|
|
}
|
|
|
|
[Test]
|
|
public void ImportRestoresXmlElementValue()
|
|
{
|
|
BaseVariableState variable = ImportXmlElementValue("<a x='1'><b>text</b></a>");
|
|
|
|
Assert.That(variable.WrappedValue.TryGetValue(out XmlElement value), Is.True);
|
|
// the content inherits the default namespace of the NodeSet.
|
|
Assert.That(value, Is.EqualTo(XmlElement.From(
|
|
"<a x=\"1\" xmlns=\"http://opcfoundation.org/UA/2011/03/UANodeSet.xsd\">" +
|
|
"<b>text</b></a>")));
|
|
}
|
|
|
|
private static BaseVariableState ImportXmlElementValue(string content)
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
string nodeSetXml = @"
|
|
<UANodeSet xmlns='http://opcfoundation.org/UA/2011/03/UANodeSet.xsd'>
|
|
<NamespaceUris>
|
|
<Uri>urn:test:values</Uri>
|
|
</NamespaceUris>
|
|
<Aliases>
|
|
<Alias Alias='XmlElement'>i=16</Alias>
|
|
</Aliases>
|
|
<UAVariable NodeId='ns=1;s=Xml' BrowseName='1:Xml' DataType='XmlElement'>
|
|
<DisplayName>Xml</DisplayName>
|
|
<Value>
|
|
<uax:XmlElement xmlns:uax='http://opcfoundation.org/UA/2008/02/Types.xsd'>" +
|
|
content +
|
|
@"</uax:XmlElement>
|
|
</Value>
|
|
</UAVariable>
|
|
</UANodeSet>";
|
|
|
|
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(nodeSetXml));
|
|
Export.UANodeSet nodeSet = Export.UANodeSet.Read(stream)!;
|
|
var context = new SystemContext(telemetry) { NamespaceUris = new NamespaceTable() };
|
|
foreach (string namespaceUri in nodeSet!.NamespaceUris!)
|
|
{
|
|
context.NamespaceUris.Append(namespaceUri);
|
|
}
|
|
var imported = new NodeStateCollection();
|
|
|
|
nodeSet.Import(context, imported);
|
|
|
|
return imported.OfType<BaseVariableState>().Single();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test Structure Field ArrayDimensions attribute is correctly imported respectively exported
|
|
/// </summary>
|
|
[Test]
|
|
public void ArrayDimensionsValidationTest()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
|
|
const string bufferPath = "./ArrayDimensionsValidationTest.xml";
|
|
const string importBuffer =
|
|
@"<?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' LastModified='2021-09-16T19:10:18.097476Z' xmlns='http://opcfoundation.org/UA/2011/03/UANodeSet.xsd'>
|
|
<NamespaceUris>
|
|
<Uri>urn:foobar</Uri>
|
|
</NamespaceUris>
|
|
<Aliases>
|
|
<Alias Alias='HasSubtype'>i=45</Alias>
|
|
<Alias Alias='HasEncoding'>i=38</Alias>
|
|
</Aliases>
|
|
<UADataType NodeId='ns=1;s=Simple Structure' BrowseName='Simple Structure'>
|
|
<DisplayName>Simple Structure</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType='HasEncoding'>ns=1;s=Simple Structure Encoding</Reference>
|
|
<Reference ReferenceType='HasSubtype' IsForward='false'>i=22</Reference>
|
|
</References>
|
|
<Definition Name='Simple Structure' IsUnion='true'>
|
|
<Field Name='Duration Field' DataType='i=290' />
|
|
<Field Name='Double Field' DataType='i=11' />
|
|
</Definition>
|
|
</UADataType>
|
|
<UADataType NodeId='ns=1;s=Complex Structure' BrowseName='Complex Structure'>
|
|
<DisplayName>Complex Structure</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType='HasEncoding'>ns=1;s=Complex Structure Encoding</Reference>
|
|
<Reference ReferenceType='HasSubtype' IsForward='false'>i=22</Reference>
|
|
</References>
|
|
<Definition Name='Complex Structure'>
|
|
<Field Name='Scalar Structure' DataType='i=22' />
|
|
<Field Name='Scalar BuildInfo' DataType='i=338' />
|
|
<Field Name='Scalar Simple Structure' DataType='ns=1;s=Simple Structure' />
|
|
<Field Name='Scalar Boolean' DataType='i=1' />
|
|
<Field Name='Scalar Duration' DataType='i=290' />
|
|
<Field Name='Scalar String within max length' DataType='i=12' MaxStringLength='256' />
|
|
<Field Name='1D Array String no max length' DataType='i=12' ValueRank='1' />
|
|
<Field Name='1D Array String within max length' DataType='i=12' ValueRank='1' MaxStringLength='256' />
|
|
<Field Name='1D Array of Simple Structure 1' DataType='ns=1;s=Simple Structure' ValueRank='1' ArrayDimensions='2' />
|
|
<Field Name='1D Array of Simple Structure 2' DataType='ns=1;s=Simple Structure' ValueRank='1' ArrayDimensions='3' />
|
|
<Field Name='1D Array of BuildInfo' DataType='i=338' ValueRank='1' />
|
|
<Field Name='1D Array of Simple Structure' DataType='ns=1;s=Simple Structure' ValueRank='1' />
|
|
<Field Name='1D Array of Boolean' DataType='i=1' ValueRank='1' />
|
|
<Field Name='1D Array of Duration' DataType='i=290' ValueRank='1' />
|
|
<Field Name='1D Array of MessageSecurityMode' DataType='i=302' ValueRank='1' />
|
|
<Field Name='2D Array of Structure' DataType='i=22' ValueRank='2' ArrayDimensions='2,3' />
|
|
<Field Name='2D Array of BuildInfo' DataType='i=338' ValueRank='2' ArrayDimensions='2,3' />
|
|
<Field Name='2D Array of Simple Structure' DataType='ns=1;s=Simple Structure' ValueRank='2' ArrayDimensions='2,3' />
|
|
<Field Name='2D Array of Boolean' DataType='i=1' ValueRank='2' ArrayDimensions='2,3' />
|
|
<Field Name='2D Array of Duration' DataType='i=290' ValueRank='2' ArrayDimensions='2,3' />
|
|
<Field Name='2D Array of MessageSecurityMode' DataType='i=302' ValueRank='2' ArrayDimensions='2,3' />
|
|
</Definition>
|
|
</UADataType>
|
|
</UANodeSet>";
|
|
|
|
using var importStream = new MemoryStream(Encoding.UTF8.GetBytes(importBuffer));
|
|
var importedNodeSet = Export.UANodeSet.Read(importStream);
|
|
|
|
var importedNodeStates = new NodeStateCollection();
|
|
var localContext = new SystemContext(telemetry) { NamespaceUris = new NamespaceTable() };
|
|
foreach (string namespaceUri in importedNodeSet!.NamespaceUris!)
|
|
{
|
|
localContext.NamespaceUris.Append(namespaceUri);
|
|
}
|
|
|
|
importedNodeSet.Import(localContext, importedNodeStates);
|
|
|
|
Assert.That(importedNodeSet.NamespaceUris, Has.Length.EqualTo(1));
|
|
Assert.That(importedNodeSet.Items, Has.Length.EqualTo(2));
|
|
var dataType1 = importedNodeSet.Items[0] as Export.UADataType;
|
|
var dataType2 = importedNodeSet.Items[1] as Export.UADataType;
|
|
|
|
Assert.That(dataType1, Is.Not.Null);
|
|
Assert.That(dataType1.Definition!.Field, Has.Length.EqualTo(2));
|
|
Assert.IsEmpty(dataType1.Definition.Field[0].ArrayDimensions);
|
|
Assert.That(dataType1.Definition.IsUnion, Is.True);
|
|
|
|
Assert.That(dataType2, Is.Not.Null);
|
|
Assert.That(dataType2.Definition!.IsUnion, Is.False);
|
|
Assert.That(dataType2.Definition.Field, Has.Length.EqualTo(21));
|
|
Assert.That(dataType2.Definition.Field[15].ArrayDimensions, Is.EqualTo("2,3"));
|
|
Assert.That(dataType2.Definition.Field[5].MaxStringLength, Is.EqualTo(256));
|
|
|
|
// export the nodeSet to a file, reimport it and re-test.
|
|
using (var fileStream = new FileStream(bufferPath, FileMode.Create))
|
|
{
|
|
importedNodeStates.SaveAsNodeSet2(localContext, fileStream);
|
|
}
|
|
try
|
|
{
|
|
using var exportStream = new FileStream(bufferPath, FileMode.Open);
|
|
var exportedNodeSet = Export.UANodeSet.Read(exportStream);
|
|
|
|
var exportedNodeStates = new NodeStateCollection();
|
|
localContext.NamespaceUris = new NamespaceTable();
|
|
foreach (string namespaceUri in exportedNodeSet!.NamespaceUris!)
|
|
{
|
|
localContext.NamespaceUris.Append(namespaceUri);
|
|
}
|
|
exportedNodeSet.Import(localContext, exportedNodeStates);
|
|
|
|
Assert.That(exportedNodeSet.NamespaceUris, Has.Length.EqualTo(1));
|
|
Assert.That(exportedNodeSet.Items, Has.Length.EqualTo(2));
|
|
|
|
dataType1 = exportedNodeSet.Items[0] as Export.UADataType;
|
|
dataType2 = exportedNodeSet.Items[1] as Export.UADataType;
|
|
|
|
Assert.That(dataType1, Is.Not.Null);
|
|
Assert.That(dataType1.Definition!.Field, Has.Length.EqualTo(2));
|
|
Assert.IsEmpty(dataType1.Definition.Field[0].ArrayDimensions);
|
|
Assert.That(dataType1.Definition.IsUnion, Is.True);
|
|
|
|
Assert.That(dataType2, Is.Not.Null);
|
|
Assert.That(dataType2.Definition!.IsUnion, Is.False);
|
|
Assert.That(dataType2.Definition.Field, Has.Length.EqualTo(21));
|
|
Assert.That(dataType2.Definition.Field[15].ArrayDimensions, Is.EqualTo("2,3"));
|
|
Assert.That(dataType2.Definition.Field[5].MaxStringLength, Is.EqualTo(256));
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(bufferPath);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that parent-child references are correctly established after importing a NodeSet2.
|
|
/// </summary>
|
|
[TestCase("HasComponent")]
|
|
[TestCase("Organizes")]
|
|
public void ParentChildReferencesTest(string objectReferenceType)
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
|
|
string importBuffer =
|
|
@"<?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'
|
|
LastModified='2024-01-01T00:00:00.000Z'
|
|
xmlns='http://opcfoundation.org/UA/2011/03/UANodeSet.xsd'>
|
|
<NamespaceUris>
|
|
<Uri>http://opcfoundation.org/UA/Test</Uri>
|
|
</NamespaceUris>
|
|
<Aliases>
|
|
<Alias Alias='HasComponent'>i=47</Alias>
|
|
<Alias Alias='HasProperty'>i=46</Alias>
|
|
<Alias Alias='Organizes'>i=35</Alias>
|
|
<Alias Alias='HasTypeDefinition'>i=40</Alias>
|
|
</Aliases>
|
|
<UAObject NodeId='ns=1;i=1000' BrowseName='1:ParentObject'>
|
|
<DisplayName>ParentObject</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType='HasTypeDefinition'>i=58</Reference>
|
|
<Reference ReferenceType='HasProperty'>ns=1;i=1001</Reference>
|
|
<Reference ReferenceType='HasComponent'>ns=1;i=1002</Reference>
|
|
</References>
|
|
</UAObject>
|
|
<UAVariable DataType='i=12' ParentNodeId='ns=1;i=1000' NodeId='ns=1;i=1001' BrowseName='1:ChildProperty' ValueRank='-1'>
|
|
<DisplayName>ChildProperty</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType='HasTypeDefinition'>i=68</Reference>
|
|
<Reference ReferenceType='HasProperty' IsForward='false'>ns=1;i=1000</Reference>
|
|
</References>
|
|
</UAVariable>
|
|
<UAObject ParentNodeId='ns=1;i=1000' NodeId='ns=1;i=1002' BrowseName='1:ChildObject'>
|
|
<DisplayName>ChildObject</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType='HasTypeDefinition'>i=58</Reference>
|
|
<Reference ReferenceType='HasComponent' IsForward='false'>ns=1;i=1000</Reference>
|
|
</References>
|
|
</UAObject>
|
|
</UANodeSet>";
|
|
|
|
importBuffer = importBuffer.Replace(
|
|
"ReferenceType='HasComponent'",
|
|
$"ReferenceType='{objectReferenceType}'",
|
|
StringComparison.Ordinal);
|
|
using var importStream = new MemoryStream(Encoding.UTF8.GetBytes(importBuffer));
|
|
var importedNodeSet = Export.UANodeSet.Read(importStream);
|
|
|
|
var importedNodeStates = new NodeStateCollection();
|
|
var localContext = new SystemContext(telemetry) { NamespaceUris = new NamespaceTable() };
|
|
foreach (string namespaceUri in importedNodeSet!.NamespaceUris!)
|
|
{
|
|
localContext.NamespaceUris.Append(namespaceUri);
|
|
}
|
|
|
|
importedNodeSet.Import(localContext, importedNodeStates, linkParentChild: true);
|
|
|
|
// Verify that all nodes were imported
|
|
Assert.That(importedNodeStates, Has.Count.EqualTo(3));
|
|
|
|
// Find the parent object
|
|
BaseObjectState? parentObject = null;
|
|
BaseVariableState? childProperty = null;
|
|
BaseObjectState? childObject = null;
|
|
|
|
foreach (NodeState node in importedNodeStates)
|
|
{
|
|
if (node.BrowseName.Name == "ParentObject")
|
|
{
|
|
parentObject = (node as BaseObjectState)!;
|
|
}
|
|
else if (node.BrowseName.Name == "ChildProperty")
|
|
{
|
|
childProperty = (node as BaseVariableState)!;
|
|
}
|
|
else if (node.BrowseName.Name == "ChildObject")
|
|
{
|
|
childObject = (node as BaseObjectState)!;
|
|
}
|
|
}
|
|
|
|
Assert.That(parentObject, Is.Not.Null, "ParentObject should be imported");
|
|
Assert.That(childProperty, Is.Not.Null, "ChildProperty should be imported");
|
|
Assert.That(childObject, Is.Not.Null, "ChildObject should be imported");
|
|
|
|
// Verify parent-child relationships are established
|
|
Assert.That(childProperty.Parent, Is.EqualTo(parentObject), "ChildProperty's Parent should be set to ParentObject");
|
|
Assert.That(childObject.Parent, Is.EqualTo(parentObject), "ChildObject's Parent should be set to ParentObject");
|
|
|
|
// Verify GetChildren works
|
|
var children = new List<BaseInstanceState>();
|
|
parentObject.GetChildren(localContext, children);
|
|
|
|
Assert.That(children, Has.Count.EqualTo(2), "ParentObject should have 2 children");
|
|
Assert.That(children, Does.Contain(childProperty), "Children should contain ChildProperty");
|
|
Assert.That(children, Does.Contain(childObject), "Children should contain ChildObject");
|
|
NodeId objectReferenceId = objectReferenceType == "Organizes"
|
|
? ReferenceTypeIds.Organizes
|
|
: ReferenceTypeIds.HasComponent;
|
|
Assert.That(
|
|
BrowseReferenceTypes(localContext, childProperty, parentObject.NodeId),
|
|
Is.EquivalentTo(new[] { ReferenceTypeIds.HasProperty }));
|
|
Assert.That(
|
|
BrowseReferenceTypes(localContext, childObject, parentObject.NodeId),
|
|
Is.EquivalentTo(new[] { objectReferenceId }));
|
|
Assert.That(
|
|
BrowseReferenceTypes(localContext, parentObject, childProperty.NodeId),
|
|
Is.EquivalentTo(new[] { ReferenceTypeIds.HasProperty }));
|
|
Assert.That(
|
|
BrowseReferenceTypes(localContext, parentObject, childObject.NodeId),
|
|
Is.EquivalentTo(new[] { objectReferenceId }));
|
|
}
|
|
|
|
/// <summary>
|
|
/// A node whose declared parent is not part of the same import batch —
|
|
/// because it lives in another NodeSet or another NodeManager — must
|
|
/// not have that parent silently discarded. The caller needs it so it
|
|
/// can be wired as an external reference.
|
|
/// </summary>
|
|
[Test]
|
|
public void ImportKeepsAParentThatIsNotInTheSameBatch()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
|
|
const string importBuffer =
|
|
@"<?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'
|
|
LastModified='2024-01-01T00:00:00.000Z'
|
|
xmlns='http://opcfoundation.org/UA/2011/03/UANodeSet.xsd'>
|
|
<NamespaceUris>
|
|
<Uri>http://opcfoundation.org/UA/Test</Uri>
|
|
<Uri>http://opcfoundation.org/UA/Elsewhere</Uri>
|
|
</NamespaceUris>
|
|
<Aliases>
|
|
<Alias Alias='HasComponent'>i=47</Alias>
|
|
<Alias Alias='HasTypeDefinition'>i=40</Alias>
|
|
</Aliases>
|
|
<UAObject ParentNodeId='ns=2;i=5001' NodeId='ns=1;i=1001' BrowseName='1:Orphan'>
|
|
<DisplayName>Orphan</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType='HasTypeDefinition'>i=58</Reference>
|
|
<Reference ReferenceType='HasComponent' IsForward='false'>ns=2;i=5001</Reference>
|
|
</References>
|
|
</UAObject>
|
|
</UANodeSet>";
|
|
|
|
using var importStream = new MemoryStream(Encoding.UTF8.GetBytes(importBuffer));
|
|
Export.UANodeSet importedNodeSet = Export.UANodeSet.Read(importStream)!;
|
|
|
|
var importedNodeStates = new NodeStateCollection();
|
|
var localContext = new SystemContext(telemetry) { NamespaceUris = new NamespaceTable() };
|
|
foreach (string namespaceUri in importedNodeSet!.NamespaceUris!)
|
|
{
|
|
localContext.NamespaceUris.Append(namespaceUri);
|
|
}
|
|
|
|
importedNodeSet.Import(localContext, importedNodeStates, linkParentChild: true);
|
|
|
|
Assert.That(importedNodeStates, Has.Count.EqualTo(1));
|
|
var orphan = (BaseInstanceState)importedNodeStates[0];
|
|
|
|
Assert.That(orphan.Parent, Is.Null,
|
|
"The parent is not in this batch, so it cannot be linked as an in-memory parent.");
|
|
|
|
NodeId expectedParent = new(5001u, (ushort)localContext.NamespaceUris.GetIndex(
|
|
"http://opcfoundation.org/UA/Elsewhere"));
|
|
|
|
Assert.Multiple(() => {
|
|
Assert.That(
|
|
Export.UANodeSet.TryGetUnresolvedParentNodeId(orphan, out NodeId unresolvedParent),
|
|
Is.True,
|
|
"The declared parent must survive the import so a caller can wire it " +
|
|
"as an external reference.");
|
|
|
|
Assert.That(unresolvedParent, Is.EqualTo(expectedParent));
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// The record must be precise: a parent that was linked normally is not
|
|
/// an unresolved parent, or a caller would wire references that already
|
|
/// exist.
|
|
/// </summary>
|
|
[Test]
|
|
public void ImportRecordsNoUnresolvedParentWhenTheParentIsInTheBatch()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
|
|
const string importBuffer =
|
|
@"<?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'
|
|
LastModified='2024-01-01T00:00:00.000Z'
|
|
xmlns='http://opcfoundation.org/UA/2011/03/UANodeSet.xsd'>
|
|
<NamespaceUris>
|
|
<Uri>http://opcfoundation.org/UA/Test</Uri>
|
|
</NamespaceUris>
|
|
<Aliases>
|
|
<Alias Alias='HasComponent'>i=47</Alias>
|
|
<Alias Alias='HasTypeDefinition'>i=40</Alias>
|
|
</Aliases>
|
|
<UAObject NodeId='ns=1;i=1000' BrowseName='1:ParentObject'>
|
|
<DisplayName>ParentObject</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType='HasTypeDefinition'>i=58</Reference>
|
|
</References>
|
|
</UAObject>
|
|
<UAObject ParentNodeId='ns=1;i=1000' NodeId='ns=1;i=1002' BrowseName='1:ChildObject'>
|
|
<DisplayName>ChildObject</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType='HasTypeDefinition'>i=58</Reference>
|
|
<Reference ReferenceType='HasComponent' IsForward='false'>ns=1;i=1000</Reference>
|
|
</References>
|
|
</UAObject>
|
|
</UANodeSet>";
|
|
|
|
using var importStream = new MemoryStream(Encoding.UTF8.GetBytes(importBuffer));
|
|
Export.UANodeSet importedNodeSet = Export.UANodeSet.Read(importStream)!;
|
|
|
|
var importedNodeStates = new NodeStateCollection();
|
|
var localContext = new SystemContext(telemetry) { NamespaceUris = new NamespaceTable() };
|
|
foreach (string namespaceUri in importedNodeSet!.NamespaceUris!)
|
|
{
|
|
localContext.NamespaceUris.Append(namespaceUri);
|
|
}
|
|
|
|
importedNodeSet.Import(localContext, importedNodeStates, linkParentChild: true);
|
|
|
|
foreach (NodeState node in importedNodeStates)
|
|
{
|
|
Assert.Multiple(() => {
|
|
Assert.That(
|
|
Export.UANodeSet.TryGetUnresolvedParentNodeId(node, out NodeId unresolvedParent),
|
|
Is.False,
|
|
$"'{node.BrowseName.Name}' resolved inside the batch, so nothing is unresolved.");
|
|
|
|
Assert.That(unresolvedParent.IsNull, Is.True);
|
|
});
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Imported Method argument Variables must be adopted through
|
|
/// <see cref="MethodState"/>'s declared children so Call validation
|
|
/// sees the authored argument definitions.
|
|
/// </summary>
|
|
[Test]
|
|
public void ImportBindsMethodArgumentProperties(
|
|
[Values(true, false)] bool includeParentHints,
|
|
[Values(true, false)] bool parentFirst,
|
|
[Values("Forward", "Inverse", "Both")] string referenceDirection,
|
|
[Values(true, false)] bool useNamespaceUriTargets)
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
|
|
const string importBuffer =
|
|
@"<?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>urn:test:method-arguments</Uri>
|
|
</NamespaceUris>
|
|
<Aliases>
|
|
<Alias Alias='Argument'>i=296</Alias>
|
|
<Alias Alias='HasComponent'>i=47</Alias>
|
|
<Alias Alias='HasProperty'>i=46</Alias>
|
|
<Alias Alias='HasTypeDefinition'>i=40</Alias>
|
|
</Aliases>
|
|
<UAVariable NodeId='ns=1;i=1003' BrowseName='1:InputArguments'
|
|
ParentNodeId='ns=1;i=1000' DataType='i=12'
|
|
ValueRank='-1'>
|
|
<DisplayName>Custom InputArguments</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType='HasTypeDefinition'>i=68</Reference>
|
|
<Reference ReferenceType='HasProperty' IsForward='false'>ns=1;i=1000</Reference>
|
|
</References>
|
|
</UAVariable>
|
|
<UAVariable NodeId='ns=1;i=1001' BrowseName='InputArguments'
|
|
ParentNodeId='ns=1;i=1000' DataType='Argument'
|
|
ValueRank='1' ArrayDimensions='1'
|
|
UserWriteMask='1' AccessRestrictions='1'
|
|
DesignToolOnly='true'>
|
|
<DisplayName>Imported inputs</DisplayName>
|
|
<Description>Input metadata</Description>
|
|
<Category>Method metadata</Category>
|
|
<Documentation>https://example.org/input-arguments</Documentation>
|
|
<References>
|
|
<Reference ReferenceType='HasTypeDefinition'>i=68</Reference>
|
|
<Reference ReferenceType='HasProperty' IsForward='false'>ns=1;i=1000</Reference>
|
|
</References>
|
|
<RolePermissions>
|
|
<RolePermission Permissions='1'>i=15644</RolePermission>
|
|
</RolePermissions>
|
|
<Value>
|
|
<uax:ListOfExtensionObject>
|
|
<uax:ExtensionObject>
|
|
<uax:TypeId>
|
|
<uax:Identifier>i=297</uax:Identifier>
|
|
</uax:TypeId>
|
|
<uax:Body>
|
|
<uax:Argument>
|
|
<uax:Name>revision</uax:Name>
|
|
<uax:DataType>
|
|
<uax:Identifier>i=12</uax:Identifier>
|
|
</uax:DataType>
|
|
<uax:ValueRank>-1</uax:ValueRank>
|
|
<uax:ArrayDimensions />
|
|
</uax:Argument>
|
|
</uax:Body>
|
|
</uax:ExtensionObject>
|
|
</uax:ListOfExtensionObject>
|
|
</Value>
|
|
</UAVariable>
|
|
<UAVariable NodeId='ns=1;i=1002' BrowseName='OutputArguments'
|
|
ParentNodeId='ns=1;i=1000' DataType='Argument'
|
|
ValueRank='1' ArrayDimensions='1'>
|
|
<DisplayName>Imported outputs</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType='HasTypeDefinition'>i=68</Reference>
|
|
<Reference ReferenceType='HasProperty' IsForward='false'>ns=1;i=1000</Reference>
|
|
</References>
|
|
<Value>
|
|
<uax:ListOfExtensionObject>
|
|
<uax:ExtensionObject>
|
|
<uax:TypeId>
|
|
<uax:Identifier>i=297</uax:Identifier>
|
|
</uax:TypeId>
|
|
<uax:Body>
|
|
<uax:Argument>
|
|
<uax:Name>accepted</uax:Name>
|
|
<uax:DataType>
|
|
<uax:Identifier>i=1</uax:Identifier>
|
|
</uax:DataType>
|
|
<uax:ValueRank>-1</uax:ValueRank>
|
|
<uax:ArrayDimensions />
|
|
</uax:Argument>
|
|
</uax:Body>
|
|
</uax:ExtensionObject>
|
|
</uax:ListOfExtensionObject>
|
|
</Value>
|
|
</UAVariable>
|
|
<UAMethod NodeId='ns=1;i=1000' BrowseName='1:Load'>
|
|
<DisplayName>Load</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType='HasProperty'>ns=1;i=1003</Reference>
|
|
<Reference ReferenceType='HasProperty'>ns=1;i=1001</Reference>
|
|
<Reference ReferenceType='HasProperty'>ns=1;i=1002</Reference>
|
|
</References>
|
|
</UAMethod>
|
|
</UANodeSet>";
|
|
|
|
string xml = useNamespaceUriTargets
|
|
? importBuffer.Replace(">ns=1;", ">nsu=urn:test:method-arguments;", StringComparison.Ordinal)
|
|
: importBuffer;
|
|
using var importStream = new MemoryStream(Encoding.UTF8.GetBytes(xml));
|
|
Export.UANodeSet importedNodeSet = Export.UANodeSet.Read(importStream)!;
|
|
|
|
foreach (Export.UAVariable variable in importedNodeSet!.Items!.OfType<Export.UAVariable>())
|
|
{
|
|
if (variable.BrowseName != BrowseNames.InputArguments &&
|
|
variable.BrowseName != BrowseNames.OutputArguments)
|
|
{
|
|
continue;
|
|
}
|
|
if (!includeParentHints)
|
|
{
|
|
variable.ParentNodeId = null;
|
|
}
|
|
if (referenceDirection == "Forward")
|
|
{
|
|
variable.References = variable.References!
|
|
.Where(reference => reference.ReferenceType != "HasProperty")
|
|
.ToArray();
|
|
}
|
|
}
|
|
if (referenceDirection == "Inverse")
|
|
{
|
|
Export.UAMethod methodNode = importedNodeSet.Items!.OfType<Export.UAMethod>().Single();
|
|
methodNode.References = methodNode.References!
|
|
.Where(reference => reference.Value == "ns=1;i=1003")
|
|
.ToArray();
|
|
}
|
|
if (parentFirst)
|
|
{
|
|
importedNodeSet.Items = importedNodeSet.Items!
|
|
.OrderBy(node => node is Export.UAMethod ? 0 : 1)
|
|
.ToArray();
|
|
}
|
|
|
|
var importedNodeStates = new NodeStateCollection();
|
|
var localContext = new SystemContext(telemetry) { NamespaceUris = new NamespaceTable() };
|
|
localContext.NamespaceUris.Append("urn:test:preexisting");
|
|
foreach (string namespaceUri in importedNodeSet.NamespaceUris!)
|
|
{
|
|
localContext.NamespaceUris.Append(namespaceUri);
|
|
}
|
|
|
|
localContext.ServerUris = new StringTable();
|
|
localContext.EncodeableFactory = EncodeableFactory.Create();
|
|
|
|
importedNodeSet.Import(localContext, importedNodeStates, linkParentChild: true);
|
|
|
|
MethodState method = importedNodeStates.OfType<MethodState>().Single();
|
|
ushort namespaceIndex = (ushort)localContext.NamespaceUris.GetIndex(
|
|
"urn:test:method-arguments");
|
|
var inputId = new NodeId(1001u, namespaceIndex);
|
|
var outputId = new NodeId(1002u, namespaceIndex);
|
|
var customInputId = new NodeId(1003u, namespaceIndex);
|
|
var children = new List<BaseInstanceState>();
|
|
method.GetChildren(localContext, children);
|
|
|
|
Assert.That(method.InputArguments, Is.Not.Null);
|
|
Assert.That(method.OutputArguments, Is.Not.Null);
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(method.InputArguments!.NodeId, Is.EqualTo(inputId));
|
|
Assert.That(method.OutputArguments!.NodeId, Is.EqualTo(outputId));
|
|
Assert.That(
|
|
method.InputArguments.ReferenceTypeId,
|
|
Is.EqualTo(ReferenceTypeIds.HasProperty));
|
|
Assert.That(method.InputArguments.DisplayName.Text, Is.EqualTo("Imported inputs"));
|
|
Assert.That(method.InputArguments.Description.Text, Is.EqualTo("Input metadata"));
|
|
Assert.That(
|
|
method.InputArguments.UserWriteMask,
|
|
Is.EqualTo(AttributeWriteMask.AccessLevel));
|
|
Assert.That(
|
|
method.InputArguments.AccessRestrictions,
|
|
Is.EqualTo(AccessRestrictionType.SigningRequired));
|
|
Assert.That(method.InputArguments.DesignToolOnly, Is.True);
|
|
Assert.That(
|
|
method.InputArguments.NodeSetDocumentation,
|
|
Is.EqualTo("https://example.org/input-arguments"));
|
|
Assert.That(
|
|
method.InputArguments.Categories,
|
|
Has.Count.EqualTo(1));
|
|
Assert.That(
|
|
method.InputArguments.Categories,
|
|
Has.Member("Method metadata"));
|
|
Assert.That(method.InputArguments.RolePermissions, Has.Count.EqualTo(1));
|
|
Assert.That(
|
|
method.InputArguments.RolePermissions[0].RoleId,
|
|
Is.EqualTo(ObjectIds.WellKnownRole_Anonymous));
|
|
Assert.That(
|
|
method.InputArguments.RolePermissions[0].Permissions,
|
|
Is.EqualTo((uint)PermissionType.Browse));
|
|
Assert.That(method.InputArguments.Value, Has.Count.EqualTo(1));
|
|
Assert.That(method.InputArguments.Value[0].Name, Is.EqualTo("revision"));
|
|
Assert.That(method.OutputArguments.Value, Has.Count.EqualTo(1));
|
|
Assert.That(method.OutputArguments.Value[0].Name, Is.EqualTo("accepted"));
|
|
Assert.That(
|
|
importedNodeStates.Single(node => node.NodeId == inputId),
|
|
Is.SameAs(method.InputArguments));
|
|
Assert.That(
|
|
importedNodeStates.Single(node => node.NodeId == outputId),
|
|
Is.SameAs(method.OutputArguments));
|
|
Assert.That(children, Has.Count.EqualTo(3));
|
|
Assert.That(children, Does.Contain(method.InputArguments));
|
|
Assert.That(children, Does.Contain(method.OutputArguments));
|
|
Assert.That(
|
|
children.Single(child => child.NodeId == customInputId),
|
|
Is.TypeOf<PropertyState>());
|
|
});
|
|
|
|
importedNodeSet.Items = [];
|
|
importedNodeSet.Import(localContext, importedNodeStates, linkParentChild: true);
|
|
children.Clear();
|
|
method.GetChildren(localContext, children);
|
|
Assert.That(children, Has.Count.EqualTo(3));
|
|
Assert.That(
|
|
importedNodeStates.Single(node => node.NodeId == inputId),
|
|
Is.SameAs(method.InputArguments));
|
|
Assert.That(
|
|
importedNodeStates.Single(node => node.NodeId == outputId),
|
|
Is.SameAs(method.OutputArguments));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Linking must preserve the importer's previous behavior for unnamed
|
|
/// children and sibling BrowseName collisions.
|
|
/// </summary>
|
|
[Test]
|
|
public void ImportPreservesUnnamedAndDuplicateBrowseNameChildren()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
|
|
const string importBuffer =
|
|
@"<?xml version='1.0' encoding='utf-8'?>
|
|
<UANodeSet xmlns='http://opcfoundation.org/UA/2011/03/UANodeSet.xsd'>
|
|
<NamespaceUris>
|
|
<Uri>urn:test:duplicate-children</Uri>
|
|
</NamespaceUris>
|
|
<Aliases>
|
|
<Alias Alias='HasComponent'>i=47</Alias>
|
|
<Alias Alias='HasTypeDefinition'>i=40</Alias>
|
|
</Aliases>
|
|
<UAObject NodeId='ns=1;i=2000' BrowseName='1:Parent'>
|
|
<DisplayName>Parent</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType='HasTypeDefinition'>i=58</Reference>
|
|
</References>
|
|
</UAObject>
|
|
<UAObject NodeId='ns=1;i=2001' BrowseName='1:Duplicate'
|
|
ParentNodeId='ns=1;i=2000'>
|
|
<DisplayName>First</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType='HasComponent' IsForward='false'>ns=1;i=2000</Reference>
|
|
</References>
|
|
</UAObject>
|
|
<UAObject NodeId='ns=1;i=2002' BrowseName='1:Duplicate'
|
|
ParentNodeId='ns=1;i=2000'>
|
|
<DisplayName>Second</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType='HasComponent' IsForward='false'>ns=1;i=2000</Reference>
|
|
</References>
|
|
</UAObject>
|
|
<UAObject NodeId='ns=1;i=2003' ParentNodeId='ns=1;i=2000'>
|
|
<DisplayName>Unnamed</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType='HasComponent' IsForward='false'>ns=1;i=2000</Reference>
|
|
</References>
|
|
</UAObject>
|
|
</UANodeSet>";
|
|
|
|
using var importStream = new MemoryStream(Encoding.UTF8.GetBytes(importBuffer));
|
|
Export.UANodeSet importedNodeSet = Export.UANodeSet.Read(importStream)!;
|
|
var importedNodeStates = new NodeStateCollection();
|
|
var localContext = new SystemContext(telemetry) { NamespaceUris = new NamespaceTable() };
|
|
foreach (string namespaceUri in importedNodeSet!.NamespaceUris!)
|
|
{
|
|
localContext.NamespaceUris.Append(namespaceUri);
|
|
}
|
|
|
|
importedNodeSet.Import(localContext, importedNodeStates, linkParentChild: true);
|
|
|
|
BaseObjectState parent = importedNodeStates
|
|
.OfType<BaseObjectState>()
|
|
.Single(node => node.BrowseName.Name == "Parent");
|
|
var children = new List<BaseInstanceState>();
|
|
parent.GetChildren(localContext, children);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(children, Has.Count.EqualTo(3));
|
|
Assert.That(
|
|
children.Count(child => child.BrowseName.Name == "Duplicate"),
|
|
Is.EqualTo(2));
|
|
Assert.That(
|
|
children.Count(child => child.BrowseName.IsNull),
|
|
Is.EqualTo(1));
|
|
Assert.That(
|
|
importedNodeStates.Count(node =>
|
|
node.NodeId.NamespaceIndex == parent.NodeId.NamespaceIndex),
|
|
Is.EqualTo(4));
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public void ImportBindsMethodArgumentsAcrossAccumulatedImports(
|
|
[Values(true, false)] bool propertyFirst,
|
|
[Values(true, false)] bool includeParentHint)
|
|
{
|
|
Export.UANodeSet nodeSet = CreateMethodReferenceNodeSet();
|
|
Export.UANode[] items = nodeSet.Items!;
|
|
Export.UAVariable property = items!.OfType<Export.UAVariable>().Single();
|
|
property.ParentNodeId = includeParentHint ? "ns=1;i=1000" : null;
|
|
var context = new SystemContext(NUnitTelemetryContext.Create())
|
|
{
|
|
NamespaceUris = new NamespaceTable(),
|
|
EncodeableFactory = EncodeableFactory.Create()
|
|
};
|
|
var nodes = new NodeStateCollection();
|
|
nodeSet.Items = items.Where(node => (node is Export.UAVariable) == propertyFirst).ToArray();
|
|
nodeSet.Import(context, nodes, linkParentChild: true);
|
|
nodeSet.Items = items.Where(node => (node is Export.UAVariable) != propertyFirst).ToArray();
|
|
nodeSet.Import(context, nodes, linkParentChild: true);
|
|
|
|
MethodState method = nodes.OfType<MethodState>().Single(node => node.BrowseName.Name == "Load");
|
|
Assert.That(method.InputArguments, Is.Not.Null);
|
|
NodeState input = nodes.Single(node => node.BrowseName.Name == BrowseNames.InputArguments);
|
|
Assert.That(input, Is.SameAs(method.InputArguments));
|
|
Assert.That(method.InputArguments.Parent, Is.SameAs(method));
|
|
Assert.That(Export.UANodeSet.TryGetUnresolvedParentNodeId(input, out _), Is.False);
|
|
|
|
nodeSet.Items = [];
|
|
nodeSet.Import(context, nodes, linkParentChild: true);
|
|
var children = new List<BaseInstanceState>();
|
|
method.GetChildren(context, children);
|
|
Assert.That(children, Has.Count.EqualTo(1));
|
|
Assert.That(children[0], Is.SameAs(input));
|
|
Assert.That(nodes, Has.Count.EqualTo(3));
|
|
}
|
|
|
|
[TestCase("CustomName")]
|
|
[TestCase("DataType")]
|
|
[TestCase("TypeDefinition")]
|
|
[TestCase("Rank")]
|
|
[TestCase("ReferenceType")]
|
|
[TestCase("Ambiguous")]
|
|
[TestCase("AmbiguousWithHint")]
|
|
[TestCase("ConflictingHint")]
|
|
[TestCase("UnavailableHint")]
|
|
public void ImportPreservesInvalidMethodArgumentDeclarations(string scenario)
|
|
{
|
|
Export.UANodeSet nodeSet = CreateMethodReferenceNodeSet();
|
|
Export.UAVariable property = nodeSet.Items!.OfType<Export.UAVariable>().Single();
|
|
switch (scenario)
|
|
{
|
|
case "CustomName":
|
|
property.BrowseName = "1:InputArguments";
|
|
break;
|
|
case "DataType":
|
|
property.DataType = "i=12";
|
|
break;
|
|
case "TypeDefinition":
|
|
property.References![0].Value = "i=63";
|
|
break;
|
|
case "Rank":
|
|
property.ValueRank = -1;
|
|
break;
|
|
case "ReferenceType":
|
|
property.References![1].ReferenceType = "i=47";
|
|
break;
|
|
case "Ambiguous":
|
|
case "AmbiguousWithHint":
|
|
property.References =
|
|
[
|
|
.. property.References!,
|
|
new Export.Reference
|
|
{
|
|
ReferenceType = "i=46",
|
|
IsForward = false,
|
|
Value = "ns=1;i=1002"
|
|
}
|
|
];
|
|
if (scenario == "AmbiguousWithHint")
|
|
{
|
|
property.ParentNodeId = "ns=1;i=1000";
|
|
}
|
|
break;
|
|
case "ConflictingHint":
|
|
property.ParentNodeId = "ns=1;i=1002";
|
|
break;
|
|
case "UnavailableHint":
|
|
property.ParentNodeId = "ns=1;i=1999";
|
|
break;
|
|
default:
|
|
throw new ArgumentOutOfRangeException(nameof(scenario));
|
|
}
|
|
|
|
var context = new SystemContext(NUnitTelemetryContext.Create())
|
|
{
|
|
NamespaceUris = new NamespaceTable()
|
|
};
|
|
var nodes = new NodeStateCollection();
|
|
nodeSet.Import(context, nodes, linkParentChild: true);
|
|
|
|
Assert.That(nodes.OfType<MethodState>().Select(method => method.InputArguments), Is.All.Null);
|
|
BaseVariableState imported = nodes.OfType<BaseVariableState>().Single();
|
|
Assert.That(
|
|
imported.TypeDefinitionId,
|
|
Is.EqualTo(NodeId.Parse(property.References![0].Value!)));
|
|
Assert.That(imported.DataType, Is.EqualTo(NodeId.Parse(property.DataType!)));
|
|
Assert.That(imported.ValueRank, Is.EqualTo(property.ValueRank));
|
|
var references = new List<IReference>();
|
|
imported.GetReferences(context, references);
|
|
Assert.That(
|
|
references.Select(reference =>
|
|
(reference.ReferenceTypeId, reference.IsInverse, reference.TargetId)),
|
|
Is.EquivalentTo(property.References
|
|
.Where(reference => reference.ReferenceType != "i=40")
|
|
.Select(reference =>
|
|
(NodeId.Parse(reference.ReferenceType!),
|
|
!reference.IsForward,
|
|
ExpandedNodeId.Parse(reference.Value!)))));
|
|
Assert.That(nodes, Has.Count.EqualTo(3));
|
|
if (scenario == "UnavailableHint")
|
|
{
|
|
Assert.That(
|
|
Export.UANodeSet.TryGetUnresolvedParentNodeId(
|
|
nodes.OfType<BaseVariableState>().Single(),
|
|
out NodeId unresolved),
|
|
Is.True);
|
|
Assert.That(unresolved, Is.EqualTo(new NodeId(1999u, 1)));
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void ImportDoesNotInferMethodArgumentsWhenLinkingIsDisabled(
|
|
[Values(true, false)] bool useNamespaceUriTargets)
|
|
{
|
|
Export.UANodeSet nodeSet = CreateMethodReferenceNodeSet();
|
|
if (useNamespaceUriTargets)
|
|
{
|
|
nodeSet.Items!.OfType<Export.UAVariable>().Single().References![1].Value =
|
|
"nsu=urn:test:method-reference-parent;i=1000";
|
|
}
|
|
var context = new SystemContext(NUnitTelemetryContext.Create())
|
|
{
|
|
NamespaceUris = new NamespaceTable()
|
|
};
|
|
var nodes = new NodeStateCollection();
|
|
nodeSet.Import(context, nodes);
|
|
|
|
Assert.That(nodes.OfType<MethodState>().Select(method => method.InputArguments), Is.All.Null);
|
|
Assert.That(nodes.OfType<BaseVariableState>().Single().Parent, Is.Null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that parent-child references are NOT established by default (backward compatibility).
|
|
/// </summary>
|
|
[Test]
|
|
public void ParentChildReferencesDefaultBehaviorTest()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
|
|
const string importBuffer =
|
|
@"<?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'
|
|
LastModified='2024-01-01T00:00:00.000Z'
|
|
xmlns='http://opcfoundation.org/UA/2011/03/UANodeSet.xsd'>
|
|
<NamespaceUris>
|
|
<Uri>http://opcfoundation.org/UA/Test</Uri>
|
|
</NamespaceUris>
|
|
<Aliases>
|
|
<Alias Alias='HasComponent'>i=47</Alias>
|
|
<Alias Alias='HasTypeDefinition'>i=40</Alias>
|
|
</Aliases>
|
|
<UAObject NodeId='ns=1;i=1000' BrowseName='1:ParentObject'>
|
|
<DisplayName>ParentObject</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType='HasTypeDefinition'>i=58</Reference>
|
|
</References>
|
|
</UAObject>
|
|
<UAObject ParentNodeId='ns=1;i=1000' NodeId='ns=1;i=1002' BrowseName='1:ChildObject'>
|
|
<DisplayName>ChildObject</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType='HasTypeDefinition'>i=58</Reference>
|
|
<Reference ReferenceType='HasComponent' IsForward='false'>ns=1;i=1000</Reference>
|
|
</References>
|
|
</UAObject>
|
|
</UANodeSet>";
|
|
|
|
using var importStream = new MemoryStream(Encoding.UTF8.GetBytes(importBuffer));
|
|
var importedNodeSet = Export.UANodeSet.Read(importStream);
|
|
|
|
var importedNodeStates = new NodeStateCollection();
|
|
var localContext = new SystemContext(telemetry) { NamespaceUris = new NamespaceTable() };
|
|
foreach (string namespaceUri in importedNodeSet!.NamespaceUris!)
|
|
{
|
|
localContext.NamespaceUris.Append(namespaceUri);
|
|
}
|
|
|
|
// Import without linkParentChild parameter (default is false)
|
|
importedNodeSet.Import(localContext, importedNodeStates);
|
|
|
|
// Verify that all nodes were imported
|
|
Assert.That(importedNodeStates, Has.Count.EqualTo(2));
|
|
|
|
// Find the parent object
|
|
BaseObjectState? parentObject = null;
|
|
BaseObjectState? childObject = null;
|
|
|
|
foreach (NodeState node in importedNodeStates)
|
|
{
|
|
if (node.BrowseName.Name == "ParentObject")
|
|
{
|
|
parentObject = (node as BaseObjectState)!;
|
|
}
|
|
else if (node.BrowseName.Name == "ChildObject")
|
|
{
|
|
childObject = (node as BaseObjectState)!;
|
|
}
|
|
}
|
|
|
|
Assert.That(parentObject, Is.Not.Null, "ParentObject should be imported");
|
|
Assert.That(childObject, Is.Not.Null, "ChildObject should be imported");
|
|
|
|
// Verify parent-child relationships are NOT established (backward compatibility)
|
|
Assert.That(childObject.Parent, Is.Null, "ChildObject's Parent should be null by default");
|
|
|
|
// Verify GetChildren returns empty (backward compatibility)
|
|
var children = new List<BaseInstanceState>();
|
|
parentObject.GetChildren(localContext, children);
|
|
|
|
Assert.That(children, Is.Empty, "ParentObject should have 0 children by default (backward compatibility)");
|
|
}
|
|
|
|
private static Export.UANodeSet CreateMethodReferenceNodeSet()
|
|
{
|
|
const string xml = """
|
|
<UANodeSet xmlns="http://opcfoundation.org/UA/2011/03/UANodeSet.xsd">
|
|
<NamespaceUris><Uri>urn:test:method-reference-parent</Uri></NamespaceUris>
|
|
<UAMethod NodeId="ns=1;i=1000" BrowseName="1:Load">
|
|
<DisplayName>Load</DisplayName>
|
|
</UAMethod>
|
|
<UAMethod NodeId="ns=1;i=1002" BrowseName="1:Other">
|
|
<DisplayName>Other</DisplayName>
|
|
</UAMethod>
|
|
<UAVariable NodeId="ns=1;i=1001" BrowseName="InputArguments" DataType="i=296" ValueRank="1">
|
|
<DisplayName>InputArguments</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="i=40">i=68</Reference>
|
|
<Reference ReferenceType="i=46" IsForward="false">ns=1;i=1000</Reference>
|
|
</References>
|
|
</UAVariable>
|
|
</UANodeSet>
|
|
""";
|
|
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(xml));
|
|
return Export.UANodeSet.Read(stream)!;
|
|
}
|
|
|
|
private static List<NodeId> BrowseReferenceTypes(
|
|
ISystemContext context,
|
|
NodeState source,
|
|
NodeId targetId)
|
|
{
|
|
using INodeBrowser browser = source.CreateBrowser(
|
|
context,
|
|
null,
|
|
NodeId.Null,
|
|
false,
|
|
BrowseDirection.Both,
|
|
QualifiedName.Null,
|
|
null,
|
|
true);
|
|
var referenceTypes = new List<NodeId>();
|
|
IReference reference;
|
|
while ((reference = browser.Next()!) is not null)
|
|
{
|
|
if (reference.TargetId == targetId)
|
|
{
|
|
referenceTypes.Add(reference.ReferenceTypeId);
|
|
}
|
|
}
|
|
return referenceTypes;
|
|
}
|
|
}
|
|
}
|