Files
UA-.NETStandard/tests/Opc.Ua.Server.Tests/NodeManager/AsyncCustomNodeManagerNodeManagementTests.cs
Marc Schier 0374e19121 Add MachineTool, WMTP, GMS and CranesHoists companions (#4765)
# Description

_Describe the changes here to communicate to the maintainers why they
should accept this pull request. By default - this will become the
Commit message after merging and thus define history._

Add provider-backed MachineTool, bundled WMTP, GMS and CranesHoists
companions using the existing Machinery/MachineVision fluent, DI and
typed-client approach. **The PR is open/non-draft by user choice.
Current head `ecc4a7d6ca3e8004cf28ab54da605da302d026bf` is mergeable and
has 108 successful checks, six intentionally skipped checks, zero
failures and zero pending checks. All five primary workflows completed
successfully at that exact head.**

| Family | Released specification/model | Packaging |
| --- | --- | --- |
| MachineTool | OPC 40501-1, 1.02.0 | Model, Server, Client and MCP;
normal root versions |
| WMTP | OPC 40505, 1.0.0 | Bundled in MachineTool; distinct CLR/wire
namespace and independent WMTP-only hosting |
| GMS | OPC 40210 reference 1.0.1, normative model 1.0.0 | Model,
Server, Client and MCP; normal root versions |
| CranesHoists | OPC 40020-1, 1.00 | Model, Server, Client and MCP; all
four plus Debug variants forced preview |

There are **12 new packable projects**. No separate WMTP
package/profile/guide, root version bump, tag, release or package
publication is included. Mandatory instance structure is the default;
selected optional features require explicit providers. The
implementation reuses Machinery results/jobs/files/ProcessValues and
Robotics topology, including retained MachineTool Production, defined
GMS jobs/parts, modern ISA95/Machinery jobs, shared-owner hosting and
isolated instances alongside MachineVision.

Runtime and integration fixes preserve raw quality/timestamps, coherent
acquisition, namespace ownership, accepted-configuration state,
borrowed-resource lifetime, session/node/file cleanup, terminal-value
publication, event propagation and endpoint-aware capability
publication. Firmware/licence transfer means provider acceptance, not
installation/activation. Cancellation does not silently abort another
caller's operation. The later upstream MCP expansion is integrated
without losing either family set or its default-off privileged/live
execution policy.

### Conformance and scope

Canonical model inputs are pinned to UA-Nodeset revision
`96a08945425d0295edb3607d2a38f6841ef44046`.

**GMS upstream exception:** [Annex
A.1](https://reference.opcfoundation.org/specs/OPC-40210/annex-a) makes
the released NodeSet normative, but §12.3.2.7 requires undefined
`GMS_ProductionStatisticsType`. Inherited statistics and defined
jobs/parts are implemented without inventing a standard type/NodeId; the
complete GMS Production facet is withheld.

The requirement ledger records **102 positively evidenced CU rows, 96
directly negative-tested rows and six explicit negative-evidence
limitations**. All 648 test-class/name/TRX links were independently
resolved to passing cases. The six limitations concern Stacklight,
retained legacy PowerOnDuration, Machinery PowerOnDuration, WorkingUnit,
Obligation and MaintenanceMode. They are not claims that every
invalid-input or hardware-control behavior was tested. The undefined GMS
requirement is outside the 102 defined CUs. No external certification
claim is made.

Retained baseline complete-assembly line coverage, with generated code
retained and no source exclusions:

| Family | Model | Server | Client |
| --- | ---: | ---: | ---: |
| MachineTool/WMTP | 83.80% | 91.98% | 84.74% |
| GMS | 95.51% | 90.63% | 88.38% |
| CranesHoists | 92.76% | 94.45% | 92.24% |

These nine measurements predate the later focused review fixes and
merges; they are not relabelled as new `ecc4a7d6c` percentages. The
current head's separate CI code-coverage check succeeded. No new
exclusions or threshold relaxations were introduced.

## Related Issues

- Fixes
[#4764](https://github.com/OPCFoundation/UA-.NETStandard/issues/4764),
the implementation/design and acceptance tracker.

## Checklist

_Put an `x` in the boxes that apply. You can complete these step by step
after opening the PR._

- [ ] 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.
- [x] 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.
- [ ] 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.
- [x] I have addressed **all** PR feedback received.
2026-10-15 15:15:46 +02:00

2025 lines
92 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/
* ======================================================================*/
// CA2000: test code; disposables are ownership-transferred to test fixtures or are short-lived,
// making CA2000 noisy without a real leak risk. Disabled file-level for the suite.
#pragma warning disable CA2000
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Moq;
using NUnit.Framework;
using ModelChangeVerbs = Opc.Ua.Server.NodeManager.ModelChangeVerbs;
namespace Opc.Ua.Server.Tests.NodeManager
{
/// <summary>
/// Unit tests for the <see cref="INodeManagementAsyncNodeManager"/>
/// implementation on <see cref="AsyncCustomNodeManager"/>.
/// </summary>
[TestFixture]
[Category("NodeManager")]
[Category("NodeManagement")]
[Parallelizable(ParallelScope.All)]
public class AsyncCustomNodeManagerNodeManagementTests
{
private const string TestNamespaceUri = "http://test.org/UA/NodeManagement/";
[TestCase("unknownDataType")]
[TestCase("nonDataType")]
[TestCase("wrongValue")]
[TestCase("arrayRank")]
[TestCase("invalidRank")]
[TestCase("negativeMinimum")]
[TestCase("nanMinimum")]
[TestCase("infiniteMinimum")]
[TestCase("scalarDimensions")]
[TestCase("rankDimensions")]
public async Task AddVariableRejectsInconsistentAttributes(string invalidAttribute)
{
using Harness h = CreateHarness(allowNodeManagement: true);
var types = (TypeTable)h.Context.TypeTable;
types.AddSubtype(DataTypeIds.BaseDataType, NodeId.Null);
types.AddSubtype(DataTypeIds.Int32, DataTypeIds.BaseDataType);
types.AddSubtype(ObjectTypeIds.BaseObjectType, NodeId.Null);
NodeId parentId = await h.AddObjectAsync("Parent").ConfigureAwait(false);
ushort ns = h.Manager.NamespaceIndexes[0];
var attributes = new VariableAttributes
{
SpecifiedAttributes = (uint)NodeAttributesMask.DataType |
(uint)NodeAttributesMask.ValueRank |
(uint)NodeAttributesMask.Value |
(uint)NodeAttributesMask.MinimumSamplingInterval |
(uint)NodeAttributesMask.ArrayDimensions,
DataType = DataTypeIds.Int32,
ValueRank = ValueRanks.Scalar,
Value = 42,
MinimumSamplingInterval = -1
};
switch (invalidAttribute)
{
case "unknownDataType":
attributes.DataType = new NodeId("Unknown", ns);
break;
case "nonDataType":
attributes.DataType = ObjectTypeIds.BaseObjectType;
break;
case "wrongValue":
attributes.Value = "not an integer";
break;
case "arrayRank":
attributes.ValueRank = ValueRanks.OneDimension;
break;
case "invalidRank":
attributes.ValueRank = -4;
break;
case "negativeMinimum":
attributes.MinimumSamplingInterval = -2;
break;
case "nanMinimum":
attributes.MinimumSamplingInterval = double.NaN;
break;
case "infiniteMinimum":
attributes.MinimumSamplingInterval = double.PositiveInfinity;
break;
case "scalarDimensions":
attributes.ArrayDimensions = [2];
break;
case "rankDimensions":
attributes.ValueRank = ValueRanks.OneDimension;
attributes.Value = new Variant([1, 2]);
attributes.ArrayDimensions = [2, 2];
break;
}
var requestedId = new NodeId("Variable", ns);
var item = new AddNodesItem
{
ParentNodeId = parentId,
ReferenceTypeId = ReferenceTypeIds.HasComponent,
RequestedNewNodeId = requestedId,
BrowseName = new QualifiedName("Variable", ns),
NodeClass = NodeClass.Variable,
TypeDefinition = VariableTypeIds.BaseDataVariableType,
NodeAttributes = new ExtensionObject(attributes)
};
(ServiceResult result, NodeId addedId) = await h.Manager.AddNodeAsync(h.OperationContext, item)
.ConfigureAwait(false);
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadNodeAttributesInvalid));
Assert.That(addedId.IsNull, Is.True);
Assert.That(h.Manager.PredefinedNodes.ContainsKey(requestedId), Is.False);
Assert.That(h.Manager.PredefinedNodes[parentId].FindChildWithQualifiedName(h.Context, item.BrowseName),
Is.Null);
}
[TestCase(-1)]
[TestCase(0)]
[TestCase(100)]
public async Task AddVariableAcceptsValidAttributesAndIndeterminateSampling(double minimumSamplingInterval)
{
using Harness h = CreateHarness(allowNodeManagement: true);
var types = (TypeTable)h.Context.TypeTable;
types.AddSubtype(DataTypeIds.BaseDataType, NodeId.Null);
types.AddSubtype(DataTypeIds.Int32, DataTypeIds.BaseDataType);
NodeId parentId = await h.AddObjectAsync("Parent").ConfigureAwait(false);
ushort ns = h.Manager.NamespaceIndexes[0];
var item = new AddNodesItem
{
ParentNodeId = parentId,
ReferenceTypeId = ReferenceTypeIds.HasComponent,
BrowseName = new QualifiedName("Variable", ns),
NodeClass = NodeClass.Variable,
TypeDefinition = VariableTypeIds.BaseDataVariableType,
NodeAttributes = new ExtensionObject(new VariableAttributes
{
SpecifiedAttributes = (uint)NodeAttributesMask.DataType |
(uint)NodeAttributesMask.ValueRank |
(uint)NodeAttributesMask.Value |
(uint)NodeAttributesMask.MinimumSamplingInterval |
(uint)NodeAttributesMask.ArrayDimensions,
DataType = DataTypeIds.Int32,
ValueRank = ValueRanks.OneDimension,
Value = new Variant([1, 2]),
ArrayDimensions = [2],
MinimumSamplingInterval = minimumSamplingInterval
})
};
(ServiceResult result, NodeId addedId) = await h.Manager.AddNodeAsync(h.OperationContext, item)
.ConfigureAwait(false);
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.Good));
BaseVariableState variable = h.Manager.FindPredefinedNode<BaseVariableState>(addedId);
Assert.That(variable.DataType, Is.EqualTo(DataTypeIds.Int32));
Assert.That(variable.ValueRank, Is.EqualTo(ValueRanks.OneDimension));
Assert.That(variable.Value.TryGetValue(out ArrayOf<int> values), Is.True);
Assert.That(values, Is.EqualTo((ArrayOf<int>)[1, 2]));
Assert.That(variable.ArrayDimensions, Is.EqualTo((ArrayOf<uint>)[2]));
Assert.That(variable.MinimumSamplingInterval, Is.EqualTo(minimumSamplingInterval));
}
[TestCase(1, 2u, true)]
[TestCase(2, 2u, true)]
[TestCase(3, 2u, false)]
[TestCase(4, 2u, false)]
[TestCase(3, 0u, true)]
public Task AddVariableEnforcesDeclaredArrayMaximum(int length, uint maximum, bool valid)
{
return AssertVariableDimensionAdmissionAsync(
new Variant(Enumerable.Range(1, length).ToArrayOf()),
DataTypeIds.Int32, ValueRanks.OneDimension, [maximum], valid);
}
[TestCase(false, 2u)]
[TestCase(true, 2u)]
[TestCase(false, 0u)]
[TestCase(true, 0u)]
public Task AddVariableAcceptsTypedNullAndEmptyArrayValues(bool nullArray, uint maximum)
{
ArrayOf<int> values = nullArray ? ArrayOf<int>.Null : ArrayOf<int>.Empty;
return AssertVariableDimensionAdmissionAsync(
new Variant(values), DataTypeIds.Int32, ValueRanks.OneDimension, [maximum], valid: true);
}
[TestCase(1, 2, 2u, 2u, true)]
[TestCase(2, 1, 2u, 2u, true)]
[TestCase(2, 2, 2u, 2u, true)]
[TestCase(3, 1, 2u, 2u, false)]
[TestCase(4, 1, 2u, 2u, false)]
[TestCase(1, 3, 2u, 2u, false)]
[TestCase(3, 2, 0u, 2u, true)]
[TestCase(3, 3, 0u, 2u, false)]
[TestCase(2, 3, 2u, 0u, true)]
[TestCase(3, 3, 2u, 0u, false)]
public Task AddVariableEnforcesDeclaredMatrixMaxima(
int rows,
int columns,
uint rowMaximum,
uint columnMaximum,
bool valid)
{
MatrixOf<int> matrix = Enumerable.Range(1, rows * columns).ToArrayOf().ToMatrix(rows, columns);
return AssertVariableDimensionAdmissionAsync(
new Variant(matrix), DataTypeIds.Int32, 2, [rowMaximum, columnMaximum], valid);
}
[TestCase(1, 2u, true)]
[TestCase(2, 2u, true)]
[TestCase(3, 2u, false)]
[TestCase(4, 2u, false)]
[TestCase(3, 0u, true)]
public Task AddByteArrayVariableEnforcesMaximumForByteStringValue(int length, uint maximum, bool valid)
{
ByteString bytes = Enumerable.Range(1, length).Select(value => (byte)value).ToByteString();
return AssertVariableDimensionAdmissionAsync(
new Variant(bytes), DataTypeIds.Byte, ValueRanks.OneDimension, [maximum], valid);
}
[TestCaseSource(nameof(DimensionValueTypeCases))]
public async Task AddVariableEnforcesDimensionMaximaForBuiltInTypes(
BuiltInType builtInType,
Variant value,
NodeId dataType,
ArrayOf<uint> dimensions,
bool valid)
{
using (Assert.EnterMultipleScope())
{
Assert.That(value.IsNull, Is.False);
Assert.That(value.TypeInfo.BuiltInType, Is.EqualTo(builtInType));
Assert.That(value.TypeInfo.ValueRank, Is.EqualTo(dimensions.Count));
}
await AssertVariableDimensionAdmissionAsync(value, dataType, dimensions.Count, dimensions, valid)
.ConfigureAwait(false);
}
[TestCase("subtype", true)]
[TestCase("inherited", true)]
[TestCase("dataType", false)]
[TestCase("valueRank", false)]
public async Task AddVariableHonorsTypeDefinitionConstraints(string constraint, bool valid)
{
using Harness h = CreateHarness(allowNodeManagement: true);
var types = (TypeTable)h.Context.TypeTable;
types.AddSubtype(DataTypeIds.BaseDataType, NodeId.Null);
types.AddSubtype(DataTypeIds.Number, DataTypeIds.BaseDataType);
types.AddSubtype(DataTypeIds.Int32, DataTypeIds.Number);
types.AddSubtype(DataTypeIds.String, DataTypeIds.BaseDataType);
types.AddSubtype(VariableTypeIds.BaseVariableType, NodeId.Null);
ushort ns = h.Manager.NamespaceIndexes[0];
var variableType = new ConstrainedVariableType
{
NodeId = new NodeId("NumericType", ns),
BrowseName = new QualifiedName("NumericType", ns),
SuperTypeId = VariableTypeIds.BaseVariableType,
DataType = DataTypeIds.Number,
ValueRank = ValueRanks.Scalar
};
await h.Manager.AddPredefinedNodeAsyncPublic(h.Context, variableType).ConfigureAwait(false);
NodeId parentId = await h.AddObjectAsync("Parent").ConfigureAwait(false);
var attributes = new VariableAttributes
{
SpecifiedAttributes = (uint)NodeAttributesMask.DataType |
(uint)NodeAttributesMask.ValueRank |
(uint)NodeAttributesMask.Value,
DataType = DataTypeIds.Int32,
ValueRank = ValueRanks.Scalar,
Value = 42
};
if (constraint == "inherited")
{
attributes.SpecifiedAttributes = (uint)NodeAttributesMask.Value;
}
else if (constraint == "dataType")
{
attributes.DataType = DataTypeIds.String;
attributes.Value = "text";
}
else if (constraint == "valueRank")
{
attributes.ValueRank = ValueRanks.OneDimension;
attributes.Value = new Variant([42]);
}
var item = new AddNodesItem
{
ParentNodeId = parentId,
ReferenceTypeId = ReferenceTypeIds.HasComponent,
BrowseName = new QualifiedName("Variable", ns),
NodeClass = NodeClass.Variable,
TypeDefinition = variableType.NodeId,
NodeAttributes = new ExtensionObject(attributes)
};
(ServiceResult result, NodeId addedId) = await h.Manager.AddNodeAsync(h.OperationContext, item)
.ConfigureAwait(false);
Assert.That(result.StatusCode,
Is.EqualTo(valid ? StatusCodes.Good : StatusCodes.BadNodeAttributesInvalid));
if (valid)
{
BaseVariableState variable = h.Manager.FindPredefinedNode<BaseVariableState>(addedId);
Assert.That(variable.DataType,
Is.EqualTo(constraint == "inherited" ? DataTypeIds.Number : DataTypeIds.Int32));
Assert.That(variable.ValueRank, Is.EqualTo(ValueRanks.Scalar));
Assert.That(variable.Value, Is.EqualTo(new Variant(42)));
}
else
{
Assert.That(addedId.IsNull, Is.True);
Assert.That(h.Manager.PredefinedNodes[parentId].FindChildWithQualifiedName(h.Context, item.BrowseName),
Is.Null);
}
}
[Test]
public void AllowNodeManagement_DefaultsToFalse()
{
using Harness h = CreateHarness();
Assert.That(h.Manager.AllowNodeManagement, Is.False);
}
[Test]
public void AllowNodeManagement_IsTrueWhenOptedIn()
{
using Harness h = CreateHarness(allowNodeManagement: true);
Assert.That(h.Manager.AllowNodeManagement, Is.True);
}
[Test]
public async Task AddNodeAsync_BrowseNameInvalid_ReturnsBadBrowseNameInvalidAsync()
{
using Harness h = CreateHarness(allowNodeManagement: true);
ushort ns = h.Manager.NamespaceIndexes[0];
var item = new AddNodesItem
{
ParentNodeId = ObjectIds.ObjectsFolder,
ReferenceTypeId = ReferenceTypeIds.Organizes,
BrowseName = default,
NodeClass = NodeClass.Object,
RequestedNewNodeId = new NodeId("MyObj", ns)
};
(ServiceResult result, NodeId added) = await h.Manager
.AddNodeAsync(h.OperationContext, item).ConfigureAwait(false);
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadBrowseNameInvalid));
Assert.That(added.IsNull, Is.True);
}
[Test]
public async Task AddNodeAsync_ParentNodeIdInvalid_ReturnsBadParentNodeIdInvalidAsync()
{
using Harness h = CreateHarness(allowNodeManagement: true);
ushort ns = h.Manager.NamespaceIndexes[0];
var item = new AddNodesItem
{
ParentNodeId = default,
ReferenceTypeId = ReferenceTypeIds.Organizes,
BrowseName = new QualifiedName("MyObj", ns),
NodeClass = NodeClass.Object,
RequestedNewNodeId = new NodeId("MyObj", ns)
};
(ServiceResult result, _) = await h.Manager
.AddNodeAsync(h.OperationContext, item).ConfigureAwait(false);
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadParentNodeIdInvalid));
}
[Test]
public async Task AddNodeAsync_RequestedNewNodeIdInOtherNamespace_ReturnsBadNodeIdRejectedAsync()
{
using Harness h = CreateHarness(allowNodeManagement: true);
ushort ns = h.Manager.NamespaceIndexes[0];
// Add a parent locally so the inverse-edge path runs but the
// RequestedNewNodeId is in a foreign namespace.
NodeId parentId = await h.AddObjectAsync("Parent").ConfigureAwait(false);
var item = new AddNodesItem
{
ParentNodeId = parentId,
ReferenceTypeId = ReferenceTypeIds.Organizes,
BrowseName = new QualifiedName("Child", ns),
NodeClass = NodeClass.Object,
TypeDefinition = ObjectTypeIds.BaseObjectType,
RequestedNewNodeId = new NodeId("Foreign", 0) // ns=0 not in this manager
};
(ServiceResult result, _) = await h.Manager
.AddNodeAsync(h.OperationContext, item).ConfigureAwait(false);
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadNodeIdRejected));
}
[Test]
public async Task AddNodeAsync_NullTypeDefinition_ReturnsBadTypeDefinitionInvalidAsync()
{
using Harness h = CreateHarness(allowNodeManagement: true);
ushort ns = h.Manager.NamespaceIndexes[0];
NodeId parentId = await h.AddObjectAsync("Parent").ConfigureAwait(false);
var item = new AddNodesItem
{
ParentNodeId = parentId,
ReferenceTypeId = ReferenceTypeIds.Organizes,
BrowseName = new QualifiedName("Child", ns),
NodeClass = NodeClass.Object
};
(ServiceResult result, NodeId added) = await h.Manager
.AddNodeAsync(h.OperationContext, item).ConfigureAwait(false);
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadTypeDefinitionInvalid));
Assert.That(added.IsNull, Is.True);
}
[Test]
public async Task AddNodeAsync_WrongTypeDefinitionClass_ReturnsBadTypeDefinitionInvalidAsync()
{
using Harness h = CreateHarness(allowNodeManagement: true);
ushort ns = h.Manager.NamespaceIndexes[0];
NodeId parentId = await h.AddObjectAsync("Parent").ConfigureAwait(false);
var item = new AddNodesItem
{
ParentNodeId = parentId,
ReferenceTypeId = ReferenceTypeIds.Organizes,
BrowseName = new QualifiedName("Child", ns),
NodeClass = NodeClass.Object,
TypeDefinition = VariableTypeIds.BaseVariableType
};
(ServiceResult result, NodeId added) = await h.Manager
.AddNodeAsync(h.OperationContext, item).ConfigureAwait(false);
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadTypeDefinitionInvalid));
Assert.That(added.IsNull, Is.True);
}
[Test]
public async Task AddNodeAsync_UnknownTypeDefinition_ReturnsBadTypeDefinitionInvalidAsync()
{
using Harness h = CreateHarness(allowNodeManagement: true);
ushort ns = h.Manager.NamespaceIndexes[0];
NodeId parentId = await h.AddObjectAsync("Parent").ConfigureAwait(false);
var item = new AddNodesItem
{
ParentNodeId = parentId,
ReferenceTypeId = ReferenceTypeIds.Organizes,
BrowseName = new QualifiedName("Child", ns),
NodeClass = NodeClass.Object,
TypeDefinition = new NodeId("UnknownType", ns)
};
(ServiceResult result, NodeId added) = await h.Manager
.AddNodeAsync(h.OperationContext, item).ConfigureAwait(false);
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadTypeDefinitionInvalid));
Assert.That(added.IsNull, Is.True);
}
[Test]
public async Task AddNodeAsync_RequestedNodeIdAlreadyExists_ReturnsBadNodeIdExistsAsync()
{
using Harness h = CreateHarness(allowNodeManagement: true);
ushort ns = h.Manager.NamespaceIndexes[0];
NodeId parentId = await h.AddObjectAsync("Parent").ConfigureAwait(false);
NodeId existingId = await h.AddObjectAsync("Existing").ConfigureAwait(false);
var item = new AddNodesItem
{
ParentNodeId = parentId,
ReferenceTypeId = ReferenceTypeIds.Organizes,
BrowseName = new QualifiedName("Child", ns),
NodeClass = NodeClass.Object,
TypeDefinition = ObjectTypeIds.BaseObjectType,
RequestedNewNodeId = existingId
};
(ServiceResult result, _) = await h.Manager
.AddNodeAsync(h.OperationContext, item).ConfigureAwait(false);
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadNodeIdExists));
}
[Test]
public async Task AddNodeAsync_DuplicateBrowseNameUnderParent_ReturnsBadBrowseNameDuplicatedAsync()
{
using Harness h = CreateHarness(allowNodeManagement: true);
ushort ns = h.Manager.NamespaceIndexes[0];
NodeId parentId = await h.AddObjectAsync("Parent").ConfigureAwait(false);
// First child succeeds.
var first = new AddNodesItem
{
ParentNodeId = parentId,
ReferenceTypeId = ReferenceTypeIds.Organizes,
BrowseName = new QualifiedName("SameName", ns),
NodeClass = NodeClass.Object,
TypeDefinition = ObjectTypeIds.BaseObjectType
};
(ServiceResult firstResult, NodeId firstId) = await h.Manager
.AddNodeAsync(h.OperationContext, first).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(firstResult), Is.True, $"expected Good result; got {firstResult}");
Assert.That(firstId.IsNull, Is.False);
// Second child under same parent with same browse name fails.
var second = new AddNodesItem
{
ParentNodeId = parentId,
ReferenceTypeId = ReferenceTypeIds.Organizes,
BrowseName = new QualifiedName("SameName", ns),
NodeClass = NodeClass.Object,
TypeDefinition = ObjectTypeIds.BaseObjectType
};
(ServiceResult secondResult, NodeId secondId) = await h.Manager
.AddNodeAsync(h.OperationContext, second).ConfigureAwait(false);
Assert.That(secondResult.StatusCode, Is.EqualTo(StatusCodes.BadBrowseNameDuplicated));
Assert.That(secondId.IsNull, Is.True);
}
/// <summary>
/// Verifies that the in-flight BrowseName reservation beneath a remote parent only
/// blocks adds with the same relationship (Part 4 5.8.2.4): a concurrent add of the
/// same BrowseName with another ReferenceType succeeds, one with the same type fails.
/// </summary>
[Test]
public async Task AddNodeAsync_ConcurrentSameBrowseNameDifferentReferenceTypeUnderRemoteParentSucceedsAsync()
{
using Harness h = CreateHarness(allowNodeManagement: true);
ushort ns = h.Manager.NamespaceIndexes[0];
var entered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
int calls = 0;
h.MockMasterNodeManager
.Setup(m => m.AddReferencesAsync(
It.IsAny<NodeId>(),
It.IsAny<IList<IReference>>(),
It.IsAny<CancellationToken>()))
.Returns(() =>
{
if (Interlocked.Increment(ref calls) == 1)
{
entered.TrySetResult(true);
return new ValueTask(release.Task);
}
return default;
});
AddNodesItem CreateItem(NodeId referenceTypeId)
{
return new AddNodesItem
{
ParentNodeId = ObjectIds.ObjectsFolder,
ReferenceTypeId = referenceTypeId,
BrowseName = new QualifiedName("SameName", ns),
NodeClass = NodeClass.Object,
TypeDefinition = ObjectTypeIds.BaseObjectType
};
}
Task<(ServiceResult, NodeId)> inFlight = h.Manager
.AddNodeAsync(h.OperationContext, CreateItem(ReferenceTypeIds.Organizes)).AsTask();
await entered.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
(ServiceResult sameType, _) = await h.Manager
.AddNodeAsync(h.OperationContext, CreateItem(ReferenceTypeIds.Organizes)).ConfigureAwait(false);
(ServiceResult otherType, NodeId otherId) = await h.Manager
.AddNodeAsync(h.OperationContext, CreateItem(ReferenceTypeIds.HasComponent)).ConfigureAwait(false);
release.TrySetResult(true);
(ServiceResult first, NodeId firstId) = await inFlight.ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(first), Is.True, first.ToString());
Assert.That(sameType.StatusCode, Is.EqualTo(StatusCodes.BadBrowseNameDuplicated));
Assert.That(ServiceResult.IsGood(otherType), Is.True, otherType.ToString());
Assert.That(otherId, Is.Not.EqualTo(firstId));
}
/// <summary>
/// Verifies indexed browse-name reuse after renaming cannot overwrite the existing node's identifier.
/// </summary>
[Test]
public async Task IndexedBrowseNameReusePreservesExistingNodeIdentityAsync()
{
// The CTT (Node Management Delete Node Err-002.js) adds thousands of nodes
// below one parent; the duplicate check must stay correct for large parents.
using Harness h = CreateHarness(allowNodeManagement: true);
ushort ns = h.Manager.NamespaceIndexes[0];
NodeId parentId = await h.AddObjectAsync("Parent").ConfigureAwait(false);
NodeId renamedId = default;
for (int ii = 0; ii < 500; ii++)
{
(ServiceResult result, NodeId added) = await h.Manager
.AddNodeAsync(h.OperationContext, new AddNodesItem
{
ParentNodeId = parentId,
ReferenceTypeId = ReferenceTypeIds.Organizes,
BrowseName = new QualifiedName("Child" + ii, ns),
NodeClass = NodeClass.Variable,
TypeDefinition = VariableTypeIds.BaseDataVariableType
}).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(result), Is.True, $"child {ii}: {result}");
if (ii == 42)
{
renamedId = added;
}
}
(ServiceResult duplicate, NodeId duplicateId) = await h.Manager
.AddNodeAsync(h.OperationContext, new AddNodesItem
{
ParentNodeId = parentId,
ReferenceTypeId = ReferenceTypeIds.Organizes,
BrowseName = new QualifiedName("Child250", ns),
NodeClass = NodeClass.Variable,
TypeDefinition = VariableTypeIds.BaseDataVariableType
}).ConfigureAwait(false);
Assert.That(duplicate.StatusCode, Is.EqualTo(StatusCodes.BadBrowseNameDuplicated));
Assert.That(duplicateId.IsNull, Is.True);
// a renamed child frees its old browse name and takes the new one.
NodeState renamedNode = h.Manager.PredefinedNodes[renamedId];
renamedNode.BrowseName = new QualifiedName("Renamed", ns);
int countBefore = h.Manager.PredefinedNodes.Count;
(ServiceResult collision, NodeId collisionId) = await h.Manager
.AddNodeAsync(h.OperationContext, new AddNodesItem
{
ParentNodeId = parentId,
ReferenceTypeId = ReferenceTypeIds.Organizes,
RequestedNewNodeId = renamedId,
BrowseName = new QualifiedName("Child42", ns),
NodeClass = NodeClass.Variable,
TypeDefinition = VariableTypeIds.BaseDataVariableType
}).ConfigureAwait(false);
Assert.That(collision.StatusCode, Is.EqualTo(StatusCodes.BadNodeIdExists));
Assert.That(collisionId.IsNull, Is.True);
Assert.That(h.Manager.PredefinedNodes, Has.Count.EqualTo(countBefore));
Assert.That(h.Manager.PredefinedNodes[renamedId], Is.SameAs(renamedNode));
Assert.That(renamedNode.BrowseName, Is.EqualTo(new QualifiedName("Renamed", ns)));
// Renaming frees the BrowseName; automatic allocation must not reuse the still-owned NodeId.
(ServiceResult freed, NodeId freedId) = await h.Manager
.AddNodeAsync(h.OperationContext, new AddNodesItem
{
ParentNodeId = parentId,
ReferenceTypeId = ReferenceTypeIds.Organizes,
BrowseName = new QualifiedName("Child42", ns),
NodeClass = NodeClass.Variable,
TypeDefinition = VariableTypeIds.BaseDataVariableType
}).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(freed), Is.True, $"expected Good result; got {freed}");
Assert.That(freedId.IsNull, Is.False);
Assert.That(freedId.NamespaceIndex, Is.EqualTo(ns));
Assert.That(freedId, Is.Not.EqualTo(renamedId));
Assert.That(h.Manager.PredefinedNodes[renamedId], Is.SameAs(renamedNode));
Assert.That(h.Manager.PredefinedNodes, Has.Count.EqualTo(countBefore + 1));
(ServiceResult taken, _) = await h.Manager
.AddNodeAsync(h.OperationContext, new AddNodesItem
{
ParentNodeId = parentId,
ReferenceTypeId = ReferenceTypeIds.Organizes,
BrowseName = new QualifiedName("Renamed", ns),
NodeClass = NodeClass.Variable,
TypeDefinition = VariableTypeIds.BaseDataVariableType
}).ConfigureAwait(false);
Assert.That(taken.StatusCode, Is.EqualTo(StatusCodes.BadBrowseNameDuplicated));
}
[Test]
public async Task AddNodeAsync_AllocatesNewNodeIdWhenRequestedIsNullAsync()
{
using Harness h = CreateHarness(allowNodeManagement: true);
ushort ns = h.Manager.NamespaceIndexes[0];
NodeId parentId = await h.AddObjectAsync("Parent").ConfigureAwait(false);
var item = new AddNodesItem
{
ParentNodeId = parentId,
ReferenceTypeId = ReferenceTypeIds.Organizes,
BrowseName = new QualifiedName("AutoNode", ns),
NodeClass = NodeClass.Object,
TypeDefinition = ObjectTypeIds.BaseObjectType
};
(ServiceResult result, NodeId added) = await h.Manager
.AddNodeAsync(h.OperationContext, item).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(result), Is.True, $"expected Good result; got {result}");
Assert.That(added.IsNull, Is.False);
Assert.That(added.NamespaceIndex, Is.EqualTo(ns));
Assert.That(h.Manager.PredefinedNodes.ContainsKey(added), Is.True);
}
[Test]
public async Task AddNodeAsync_HonoursRequestedNewNodeIdAsync()
{
using Harness h = CreateHarness(allowNodeManagement: true);
ushort ns = h.Manager.NamespaceIndexes[0];
NodeId parentId = await h.AddObjectAsync("Parent").ConfigureAwait(false);
var requested = new NodeId("ExplicitId", ns);
var item = new AddNodesItem
{
ParentNodeId = parentId,
ReferenceTypeId = ReferenceTypeIds.Organizes,
BrowseName = new QualifiedName("ExplicitId", ns),
NodeClass = NodeClass.Object,
TypeDefinition = ObjectTypeIds.BaseObjectType,
RequestedNewNodeId = requested
};
(ServiceResult result, NodeId added) = await h.Manager
.AddNodeAsync(h.OperationContext, item).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(result), Is.True, $"expected Good result; got {result}");
Assert.That(added, Is.EqualTo(requested));
Assert.That(h.Manager.PredefinedNodes.ContainsKey(requested), Is.True);
}
[Test]
public async Task AddNodeAsyncRequestedIdWithSupportedNamespaceUriIsHonouredAsync()
{
using Harness h = CreateHarness(allowNodeManagement: true);
ushort namespaceIndex = h.Manager.NamespaceIndexes[0];
NodeId parentNodeId = await h.AddObjectAsync("Parent").ConfigureAwait(false);
var requestedNodeId = new ExpandedNodeId("ExplicitUriId", TestNamespaceUri);
var item = new AddNodesItem
{
ParentNodeId = parentNodeId,
ReferenceTypeId = ReferenceTypeIds.Organizes,
BrowseName = new QualifiedName("ExplicitUriId", namespaceIndex),
NodeClass = NodeClass.Object,
TypeDefinition = ObjectTypeIds.BaseObjectType,
RequestedNewNodeId = requestedNodeId
};
(ServiceResult result, NodeId addedNodeId) = await h.Manager
.AddNodeAsync(h.OperationContext, item).ConfigureAwait(false);
var expectedNodeId = new NodeId("ExplicitUriId", namespaceIndex);
Assert.That(ServiceResult.IsGood(result), Is.True, $"expected Good result; got {result}");
Assert.That(addedNodeId, Is.EqualTo(expectedNodeId));
Assert.That(h.Manager.PredefinedNodes.ContainsKey(expectedNodeId), Is.True);
}
[Test]
public async Task AddNodeVariableWithAttributesAppliesAttributesAsync()
{
using Harness h = CreateHarness(allowNodeManagement: true);
var types = (TypeTable)h.Context.TypeTable;
types.AddSubtype(DataTypeIds.BaseDataType, NodeId.Null);
types.AddSubtype(DataTypeIds.Int32, DataTypeIds.BaseDataType);
ushort ns = h.Manager.NamespaceIndexes[0];
NodeId parentId = await h.AddObjectAsync("Parent").ConfigureAwait(false);
var attributes = new VariableAttributes
{
SpecifiedAttributes =
(uint)NodeAttributesMask.DisplayName |
(uint)NodeAttributesMask.DataType |
(uint)NodeAttributesMask.ValueRank |
(uint)NodeAttributesMask.AccessLevel,
DisplayName = new LocalizedText("Test Variable"),
DataType = DataTypeIds.Int32,
ValueRank = ValueRanks.Scalar,
AccessLevel = AccessLevels.CurrentRead
};
var item = new AddNodesItem
{
ParentNodeId = parentId,
ReferenceTypeId = ReferenceTypeIds.HasComponent,
BrowseName = new QualifiedName("AttrVar", ns),
NodeClass = NodeClass.Variable,
TypeDefinition = VariableTypeIds.BaseDataVariableType,
NodeAttributes = new ExtensionObject(attributes)
};
(ServiceResult result, NodeId added) = await h.Manager
.AddNodeAsync(h.OperationContext, item).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(result), Is.True, $"expected Good result; got {result}");
var variable = h.Manager.PredefinedNodes[added] as BaseDataVariableState;
Assert.That(variable, Is.Not.Null);
Assert.That(variable!.DataType, Is.EqualTo(DataTypeIds.Int32));
Assert.That(variable.ValueRank, Is.EqualTo(ValueRanks.Scalar));
Assert.That(variable.AccessLevel, Is.EqualTo(AccessLevels.CurrentRead));
Assert.That(variable.DisplayName.Text, Is.EqualTo("Test Variable"));
}
[Test]
public async Task AddNodeAsync_UnsupportedNodeClass_ReturnsBadNodeClassInvalidAsync()
{
using Harness h = CreateHarness(allowNodeManagement: true);
ushort ns = h.Manager.NamespaceIndexes[0];
NodeId parentId = await h.AddObjectAsync("Parent").ConfigureAwait(false);
var item = new AddNodesItem
{
ParentNodeId = parentId,
ReferenceTypeId = ReferenceTypeIds.HasComponent,
BrowseName = new QualifiedName("MethodNode", ns),
NodeClass = NodeClass.Method
};
(ServiceResult result, _) = await h.Manager
.AddNodeAsync(h.OperationContext, item).ConfigureAwait(false);
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadNodeClassInvalid));
}
[Test]
public async Task DeleteNodeAsync_UnknownNodeId_ReturnsBadNodeIdUnknownAsync()
{
using Harness h = CreateHarness(allowNodeManagement: true);
ushort ns = h.Manager.NamespaceIndexes[0];
var item = new DeleteNodesItem
{
NodeId = new NodeId("DoesNotExist", ns),
DeleteTargetReferences = false
};
ServiceResult result = await h.Manager
.DeleteNodeAsync(h.OperationContext, item).ConfigureAwait(false);
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadNodeIdUnknown));
}
[Test]
public async Task DeleteNodeAsync_NullNodeId_ReturnsBadNodeIdInvalidAsync()
{
using Harness h = CreateHarness(allowNodeManagement: true);
var item = new DeleteNodesItem
{
NodeId = default,
DeleteTargetReferences = false
};
ServiceResult result = await h.Manager
.DeleteNodeAsync(h.OperationContext, item).ConfigureAwait(false);
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadNodeIdInvalid));
}
[Test]
public async Task DeleteNodeAsync_RemovesNodeFromPredefinedNodesAsync()
{
using Harness h = CreateHarness(allowNodeManagement: true);
NodeId nodeId = await h.AddObjectAsync("Deletable").ConfigureAwait(false);
var item = new DeleteNodesItem
{
NodeId = nodeId,
DeleteTargetReferences = false
};
ServiceResult result = await h.Manager
.DeleteNodeAsync(h.OperationContext, item).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(result), Is.True, $"expected Good result; got {result}");
Assert.That(h.Manager.PredefinedNodes.ContainsKey(nodeId), Is.False);
}
[Test]
public async Task DeleteNodeAsync_DeleteTargetReferencesTrue_RequestsRemoveReferencesAsync()
{
using Harness h = CreateHarness(allowNodeManagement: true);
ushort ns = h.Manager.NamespaceIndexes[0];
// Set up a node with an inverse reference so RemovePredefinedNodeAsync
// collects a LocalReference to remove on the target.
NodeId nodeId = await h.AddObjectAsync("WithRefs").ConfigureAwait(false);
NodeState node = h.Manager.PredefinedNodes[nodeId];
// Add an inverse hierarchical reference pointing at a foreign target.
var foreign = new NodeId("ForeignTarget", (ushort)(ns + 100));
node.AddReference(ReferenceTypeIds.Organizes, true, foreign);
var item = new DeleteNodesItem
{
NodeId = nodeId,
DeleteTargetReferences = true
};
ServiceResult result = await h.Manager
.DeleteNodeAsync(h.OperationContext, item).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(result), Is.True, $"expected Good result; got {result}");
h.MockMasterNodeManager.Verify(
m => m.RemoveReferencesAsync(
It.Is<List<LocalReference>>(l => l.Count > 0),
It.IsAny<CancellationToken>()),
Times.AtLeastOnce);
}
[Test]
public async Task DeleteNodeAsync_DeleteTargetReferencesFalse_DoesNotRequestRemoveReferencesAsync()
{
using Harness h = CreateHarness(allowNodeManagement: true);
ushort ns = h.Manager.NamespaceIndexes[0];
NodeId nodeId = await h.AddObjectAsync("KeepRefs").ConfigureAwait(false);
NodeState node = h.Manager.PredefinedNodes[nodeId];
var foreign = new NodeId("ForeignTarget", (ushort)(ns + 100));
node.AddReference(ReferenceTypeIds.Organizes, true, foreign);
var item = new DeleteNodesItem
{
NodeId = nodeId,
DeleteTargetReferences = false
};
ServiceResult result = await h.Manager
.DeleteNodeAsync(h.OperationContext, item).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(result), Is.True, $"expected Good result; got {result}");
h.MockMasterNodeManager.Verify(
m => m.RemoveReferencesAsync(
It.IsAny<List<LocalReference>>(),
It.IsAny<CancellationToken>()),
Times.Never);
}
[Test]
public async Task AddNodeAsync_LocalParentDoesNotCreateExplicitForwardReferenceAsync()
{
using Harness h = CreateHarness(allowNodeManagement: true);
ushort ns = h.Manager.NamespaceIndexes[0];
NodeId parentId = await h.AddObjectAsync("Parent").ConfigureAwait(false);
(ServiceResult result, NodeId added) = await h.Manager
.AddNodeAsync(h.OperationContext, new AddNodesItem
{
ParentNodeId = parentId,
ReferenceTypeId = ReferenceTypeIds.Organizes,
BrowseName = new QualifiedName("Child", ns),
NodeClass = NodeClass.Object,
TypeDefinition = ObjectTypeIds.BaseObjectType
}).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(result), Is.True, $"expected Good result; got {result}");
Assert.That(
h.Manager.PredefinedNodes[parentId].ReferenceExists(
ReferenceTypeIds.Organizes,
false,
added),
Is.False);
}
[Test]
public async Task AddReferenceAsync_SourceUnknown_ReturnsBadSourceNodeIdInvalidAsync()
{
using Harness h = CreateHarness(allowNodeManagement: true);
ushort ns = h.Manager.NamespaceIndexes[0];
var item = new AddReferencesItem
{
SourceNodeId = new NodeId("DoesNotExist", ns),
ReferenceTypeId = ReferenceTypeIds.Organizes,
IsForward = true,
TargetNodeId = new NodeId("Anything", ns)
};
ServiceResult result = await h.Manager
.AddReferenceAsync(h.OperationContext, item).ConfigureAwait(false);
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadSourceNodeIdInvalid));
}
[Test]
public async Task AddReferenceAsync_DuplicateReference_ReturnsBadDuplicateReferenceNotAllowedAsync()
{
using Harness h = CreateHarness(allowNodeManagement: true);
ushort ns = h.Manager.NamespaceIndexes[0];
NodeId sourceId = await h.AddObjectAsync("Source").ConfigureAwait(false);
var targetId = new NodeId("Target", ns);
var item = new AddReferencesItem
{
SourceNodeId = sourceId,
ReferenceTypeId = ReferenceTypeIds.Organizes,
IsForward = true,
TargetNodeId = targetId
};
ServiceResult first = await h.Manager
.AddReferenceAsync(h.OperationContext, item).ConfigureAwait(false);
Assume.That(ServiceResult.IsGood(first), Is.True);
ServiceResult second = await h.Manager
.AddReferenceAsync(h.OperationContext, item).ConfigureAwait(false);
Assert.That(second.StatusCode, Is.EqualTo(StatusCodes.BadDuplicateReferenceNotAllowed));
}
[Test]
public async Task AddReferenceAsync_AddsReferenceToSourceNodeAsync()
{
using Harness h = CreateHarness(allowNodeManagement: true);
ushort ns = h.Manager.NamespaceIndexes[0];
NodeId sourceId = await h.AddObjectAsync("Source").ConfigureAwait(false);
var targetId = new NodeId("Target", ns);
var item = new AddReferencesItem
{
SourceNodeId = sourceId,
ReferenceTypeId = ReferenceTypeIds.Organizes,
IsForward = true,
TargetNodeId = targetId
};
ServiceResult result = await h.Manager
.AddReferenceAsync(h.OperationContext, item).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(result), Is.True);
NodeState source = h.Manager.PredefinedNodes[sourceId];
Assert.That(source.ReferenceExists(ReferenceTypeIds.Organizes, false, targetId), Is.True);
}
[Test]
public async Task DeleteReferenceAsync_NoMatch_ReturnsBadNoMatchAsync()
{
using Harness h = CreateHarness(allowNodeManagement: true);
ushort ns = h.Manager.NamespaceIndexes[0];
NodeId sourceId = await h.AddObjectAsync("Source").ConfigureAwait(false);
var item = new DeleteReferencesItem
{
SourceNodeId = sourceId,
ReferenceTypeId = ReferenceTypeIds.Organizes,
IsForward = true,
TargetNodeId = new NodeId("NoSuchTarget", ns)
};
ServiceResult result = await h.Manager
.DeleteReferenceAsync(h.OperationContext, item).ConfigureAwait(false);
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadNoMatch));
}
[Test]
public async Task DeleteReferenceAsync_RemovesExistingReferenceAsync()
{
using Harness h = CreateHarness(allowNodeManagement: true);
ushort ns = h.Manager.NamespaceIndexes[0];
NodeId sourceId = await h.AddObjectAsync("Source").ConfigureAwait(false);
var targetId = new NodeId("Target", ns);
h.Manager.PredefinedNodes[sourceId].AddReference(ReferenceTypeIds.Organizes, false, targetId);
var item = new DeleteReferencesItem
{
SourceNodeId = sourceId,
ReferenceTypeId = ReferenceTypeIds.Organizes,
IsForward = true,
TargetNodeId = targetId
};
ServiceResult result = await h.Manager
.DeleteReferenceAsync(h.OperationContext, item).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(result), Is.True);
Assert.That(
h.Manager.PredefinedNodes[sourceId].ReferenceExists(ReferenceTypeIds.Organizes, false, targetId),
Is.False);
}
[Test]
public async Task AddReferenceBumpsNodeVersionAndReportsReferenceAddedAsync()
{
using Harness h = CreateHarness(allowNodeManagement: true);
ushort ns = h.Manager.NamespaceIndexes[0];
NodeId sourceId = await h.AddObjectAsync("Source").ConfigureAwait(false);
PropertyState<string> nodeVersion =
h.Manager.EnableModelChangeTrackingFor(h.Manager.PredefinedNodes[sourceId]);
string before = nodeVersion.Value;
ServiceResult result = await h.Manager.AddReferenceAsync(
h.OperationContext,
new AddReferencesItem
{
SourceNodeId = sourceId,
ReferenceTypeId = ReferenceTypeIds.Organizes,
IsForward = true,
TargetNodeId = new NodeId("Target", ns)
}).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(result), Is.True);
Assert.That(nodeVersion.Value, Is.Not.EqualTo(before));
Assert.That(
GetReportedModelChanges(h).Any(c =>
c.Affected == sourceId && c.Verb == (byte)ModelChangeVerbs.ReferenceAdded),
Is.True);
}
[Test]
public async Task DeleteReferenceBumpsNodeVersionAndReportsReferenceDeletedAsync()
{
using Harness h = CreateHarness(allowNodeManagement: true);
ushort ns = h.Manager.NamespaceIndexes[0];
NodeId sourceId = await h.AddObjectAsync("Source").ConfigureAwait(false);
var targetId = new NodeId("Target", ns);
NodeState source = h.Manager.PredefinedNodes[sourceId];
source.AddReference(ReferenceTypeIds.Organizes, false, targetId);
PropertyState<string> nodeVersion = h.Manager.EnableModelChangeTrackingFor(source);
string before = nodeVersion.Value;
ServiceResult result = await h.Manager.DeleteReferenceAsync(
h.OperationContext,
new DeleteReferencesItem
{
SourceNodeId = sourceId,
ReferenceTypeId = ReferenceTypeIds.Organizes,
IsForward = true,
TargetNodeId = targetId
}).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(result), Is.True);
Assert.That(nodeVersion.Value, Is.Not.EqualTo(before));
Assert.That(
GetReportedModelChanges(h).Any(c =>
c.Affected == sourceId && c.Verb == (byte)ModelChangeVerbs.ReferenceDeleted),
Is.True);
}
[Test]
public async Task FailedDeleteReferenceLeavesNodeVersionUnchangedAsync()
{
using Harness h = CreateHarness(allowNodeManagement: true);
ushort ns = h.Manager.NamespaceIndexes[0];
NodeId sourceId = await h.AddObjectAsync("Source").ConfigureAwait(false);
PropertyState<string> nodeVersion =
h.Manager.EnableModelChangeTrackingFor(h.Manager.PredefinedNodes[sourceId]);
string before = nodeVersion.Value;
ServiceResult result = await h.Manager.DeleteReferenceAsync(
h.OperationContext,
new DeleteReferencesItem
{
SourceNodeId = sourceId,
ReferenceTypeId = ReferenceTypeIds.Organizes,
IsForward = true,
TargetNodeId = new NodeId("NoSuchTarget", ns)
}).ConfigureAwait(false);
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadNoMatch));
Assert.That(nodeVersion.Value, Is.EqualTo(before));
Assert.That(GetReportedModelChanges(h), Is.Empty);
}
[Test]
public async Task DeleteNodeReportsReferenceDeletedOnParentAsync()
{
using Harness h = CreateHarness(allowNodeManagement: true);
ushort ns = h.Manager.NamespaceIndexes[0];
NodeId parentId = await h.AddObjectAsync("Parent").ConfigureAwait(false);
(ServiceResult addResult, NodeId childId) = await h.Manager.AddNodeAsync(
h.OperationContext,
new AddNodesItem
{
ParentNodeId = parentId,
ReferenceTypeId = ReferenceTypeIds.Organizes,
BrowseName = new QualifiedName("Child", ns),
NodeClass = NodeClass.Object,
TypeDefinition = ObjectTypeIds.BaseObjectType
}).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(addResult), Is.True);
PropertyState<string> nodeVersion =
h.Manager.EnableModelChangeTrackingFor(h.Manager.PredefinedNodes[parentId]);
string before = nodeVersion.Value;
ServiceResult result = await h.Manager.DeleteNodeAsync(
h.OperationContext,
new DeleteNodesItem { NodeId = childId }).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(result), Is.True);
Assert.That(nodeVersion.Value, Is.Not.EqualTo(before));
Assert.That(
GetReportedModelChanges(h).Any(c =>
c.Affected == parentId && c.Verb == (byte)ModelChangeVerbs.ReferenceDeleted),
Is.True);
}
/// <summary>
/// A transaction controls model-change aggregation even when cleanup fails after node deletion commits.
/// </summary>
[TestCase(false, false)]
[TestCase(true, false)]
[TestCase(false, true)]
[TestCase(true, true)]
public async Task ScopedRuntimeDeletionPreservesTheSelectedModelChangePolicyAsync(
bool emitModelChange,
bool cleanupFails)
{
using Harness h = CreateHarness(allowNodeManagement: true);
ushort ns = h.Manager.NamespaceIndexes[0];
NodeId parentId = await h.AddObjectAsync("ScopedParent").ConfigureAwait(false);
(ServiceResult added, NodeId childId) = await h.Manager.AddNodeAsync(
h.OperationContext,
new AddNodesItem
{
ParentNodeId = parentId,
ReferenceTypeId = ReferenceTypeIds.Organizes,
BrowseName = new QualifiedName("ScopedChild", ns),
NodeClass = NodeClass.Object,
TypeDefinition = ObjectTypeIds.BaseObjectType
}).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(added), Is.True);
PropertyState<string> version = h.Manager.EnableModelChangeTrackingFor(
h.Manager.PredefinedNodes[parentId]);
string before = version.Value;
h.MockServer.Invocations.Clear();
if (cleanupFails)
{
h.Manager.PredefinedNodes[childId].AddReference(
ReferenceTypeIds.Organizes, false, new NodeId("ExternalTarget", ns));
var failure = new InvalidOperationException("Injected reference cleanup failure.");
h.MockMasterNodeManager
.Setup(m => m.RemoveReferencesAsync(
It.IsAny<List<LocalReference>>(), It.IsAny<CancellationToken>()))
.Throws(failure);
await Assert.ThatAsync(
async () => await h.Manager.DeleteNodeAsync(
h.Context, childId, emitModelChange, CancellationToken.None).ConfigureAwait(false),
Throws.TypeOf<InvalidOperationException>().And.SameAs(failure)).ConfigureAwait(false);
}
else
{
bool deleted = await h.Manager.DeleteNodeAsync(
h.Context, childId, emitModelChange, CancellationToken.None).ConfigureAwait(false);
Assert.That(deleted, Is.True);
}
Assert.That(h.Manager.PredefinedNodes.ContainsKey(childId), Is.False);
Assert.That(version.Value != before, Is.EqualTo(emitModelChange));
List<ModelChangeStructureDataType> changes = GetReportedModelChanges(h);
Assert.That(changes, Has.Count.EqualTo(emitModelChange ? 1 : 0));
if (emitModelChange)
{
Assert.That(changes[0].Affected, Is.EqualTo(parentId));
Assert.That(changes[0].Verb, Is.EqualTo((byte)ModelChangeVerbs.ReferenceDeleted));
}
Assert.That(h.Manager.ModelChangeEmissionEnabled, Is.True,
"A per-call choice must not mutate the policy of concurrent operations.");
}
[Test]
public async Task AddNodeCreatesMandatoryInstanceDeclarationsAsync()
{
using Harness h = CreateHarness(allowNodeManagement: true);
ushort ns = h.Manager.NamespaceIndexes[0];
NodeId typeId = await AddMandatoryTestTypesAsync(h).ConfigureAwait(false);
NodeId parentId = await h.AddObjectAsync("Parent").ConfigureAwait(false);
NodeId first = await AddInstanceAsync(h, parentId, "First", typeId).ConfigureAwait(false);
NodeId second = await AddInstanceAsync(h, parentId, "Second", typeId).ConfigureAwait(false);
NodeState instance = h.Manager.PredefinedNodes[first];
BaseInstanceState mandatory = FindChild(h, instance, "MandatoryProp");
BaseInstanceState inherited = FindChild(h, instance, "Inherited");
BaseInstanceState method = FindChild(h, instance, "DoIt");
BaseInstanceState component = FindChild(h, instance, "Component");
Assert.That(mandatory, Is.Not.Null);
Assert.That(inherited, Is.Not.Null, "a Mandatory member of the supertype is created");
Assert.That(method, Is.InstanceOf<MethodState>());
Assert.That(component, Is.Not.Null);
Assert.That(FindChild(h, instance, "OptionalProp"), Is.Null);
Assert.That(FindChild(h, instance, "Overridden"), Is.Null,
"an Optional override hides the Mandatory declaration of the supertype");
Assert.That(FindChild(h, component, "Nested"), Is.Not.Null);
Assert.That(FindChild(h, component, "NestedOptional"), Is.Null);
Assert.That(FindChild(h, component, "TypeMember"), Is.Not.Null,
"a child is completed from its own TypeDefinition");
var subtree = new List<BaseInstanceState>();
CollectDescendants(h, instance, subtree);
// MandatoryProp, Inherited, DoIt, Component, Nested and TypeMember.
Assert.That(subtree, Has.Count.EqualTo(6));
foreach (BaseInstanceState node in subtree)
{
Assert.That(node.NodeId.NamespaceIndex, Is.EqualTo(ns), node.BrowseName.ToString());
Assert.That(h.Manager.PredefinedNodes.TryGetValue(node.NodeId, out NodeState? indexed), Is.True);
Assert.That(indexed, Is.SameAs(node));
Assert.That(node.IsPartOfTypeHierarchy, Is.False);
Assert.That(node.ModellingRuleId.IsNull, Is.True);
}
Assert.That(((MethodState)method).MethodDeclarationId, Is.EqualTo(new NodeId("TestType_DoIt", ns)));
// two instances of the same type never share an instance NodeId.
var secondSubtree = new List<BaseInstanceState>();
CollectDescendants(h, h.Manager.PredefinedNodes[second], secondSubtree);
Assert.That(
secondSubtree.Select(n => n.NodeId).Intersect(subtree.Select(n => n.NodeId)),
Is.Empty);
// the type declarations stay untouched.
NodeState type = h.Manager.PredefinedNodes[typeId];
Assert.That(FindChild(h, type, "OptionalProp"), Is.Not.Null);
Assert.That(
FindChild(h, type, "MandatoryProp").NodeId,
Is.EqualTo(new NodeId("TestType_MandatoryProp", ns)));
}
[Test]
public async Task AddVariableCreatesMandatoryInstanceDeclarationsAsync()
{
using Harness h = CreateHarness(allowNodeManagement: true);
ushort ns = h.Manager.NamespaceIndexes[0];
var types = (TypeTable)h.Context.TypeTable;
types.AddSubtype(VariableTypeIds.BaseVariableType, NodeId.Null);
types.AddSubtype(VariableTypeIds.BaseDataVariableType, VariableTypeIds.BaseVariableType);
var typeId = new NodeId("TestVariableType", ns);
types.AddSubtype(typeId, VariableTypeIds.BaseDataVariableType);
var variableType = new ConstrainedVariableType
{
NodeId = typeId,
BrowseName = new QualifiedName("TestVariableType", ns),
SuperTypeId = VariableTypeIds.BaseDataVariableType,
DataType = DataTypeIds.BaseDataType,
ValueRank = ValueRanks.Any
};
variableType.AddChild(CreateDeclaration(variableType, "Unit", ns, ObjectIds.ModellingRule_Mandatory));
variableType.AddChild(CreateDeclaration(variableType, "Range", ns, ObjectIds.ModellingRule_Optional));
await h.Manager.AddPredefinedNodeAsyncPublic(h.Context, variableType).ConfigureAwait(false);
NodeId parentId = await h.AddObjectAsync("Parent").ConfigureAwait(false);
(ServiceResult result, NodeId added) = await h.Manager.AddNodeAsync(
h.OperationContext,
new AddNodesItem
{
ParentNodeId = parentId,
ReferenceTypeId = ReferenceTypeIds.HasComponent,
BrowseName = new QualifiedName("Value", ns),
NodeClass = NodeClass.Variable,
TypeDefinition = typeId
}).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(result), Is.True, result.ToString());
NodeState variable = h.Manager.PredefinedNodes[added];
BaseInstanceState unit = FindChild(h, variable, "Unit");
Assert.That(unit, Is.Not.Null);
Assert.That(unit.NodeId, Is.Not.EqualTo(new NodeId("TestVariableType_Unit", ns)));
Assert.That(FindChild(h, variable, "Range"), Is.Null);
}
[Test]
public async Task AddNodeRejectsAbstractObjectTypeAsync()
{
using Harness h = CreateHarness(allowNodeManagement: true);
ushort ns = h.Manager.NamespaceIndexes[0];
var types = (TypeTable)h.Context.TypeTable;
types.AddSubtype(ObjectTypeIds.BaseObjectType, NodeId.Null);
var typeId = new NodeId("AbstractType", ns);
types.AddSubtype(typeId, ObjectTypeIds.BaseObjectType);
var type = new BaseObjectTypeState
{
NodeId = typeId,
BrowseName = new QualifiedName("AbstractType", ns),
SuperTypeId = ObjectTypeIds.BaseObjectType,
IsAbstract = true
};
await h.Manager.AddPredefinedNodeAsyncPublic(h.Context, type).ConfigureAwait(false);
NodeId parentId = await h.AddObjectAsync("Parent").ConfigureAwait(false);
(ServiceResult result, NodeId added) = await h.Manager.AddNodeAsync(
h.OperationContext,
new AddNodesItem
{
ParentNodeId = parentId,
ReferenceTypeId = ReferenceTypeIds.Organizes,
BrowseName = new QualifiedName("Child", ns),
NodeClass = NodeClass.Object,
TypeDefinition = typeId
}).ConfigureAwait(false);
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadTypeDefinitionInvalid));
Assert.That(added.IsNull, Is.True);
}
[Test]
public async Task AddNodeRejectsInterfaceTypeAsync()
{
using Harness h = CreateHarness(allowNodeManagement: true);
ushort ns = h.Manager.NamespaceIndexes[0];
var types = (TypeTable)h.Context.TypeTable;
types.AddSubtype(ObjectTypeIds.BaseObjectType, NodeId.Null);
types.AddSubtype(ObjectTypeIds.BaseInterfaceType, ObjectTypeIds.BaseObjectType);
var interfaceId = new NodeId("ITestInterface", ns);
types.AddSubtype(interfaceId, ObjectTypeIds.BaseInterfaceType);
NodeId parentId = await h.AddObjectAsync("Parent").ConfigureAwait(false);
foreach (NodeId typeDefinition in new[] { ObjectTypeIds.BaseInterfaceType, interfaceId })
{
(ServiceResult result, NodeId added) = await h.Manager.AddNodeAsync(
h.OperationContext,
new AddNodesItem
{
ParentNodeId = parentId,
ReferenceTypeId = ReferenceTypeIds.Organizes,
BrowseName = new QualifiedName("Child", ns),
NodeClass = NodeClass.Object,
TypeDefinition = typeDefinition
}).ConfigureAwait(false);
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadTypeDefinitionInvalid), typeDefinition.ToString());
Assert.That(added.IsNull, Is.True);
}
}
[Test]
public async Task AddNodeRejectsAbstractStandardTypesAsync()
{
using Harness h = CreateHarness(allowNodeManagement: true);
ushort ns = h.Manager.NamespaceIndexes[0];
NodeId parentId = await h.AddObjectAsync("Parent").ConfigureAwait(false);
(ServiceResult variableResult, _) = await h.Manager.AddNodeAsync(
h.OperationContext,
new AddNodesItem
{
ParentNodeId = parentId,
ReferenceTypeId = ReferenceTypeIds.HasComponent,
BrowseName = new QualifiedName("Variable", ns),
NodeClass = NodeClass.Variable,
TypeDefinition = VariableTypeIds.BaseVariableType
}).ConfigureAwait(false);
(ServiceResult eventResult, _) = await h.Manager.AddNodeAsync(
h.OperationContext,
new AddNodesItem
{
ParentNodeId = parentId,
ReferenceTypeId = ReferenceTypeIds.Organizes,
BrowseName = new QualifiedName("Event", ns),
NodeClass = NodeClass.Object,
TypeDefinition = ObjectTypeIds.BaseEventType
}).ConfigureAwait(false);
Assert.That(variableResult.StatusCode, Is.EqualTo(StatusCodes.BadTypeDefinitionInvalid));
Assert.That(eventResult.StatusCode, Is.EqualTo(StatusCodes.BadTypeDefinitionInvalid));
}
[TestCase("BaseDataVariableType")]
[TestCase("PropertyType")]
public async Task AddVariableAcceptsConcreteStandardTypesAsync(string typeName)
{
using Harness h = CreateHarness(allowNodeManagement: true);
ushort ns = h.Manager.NamespaceIndexes[0];
NodeId parentId = await h.AddObjectAsync("Parent").ConfigureAwait(false);
NodeId typeDefinition = typeName == "PropertyType"
? VariableTypeIds.PropertyType
: VariableTypeIds.BaseDataVariableType;
(ServiceResult result, NodeId added) = await h.Manager.AddNodeAsync(
h.OperationContext,
new AddNodesItem
{
ParentNodeId = parentId,
ReferenceTypeId = ReferenceTypeIds.HasComponent,
BrowseName = new QualifiedName("Variable", ns),
NodeClass = NodeClass.Variable,
TypeDefinition = typeDefinition
}).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(result), Is.True, result.ToString());
Assert.That(added.IsNull, Is.False);
}
private static List<ModelChangeStructureDataType> GetReportedModelChanges(Harness h)
{
var changes = new List<ModelChangeStructureDataType>();
foreach (IInvocation invocation in h.MockServer.Invocations)
{
if (invocation.Method.Name != nameof(IServerInternal.ReportEvent))
{
continue;
}
foreach (GeneralModelChangeEventState e in invocation.Arguments.OfType<GeneralModelChangeEventState>())
{
ArrayOf<ModelChangeStructureDataType> reported = e.Changes!.Value;
for (int ii = 0; ii < reported.Count; ii++)
{
changes.Add(reported[ii]);
}
}
}
return changes;
}
private static async Task<NodeId> AddInstanceAsync(Harness h, NodeId parentId, string name, NodeId typeId)
{
ushort ns = h.Manager.NamespaceIndexes[0];
(ServiceResult result, NodeId added) = await h.Manager.AddNodeAsync(
h.OperationContext,
new AddNodesItem
{
ParentNodeId = parentId,
ReferenceTypeId = ReferenceTypeIds.Organizes,
BrowseName = new QualifiedName(name, ns),
NodeClass = NodeClass.Object,
TypeDefinition = typeId
}).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(result), Is.True, result.ToString());
return added;
}
private static BaseInstanceState FindChild(Harness h, NodeState node, string name)
{
var children = new List<BaseInstanceState>();
node.GetChildren(h.Context, children);
return children.FirstOrDefault(c => c.BrowseName.Name == name)!;
}
private static void CollectDescendants(Harness h, NodeState node, List<BaseInstanceState> result)
{
var children = new List<BaseInstanceState>();
node.GetChildren(h.Context, children);
foreach (BaseInstanceState child in children)
{
result.Add(child);
CollectDescendants(h, child, result);
}
}
private static PropertyState CreateDeclaration(NodeState parent, string name, ushort ns, NodeId modellingRule)
{
return new PropertyState(parent)
{
NodeId = new NodeId(parent.BrowseName.Name + "_" + name, ns),
BrowseName = new QualifiedName(name, ns),
DisplayName = new LocalizedText(name),
ReferenceTypeId = ReferenceTypeIds.HasProperty,
TypeDefinitionId = VariableTypeIds.PropertyType,
ModellingRuleId = modellingRule,
DataType = DataTypeIds.Int32,
ValueRank = ValueRanks.Scalar
};
}
/// <summary>
/// Registers ChildType (Mandatory TypeMember), BaseTestType (Mandatory
/// Inherited and Overridden) and TestType, a subtype of BaseTestType
/// with Mandatory MandatoryProp, DoIt and Component (of ChildType, with
/// a Mandatory Nested and an Optional NestedOptional child), an
/// Optional OptionalProp and an Optional override of Overridden.
/// </summary>
private static async Task<NodeId> AddMandatoryTestTypesAsync(Harness h)
{
ushort ns = h.Manager.NamespaceIndexes[0];
var types = (TypeTable)h.Context.TypeTable;
types.AddSubtype(ObjectTypeIds.BaseObjectType, NodeId.Null);
var childTypeId = new NodeId("ChildType", ns);
var baseTypeId = new NodeId("BaseTestType", ns);
var typeId = new NodeId("TestType", ns);
types.AddSubtype(childTypeId, ObjectTypeIds.BaseObjectType);
types.AddSubtype(baseTypeId, ObjectTypeIds.BaseObjectType);
types.AddSubtype(typeId, baseTypeId);
var childType = new BaseObjectTypeState
{
NodeId = childTypeId,
BrowseName = new QualifiedName("ChildType", ns),
SuperTypeId = ObjectTypeIds.BaseObjectType
};
childType.AddChild(CreateDeclaration(childType, "TypeMember", ns, ObjectIds.ModellingRule_Mandatory));
await h.Manager.AddPredefinedNodeAsyncPublic(h.Context, childType).ConfigureAwait(false);
var baseType = new BaseObjectTypeState
{
NodeId = baseTypeId,
BrowseName = new QualifiedName("BaseTestType", ns),
SuperTypeId = ObjectTypeIds.BaseObjectType
};
baseType.AddChild(CreateDeclaration(baseType, "Inherited", ns, ObjectIds.ModellingRule_Mandatory));
baseType.AddChild(CreateDeclaration(baseType, "Overridden", ns, ObjectIds.ModellingRule_Mandatory));
await h.Manager.AddPredefinedNodeAsyncPublic(h.Context, baseType).ConfigureAwait(false);
var type = new BaseObjectTypeState
{
NodeId = typeId,
BrowseName = new QualifiedName("TestType", ns),
SuperTypeId = baseTypeId
};
type.AddChild(CreateDeclaration(type, "MandatoryProp", ns, ObjectIds.ModellingRule_Mandatory));
type.AddChild(CreateDeclaration(type, "OptionalProp", ns, ObjectIds.ModellingRule_Optional));
type.AddChild(CreateDeclaration(type, "Overridden", ns, ObjectIds.ModellingRule_Optional));
type.AddChild(new MethodState(type)
{
NodeId = new NodeId("TestType_DoIt", ns),
BrowseName = new QualifiedName("DoIt", ns),
DisplayName = new LocalizedText("DoIt"),
ReferenceTypeId = ReferenceTypeIds.HasComponent,
ModellingRuleId = ObjectIds.ModellingRule_Mandatory
});
var component = new BaseObjectState(type)
{
NodeId = new NodeId("TestType_Component", ns),
BrowseName = new QualifiedName("Component", ns),
DisplayName = new LocalizedText("Component"),
ReferenceTypeId = ReferenceTypeIds.HasComponent,
TypeDefinitionId = childTypeId,
ModellingRuleId = ObjectIds.ModellingRule_Mandatory
};
component.AddChild(CreateDeclaration(component, "Nested", ns, ObjectIds.ModellingRule_Mandatory));
component.AddChild(CreateDeclaration(component, "NestedOptional", ns, ObjectIds.ModellingRule_Optional));
type.AddChild(component);
await h.Manager.AddPredefinedNodeAsyncPublic(h.Context, type).ConfigureAwait(false);
return typeId;
}
[Test]
public void AddPredefinedNodeSynchronously_RegistersNodeAndChildren()
{
using Harness h = CreateHarness();
ushort ns = h.Manager.NamespaceIndexes[0];
var parent = new BaseObjectState(null)
{
NodeId = new NodeId("SyncParent", ns),
BrowseName = new QualifiedName("SyncParent", ns),
DisplayName = new LocalizedText("SyncParent")
};
var child = new BaseDataVariableState(parent)
{
NodeId = new NodeId("SyncParent_Child", ns),
BrowseName = new QualifiedName("Child", ns),
DisplayName = new LocalizedText("Child"),
DataType = DataTypeIds.Int32,
ValueRank = ValueRanks.Scalar,
ReferenceTypeId = ReferenceTypeIds.HasProperty
};
parent.AddChild(child);
h.Manager.AddPredefinedNodeSynchronouslyPublic(parent);
Assert.That(h.Manager.PredefinedNodes.ContainsKey(parent.NodeId), Is.True);
Assert.That(h.Manager.PredefinedNodes.ContainsKey(child.NodeId), Is.True);
}
[Test]
public void AddPredefinedNodeSynchronously_NullNode_ThrowsArgumentNullException()
{
using Harness h = CreateHarness();
Assert.Throws<System.ArgumentNullException>(
() => h.Manager.AddPredefinedNodeSynchronouslyPublic(null!));
}
[Test]
public void AddPredefinedNodeSynchronously_PropagatesTypeHierarchyAndUpdatesRootNotifier()
{
using Harness h = CreateHarness();
ushort ns = h.Manager.NamespaceIndexes[0];
var parent = new BaseObjectState(null)
{
NodeId = new NodeId("SyncTypeParent", ns),
BrowseName = new QualifiedName("SyncTypeParent", ns),
DisplayName = new LocalizedText("SyncTypeParent"),
IsPartOfTypeHierarchy = true
};
var child = new BaseObjectState(parent)
{
NodeId = new NodeId("SyncTypeParent_Child", ns),
BrowseName = new QualifiedName("Child", ns),
DisplayName = new LocalizedText("Child"),
ReferenceTypeId = ReferenceTypeIds.HasComponent
};
parent.AddChild(child);
// Registering the parent as a root notifier exercises the
// notifier-wiring branch inside IndexPredefinedNode.
h.Manager.SeedRootNotifier(parent.NodeId);
h.Manager.AddPredefinedNodeSynchronouslyPublic(parent);
Assert.That(h.Manager.PredefinedNodes.ContainsKey(child.NodeId), Is.True);
Assert.That(child.IsPartOfTypeHierarchy, Is.True);
Assert.That(
parent.ReferenceExists(ReferenceTypeIds.HasNotifier, true, ObjectIds.Server),
Is.True);
}
private static Harness CreateHarness(bool allowNodeManagement = false)
{
var mockServer = new Mock<IServerInternal>();
var mockLogger = new Mock<ILogger>();
var mockMasterNodeManager = new Mock<IMasterNodeManager>();
var mockConfigurationNodeManager = new Mock<IConfigurationNodeManager>();
var mockSession = new Mock<ISession>();
mockSession.Setup(s => s.EffectiveIdentity).Returns(new Mock<IUserIdentity>().Object);
mockSession.Setup(s => s.PreferredLocales).Returns([]);
var namespaceTable = new NamespaceTable();
namespaceTable.Append(TestNamespaceUri);
mockServer.Setup(s => s.NamespaceUris).Returns(namespaceTable);
mockServer.Setup(s => s.ServerUris).Returns(new StringTable());
mockServer.Setup(s => s.TypeTree).Returns(new TypeTable(namespaceTable));
mockServer.Setup(s => s.Factory).Returns(EncodeableFactory.Create());
mockServer.Setup(s => s.NodeManager).Returns(mockMasterNodeManager.Object);
mockMasterNodeManager.Setup(m => m.ConfigurationNodeManager).Returns(mockConfigurationNodeManager.Object);
var mockTelemetry = new Mock<ITelemetryContext>();
mockServer.Setup(s => s.Telemetry).Returns(mockTelemetry.Object);
var monitoredItemQueueFactory = new MonitoredItemQueueFactory(mockTelemetry.Object);
mockServer.Setup(s => s.MonitoredItemQueueFactory).Returns(monitoredItemQueueFactory);
var serverSystemContext = new ServerSystemContext(mockServer.Object);
mockServer.Setup(s => s.DefaultSystemContext).Returns(serverSystemContext);
var configuration = new ApplicationConfiguration
{
ServerConfiguration = new ServerConfiguration
{
MaxNotificationQueueSize = 100,
MaxDurableNotificationQueueSize = 200
}
};
NodeManagementTestNodeManager manager = allowNodeManagement
? new OptedInTestNodeManager(mockServer.Object, configuration, mockLogger.Object, TestNamespaceUri)
: new NodeManagementTestNodeManager(mockServer.Object, configuration, mockLogger.Object, TestNamespaceUri);
// Ensure cross-NodeManager add-reference path can find local nodes via the master.
mockMasterNodeManager
.Setup(m => m.AddReferencesAsync(
It.IsAny<NodeId>(),
It.IsAny<IList<IReference>>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask());
mockMasterNodeManager
.Setup(m => m.RemoveReferencesAsync(
It.IsAny<List<LocalReference>>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask());
var opContext = new OperationContext(
new RequestHeader(), null!, RequestType.AddNodes, RequestLifetime.None, mockSession.Object);
return new Harness(manager, serverSystemContext, opContext, monitoredItemQueueFactory, mockMasterNodeManager)
{
MockServer = mockServer
};
}
private static IEnumerable<TestCaseData> DimensionValueTypeCases()
{
IEnumerable<TestCaseData>[] cases =
[
CreateDimensionTypeCases(true, BuiltInType.Boolean, DataTypeIds.Boolean),
CreateDimensionTypeCases((sbyte)-7, BuiltInType.SByte, DataTypeIds.SByte),
CreateDimensionTypeCases((byte)7, BuiltInType.Byte, DataTypeIds.Byte),
CreateDimensionTypeCases((short)-17, BuiltInType.Int16, DataTypeIds.Int16),
CreateDimensionTypeCases((ushort)17, BuiltInType.UInt16, DataTypeIds.UInt16),
CreateDimensionTypeCases(17u, BuiltInType.UInt32, DataTypeIds.UInt32),
CreateDimensionTypeCases(-17L, BuiltInType.Int64, DataTypeIds.Int64),
CreateDimensionTypeCases(17UL, BuiltInType.UInt64, DataTypeIds.UInt64),
CreateDimensionTypeCases(1.25f, BuiltInType.Float, DataTypeIds.Float),
CreateDimensionTypeCases(1.25, BuiltInType.Double, DataTypeIds.Double),
CreateDimensionTypeCases("dimension-value", BuiltInType.String, DataTypeIds.String),
CreateDimensionTypeCases(new DateTimeUtc(2024, 1, 2), BuiltInType.DateTime, DataTypeIds.DateTime),
CreateDimensionTypeCases(new Uuid(new Guid("01234567-89ab-cdef-0123-456789abcdef")),
BuiltInType.Guid, DataTypeIds.Guid),
CreateDimensionTypeCases(ByteString.From(1, 2), BuiltInType.ByteString, DataTypeIds.ByteString),
CreateDimensionTypeCases(XmlElement.From("<dimension/>"), BuiltInType.XmlElement,
DataTypeIds.XmlElement),
CreateDimensionTypeCases(new NodeId(17), BuiltInType.NodeId, DataTypeIds.NodeId),
CreateDimensionTypeCases(new ExpandedNodeId(17), BuiltInType.ExpandedNodeId,
DataTypeIds.ExpandedNodeId),
CreateDimensionTypeCases(StatusCodes.BadNoData, BuiltInType.StatusCode, DataTypeIds.StatusCode),
CreateDimensionTypeCases(new QualifiedName("dimension"), BuiltInType.QualifiedName,
DataTypeIds.QualifiedName),
CreateDimensionTypeCases(new LocalizedText("en", "dimension"), BuiltInType.LocalizedText,
DataTypeIds.LocalizedText),
CreateDimensionTypeCases(new ExtensionObject(new Argument { Name = "DimensionValue" }),
BuiltInType.ExtensionObject, DataTypeIds.Structure),
CreateDimensionTypeCases(new DataValue(new Variant(17)), BuiltInType.DataValue, DataTypeIds.DataValue),
CreateDimensionTypeCases(new Variant(17), BuiltInType.Variant, DataTypeIds.BaseDataType),
CreateDimensionTypeCases(new EnumValue(1), BuiltInType.Enumeration, DataTypeIds.Enumeration)
];
return cases.SelectMany(value => value);
}
private static IEnumerable<TestCaseData> CreateDimensionTypeCases<T>(
T seed,
BuiltInType builtInType,
NodeId dataType)
{
ArrayOf<T> values = ArrayOf.Wrapped(seed, seed);
if (!VariantHelper.TryCastFrom(values, out Variant array) ||
!VariantHelper.TryCastFrom(values.ToMatrix(1, 2), out Variant matrix))
{
throw new InvalidOperationException($"Unsupported dimension seed type {typeof(T).Name}.");
}
string test = nameof(AddVariableEnforcesDimensionMaximaForBuiltInTypes);
yield return new TestCaseData(builtInType, array, dataType, (ArrayOf<uint>)[2], true)
.SetName($"{test}({builtInType},Array,AtMaximum)");
yield return new TestCaseData(builtInType, array, dataType, (ArrayOf<uint>)[1], false)
.SetName($"{test}({builtInType},Array,Oversize)");
yield return new TestCaseData(builtInType, matrix, dataType, (ArrayOf<uint>)[1, 2], true)
.SetName($"{test}({builtInType},Matrix,AtMaximum)");
yield return new TestCaseData(builtInType, matrix, dataType, (ArrayOf<uint>)[1, 1], false)
.SetName($"{test}({builtInType},Matrix,Oversize)");
}
private static async Task AssertVariableDimensionAdmissionAsync(
Variant value,
NodeId dataType,
int valueRank,
ArrayOf<uint> dimensions,
bool valid)
{
using Harness h = CreateHarness(allowNodeManagement: true);
var types = (TypeTable)h.Context.TypeTable;
types.AddSubtype(DataTypeIds.BaseDataType, NodeId.Null);
if (dataType == DataTypeIds.Enumeration)
{
types.AddSubtype(DataTypeIds.Int32, DataTypeIds.BaseDataType);
types.AddSubtype(dataType, DataTypeIds.Int32);
}
else if (dataType != DataTypeIds.BaseDataType)
{
types.AddSubtype(dataType, DataTypeIds.BaseDataType);
}
NodeId parentId = await h.AddObjectAsync("Parent").ConfigureAwait(false);
ushort ns = h.Manager.NamespaceIndexes[0];
var requestedId = new NodeId("DimensionedVariable", ns);
var item = new AddNodesItem
{
ParentNodeId = parentId,
ReferenceTypeId = ReferenceTypeIds.HasComponent,
RequestedNewNodeId = requestedId,
BrowseName = new QualifiedName("DimensionedVariable", ns),
NodeClass = NodeClass.Variable,
TypeDefinition = VariableTypeIds.BaseDataVariableType,
NodeAttributes = new ExtensionObject(new VariableAttributes
{
SpecifiedAttributes = (uint)NodeAttributesMask.DataType |
(uint)NodeAttributesMask.ValueRank |
(uint)NodeAttributesMask.Value |
(uint)NodeAttributesMask.ArrayDimensions,
DataType = dataType,
ValueRank = valueRank,
Value = value,
ArrayDimensions = dimensions
})
};
(ServiceResult result, NodeId addedId) = await h.Manager.AddNodeAsync(h.OperationContext, item)
.ConfigureAwait(false);
if (!valid)
{
using (Assert.EnterMultipleScope())
{
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadNodeAttributesInvalid));
Assert.That(addedId.IsNull, Is.True);
Assert.That(h.Manager.PredefinedNodes.ContainsKey(requestedId), Is.False);
Assert.That(h.Manager.PredefinedNodes[parentId].FindChildWithQualifiedName(
h.Context, item.BrowseName), Is.Null);
}
return;
}
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.Good));
Assert.That(addedId, Is.EqualTo(requestedId));
BaseVariableState variable = h.Manager.FindPredefinedNode<BaseVariableState>(addedId);
using (Assert.EnterMultipleScope())
{
Assert.That(variable.DataType, Is.EqualTo(dataType));
Assert.That(variable.ValueRank, Is.EqualTo(valueRank));
Assert.That(variable.Value, Is.EqualTo(value));
Assert.That(variable.Value.TypeInfo, Is.EqualTo(value.TypeInfo));
Assert.That(variable.ArrayDimensions, Is.EqualTo(dimensions));
Assert.That(h.Manager.PredefinedNodes[parentId].FindChildWithQualifiedName(
h.Context, item.BrowseName), Is.SameAs(variable));
}
}
private sealed class Harness : System.IDisposable
{
public NodeManagementTestNodeManager Manager { get; }
public ServerSystemContext Context { get; }
public OperationContext OperationContext { get; }
public Mock<IMasterNodeManager> MockMasterNodeManager { get; }
public Mock<IServerInternal> MockServer { get; set; } = null!;
private readonly MonitoredItemQueueFactory m_queueFactory;
public Harness(
NodeManagementTestNodeManager manager,
ServerSystemContext context,
OperationContext operationContext,
MonitoredItemQueueFactory queueFactory,
Mock<IMasterNodeManager> mockMasterNodeManager)
{
Manager = manager;
Context = context;
OperationContext = operationContext;
m_queueFactory = queueFactory;
MockMasterNodeManager = mockMasterNodeManager;
}
public async ValueTask<NodeId> AddObjectAsync(string name)
{
ushort ns = Manager.NamespaceIndexes[0];
var obj = new BaseObjectState(null);
obj.CreateAsPredefinedNode(Context);
obj.NodeId = new NodeId(name, ns);
obj.BrowseName = new QualifiedName(name, ns);
await Manager.AddPredefinedNodeAsyncPublic(Context, obj).ConfigureAwait(false);
return obj.NodeId;
}
public void Dispose()
{
m_queueFactory.Dispose();
Manager.Dispose();
}
}
private sealed class ConstrainedVariableType : BaseVariableTypeState;
private class NodeManagementTestNodeManager : AsyncCustomNodeManager
{
public NodeManagementTestNodeManager(
IServerInternal server,
ApplicationConfiguration configuration,
ILogger logger,
params string[] namespaceUris)
: base(server, configuration, logger, namespaceUris)
{
}
public new NodeIdDictionary<NodeState> PredefinedNodes => base.PredefinedNodes;
public ValueTask AddPredefinedNodeAsyncPublic(ISystemContext context, NodeState node, CancellationToken ct = default)
{
return AddPredefinedNodeAsync(context, node, ct);
}
public void AddPredefinedNodeSynchronouslyPublic(NodeState node)
{
AddPredefinedNodeSynchronously(node);
}
public void SeedRootNotifier(NodeId nodeId)
{
RootNotifiers[nodeId] = null!;
}
}
private sealed class OptedInTestNodeManager : NodeManagementTestNodeManager
{
public OptedInTestNodeManager(
IServerInternal server,
ApplicationConfiguration configuration,
ILogger logger,
params string[] namespaceUris)
: base(server, configuration, logger, namespaceUris)
{
}
public override bool AllowNodeManagement => true;
}
}
}