Files
Marc Schier 1bb4bdca1d Add Machine Vision Parts 1 and 2 (#4733)
# Description

Implement the released [OPC
40100-1](https://reference.opcfoundation.org/specs/OPC-40100-1) and [OPC
40100-2](https://reference.opcfoundation.org/specs/OPC-40100-2)
specifications with generated models, provider-backed runtimes, typed
clients, MCP workflows, and DI/Machinery integration.

- **Part 1 stays in `Opc.Ua.Vision`** and its client/server packages
alongside the distinct Perception model. Provide control/acquisition
state machines, jobs and synchronized steps,
recipe/product/configuration/result management, file transfers,
diagnostics, events, and conditions.
- **Part 2 is in `Opc.Ua.MachineVision`, `.Client`, and `.Server`.**
Provide asset categories, identification, image-handling aspects,
health/maintenance data, topology, and transactional updates. The base
Vision assemblies do not reference these optional packages; the Part 2
wire model does not depend on Part 1.
- Preserve the release-pinned model bytes, wire namespaces and NodeIds.
Perception types are in `Opc.Ua.Vision.Perception`; the AMCM generated
namespace remains `Opc.Ua.Vision.MachineVision.AMCM`, and asset
client/server extension namespaces remain `Opc.Ua.Vision.Client` and
`Opc.Ua.Vision.Server`.
- Use `AddOpcUaMachineVision` and `AddOpcUaMachineVisionAMCM` for model
loading and factory registration. Each has node-collection,
encodeable-factory-builder and node-state-factory-builder overloads.
These short entry points delegate to the generated implementations;
existing namespace-derived generated names remain available for
compatibility.
- Support multiple independent named systems/assets under one
namespace-owning manager. Reuse injectable providers, file binding,
bounded memory stores, optional transactional `IFileSystemProvider`
persistence, state machines, telemetry and conformance infrastructure.
- Keep one `docs/Vision.md` guide, titled `Machine Vision (OPC 40100-1
and OPC 40100-2)`, and one `Opc.Ua.Mcp.Vision` package. Include
deterministic server/client samples.
- Expose 17 Machine Vision MCP tools, including capabilities/status,
bounded relationships and observation, complete small-file
uploads/downloads, management, explicit control, conditions, and chunked
transfers. Catalogue counts: 39 Vision-specific, 43 with connection
tools, 81 Vision+Robotics, 155 Full. Every Machine Vision wire parameter
has a schema description.

**Compatibility and limits:** `AddVision()` remains Perception-only
unless released parts are selected. Standalone Part 2 uses
`AddVision().AddMachineVision()`; Machinery composition uses
`AddVisionToMachinery()`. Existing tool defaults and command signatures
are preserved. Observation owns and cleans up its subscription: 5
seconds/50 notifications by default, capped at 60 seconds/100
notifications. Complete-file tools are capped at 1 MiB; larger files use
chunked transfers. Domain Error values remain distinct from UA service
failures.

**Versioning:** the seven Vision/MachineVision/MCP packages and their
Debug variants follow the repository root version instead of being
forced to preview. Vision.OpenUsd and its descendants remain
preview-only. Development builds still inherit the root prerelease
version. No release version, tag, release branch, publication policy, or
feed promotion is changed.

**Review actions:** all fourteen review threads have action/evidence
replies and are resolved. Changes include the unified guide, Perception
namespace, separate Part 2 packages and final naming, multi-system
ownership explanation, interrupted-write cleanup, observer failure
isolation, complete MCP parameter descriptions, and explicit package
inventory. The preview-layout migration topic and the requested
ReleaseProcess caveat were removed. The test Publish collector now
retries recognized transient failures within one cancellation budget and
propagates other exceptions. Commit 69b1f5db9 applies the requested
short loader names and renames standalone hosting to `AddMachineVision`,
with callers, tests and documentation updated. The intervening master
merge is preserved.

### Verification

**Latest API naming changes, verified locally:**

| Scope | Result |
| --- | --- |
| Full Vision suite, net10 | 572 passed, 1 existing skip, 0 failed |
| Full Vision suite, net48 | 515 passed, 0 skipped, 0 failed |
| Final loader/hosting cases after cross-TFM null-guard adjustment | 9
passed, 0 failed, 0 skipped |
| Machine Vision MCP and profile cases | 198 passed, 0 failed, 0 skipped
|
| Main and MCP AOT test-project compilation | Both built with 0
warnings/errors |
| MCP AOT companion, managed execution | 5 passed, 0 failed, 0 skipped;
not claimed as native execution |

`ModelLoaderNamesRegisterTypedFactories`,
`ModelLoaderNamesRejectNullArguments`, and
`GeneratedModelsLoadTogetherAndCreateTypedInstances` cover all six
loader overloads, fluent return identities, typed instances, namespace
coexistence and null guards. Existing standalone Part 2 tests verify
idempotent registration, one-owner enforcement and null handling through
`AddMachineVision`. No source-generator implementation, CLR namespace,
wire identity or model payload was changed for these naming updates.

The current 69b1f5db9 revision is running
[CI](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38165826937),
[package
validation](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38165823359),
[MCP
usability](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38165826874),
[CodeQL](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38165826906),
and [Images
CI](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38165826884).
These runs are not claimed as completed results.

**Earlier completed verification:** all seven default-TFM package
archives were inspected after the package rename: exact IDs, 27 clean
DLLs, correct dependencies/readmes/licenses, and unchanged pinned model
hashes. [CI on
c75ec4fa6](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38142519760)
passed, including the formerly failing Linux batch and required summary.
Its unchanged coverage gate passed at **88.56% lines, 78.96% branches,
84.28% patch** against the 75% patch floor. The later
bounded-Publish-helper regressions reproduced 13 failures before the fix
and passed afterward; the full mainline triggering fixture passed 67
tests on each of net10/net48. These are historical results, not current
full-matrix results.

<details>
<summary>Executed API naming validation commands and outcomes</summary>

Run from the worktree root using the established artifact trees.
Dependency builds were performed before the no-build full net10 run; the
focused loader/hosting selection was rebuilt and rerun after the final
null-guard adjustment.

```powershell
$files = 'C:\Users\mschier\.copilot\session-state\deb2ba10-b0e9-4a44-8783-8331bc850814\files'
$env:DOTNET_CLI_USE_MSBUILD_SERVER = '0'
$env:TEMP = 'C:\Temp\mv-deb2\followup'
$env:TMP = $env:TEMP

# Passed: 572; Failed: 0; Skipped: 1.
dotnet test tests\Opc.Ua.Vision.Tests\Opc.Ua.Vision.Tests.csproj -c Release -f net10.0 --no-build --no-restore --artifacts-path artifacts\vision-followup -p:CustomTestTarget=net10.0 -p:UseSharedCompilation=false -m:1 -nr:false --logger 'console;verbosity=minimal' --logger trx --results-directory "$files\vision-api-names-net10" -- 'NUnit.WorkDirectory=C:\Temp\mv-deb2\followup'

# Passed: 9; Failed: 0; Skipped: 0, on the final source.
dotnet test tests\Opc.Ua.Vision.Tests\Opc.Ua.Vision.Tests.csproj -c Release -f net10.0 --no-restore --artifacts-path artifacts\vision-followup -p:CustomTestTarget=net10.0 -p:UseSharedCompilation=false -m:1 -nr:false --filter 'FullyQualifiedName~MachineVisionModelTests|FullyQualifiedName~StandalonePartTwoRegistrationUsesOneOwnerAndOneModelProvider|FullyQualifiedName~StandalonePartTwoRequiresRegistrationAndRejectsCompetingOwnership' --logger 'console;verbosity=minimal' --logger trx --results-directory "$files\vision-api-names-final-model" -- 'NUnit.WorkDirectory=C:\Temp\mv-deb2\followup'

# Passed: 198; Failed: 0; Skipped: 0.
dotnet test tests\Opc.Ua.Tools.Tests\Opc.Ua.Tools.Tests.csproj -c Release -f net10.0 --no-restore --artifacts-path artifacts\vision-followup -p:CustomTestTarget=net10.0 -p:UseSharedCompilation=false -m:1 -nr:false --filter 'FullyQualifiedName~VisionMachineToolsTests|FullyQualifiedName~McpHostBuilderTests|FullyQualifiedName~McpToolProfileCompositionTests' --logger 'console;verbosity=minimal' --logger trx --results-directory "$files\vision-api-names-final-mcp" -- 'NUnit.WorkDirectory=C:\Temp\mv-deb2\followup'

$env:TEMP = 'C:\Temp\mv-deb2\followup48'
$env:TMP = $env:TEMP

# Passed: 515; Failed: 0; Skipped: 0.
dotnet test tests\Opc.Ua.Vision.Tests\Opc.Ua.Vision.Tests.csproj -c Debug -f net48 --no-restore --artifacts-path "$files\solution-net48-artifacts" -p:CustomTestTarget=net48 -p:UseSharedCompilation=false -m:1 -nr:false --logger 'console;verbosity=minimal' --logger trx --results-directory "$files\vision-api-names-net48-final" -- 'NUnit.WorkDirectory=C:\Temp\mv-deb2\followup48'
```

Opt-in long-running/stress/durable tiers have not been rerun locally;
the unqualified **all tests** checklist item remains open. Original
implementation matrix counts are not presented as current results.

</details>

## Related Issues

- Fixes #4732.

The requester reviewed and approved the implementation plan and
subsequent packaging/API decisions in the development session. The
tracking issue records the design; maintainer architectural
discussion/sign-off there is still pending. This PR remains a draft.

## 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.
- [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.
- [x] I have addressed **all** PR feedback received.
2026-10-12 07:01:27 +00:00

1278 lines
57 KiB
C#

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