Auto: opcuaclient-8 — type definition mirroring

Adds an opt-in pass-3 walk of the upstream TypesFolder (i=86) so the OPC UA
Client driver can mirror upstream type definitions into the local address
space. Honours the curation rules from PR-7 (#359). Structural mirror only —
binary-encoding priming via LoadDataTypeSystem is tracked as a follow-up
because that helper was removed from the public ISession surface in
OPCFoundation.NetStandard 1.5.378+.

- IAddressSpaceBuilder.RegisterTypeNode (default no-op for back-compat)
- MirroredTypeNodeInfo + MirroredTypeKind in Core.Abstractions
- OpcUaClientDriverOptions.MirrorTypeDefinitions (default false)
- DiscoverAsync pass-3: FetchTypeTreeAsync + recursive HasSubtype walk per
  branch (ObjectTypes/VariableTypes/DataTypes/ReferenceTypes), best-effort
  IsAbstract read, IncludePaths/ExcludePaths still applied
- 6 new unit tests; all 153 OpcUaClient unit tests pass

Closes #280
This commit is contained in:
Joseph Doherty
2026-04-25 20:38:17 -04:00
parent 5e164dc965
commit cc21281cbb
4 changed files with 457 additions and 0 deletions

View File

@@ -0,0 +1,127 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// Unit tests for the type-mirror surface added in PR-8 (Issue #280). Live-browse
/// coverage of the Pass-3 walk against an upstream server lands in integration tests
/// against opc-plc; these unit assertions cover the option default, the new
/// <see cref="MirroredTypeNodeInfo"/> shape, and the back-compat default no-op for
/// <see cref="IAddressSpaceBuilder.RegisterTypeNode"/>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientTypeMirrorTests
{
[Fact]
public void MirrorTypeDefinitions_default_is_false_so_existing_deployments_are_unchanged()
{
var opts = new OpcUaClientDriverOptions();
opts.MirrorTypeDefinitions.ShouldBeFalse(
"opt-in flag — existing deployments shouldn't suddenly see a flood of type nodes");
}
[Fact]
public void MirrorTypeDefinitions_can_be_enabled_via_init()
{
var opts = new OpcUaClientDriverOptions { MirrorTypeDefinitions = true };
opts.MirrorTypeDefinitions.ShouldBeTrue();
}
[Fact]
public void MirroredTypeNodeInfo_carries_kind_identity_supertype_and_abstract_flag()
{
var info = new MirroredTypeNodeInfo(
Kind: MirroredTypeKind.ObjectType,
UpstreamNodeId: "ns=2;i=1234",
BrowseName: "MyMachine",
DisplayName: "My Machine",
SuperTypeNodeId: "i=58", // BaseObjectType
IsAbstract: false);
info.Kind.ShouldBe(MirroredTypeKind.ObjectType);
info.UpstreamNodeId.ShouldBe("ns=2;i=1234");
info.BrowseName.ShouldBe("MyMachine");
info.DisplayName.ShouldBe("My Machine");
info.SuperTypeNodeId.ShouldBe("i=58");
info.IsAbstract.ShouldBeFalse();
}
[Fact]
public void MirroredTypeKind_covers_all_four_OPC_UA_type_categories()
{
// The four standard OPC UA type-folder children — ObjectTypes (i=88),
// VariableTypes (i=89), DataTypes (i=90), ReferenceTypes (i=91). Asserts the enum
// shape rather than the values so the test catches accidental category renames.
Enum.GetValues<MirroredTypeKind>().Length.ShouldBe(4);
Enum.IsDefined(MirroredTypeKind.ObjectType).ShouldBeTrue();
Enum.IsDefined(MirroredTypeKind.VariableType).ShouldBeTrue();
Enum.IsDefined(MirroredTypeKind.DataType).ShouldBeTrue();
Enum.IsDefined(MirroredTypeKind.ReferenceType).ShouldBeTrue();
}
[Fact]
public void RegisterTypeNode_default_implementation_is_no_op_so_existing_builders_dont_break()
{
// Builders that only implement Folder + Variable + AddProperty (i.e. the pre-PR-8
// contract) should continue to work — the default no-op on RegisterTypeNode means
// calling it on a minimal builder doesn't throw.
IAddressSpaceBuilder builder = new MinimalBuilder();
var info = new MirroredTypeNodeInfo(MirroredTypeKind.DataType,
UpstreamNodeId: "i=290", BrowseName: "Duration", DisplayName: "Duration",
SuperTypeNodeId: "i=290", IsAbstract: false);
Should.NotThrow(() => builder.RegisterTypeNode(info));
}
[Fact]
public void RegisterTypeNode_can_be_overridden_so_OPC_UA_server_builders_record_the_call()
{
// The OPC UA server-side DriverNodeManager will override RegisterTypeNode to actually
// project the type into the local address space. This test models that contract with
// a recording stub.
var recorder = new RecordingTypeBuilder();
IAddressSpaceBuilder builder = recorder;
builder.RegisterTypeNode(new MirroredTypeNodeInfo(
MirroredTypeKind.ObjectType, "ns=2;i=1", "T1", "T1", null, false));
builder.RegisterTypeNode(new MirroredTypeNodeInfo(
MirroredTypeKind.VariableType, "ns=2;i=2", "V1", "V1", "i=63", true));
recorder.Calls.Count.ShouldBe(2);
recorder.Calls[0].Kind.ShouldBe(MirroredTypeKind.ObjectType);
recorder.Calls[1].IsAbstract.ShouldBeTrue();
recorder.Calls[1].SuperTypeNodeId.ShouldBe("i=63");
}
/// <summary>
/// Minimum back-compat implementation — only the pre-PR-8 surface is overridden.
/// Verifies the default-implementation no-op for <c>RegisterTypeNode</c> doesn't break
/// existing builders.
/// </summary>
private sealed class MinimalBuilder : IAddressSpaceBuilder
{
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
=> new StubHandle();
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
private sealed class StubHandle : IVariableHandle
{
public string FullReference => "stub";
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
=> throw new NotSupportedException();
}
}
private sealed class RecordingTypeBuilder : IAddressSpaceBuilder
{
public List<MirroredTypeNodeInfo> Calls { get; } = new();
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
=> throw new NotSupportedException();
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
public void RegisterTypeNode(MirroredTypeNodeInfo info) => Calls.Add(info);
}
}