Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUdtMemberTests.cs
Joseph Doherty b06a1ba607 AB CIP PR 6 — UDT member-declaration support. Declaration-driven UDT member fan-out — users declare a UDT-typed tag once with an explicit Members list and the driver (1) expands member-addressable tags synthetically at Initialize time so Read/Write/Subscribe hit individual native tags per member, (2) emits a folder + one Variable per member in DiscoverAsync instead of a single opaque Structure Variable. Matches the Logix 5000 addressing convention where members are reached via dotted syntax (Motor1.Speed, Motor1.Running) — AbCipTagPath already parsed this shape in PR 2, so PR 6 just had to wire config→TagPath composition. New AbCipStructureMember record — Name / DataType / Writable / WriteIdempotent — plus optional Members list on AbCipTagDefinition that's ignored for atomic types and optional for Structure types. When Structure has null or empty Members the driver falls back to emitting a single opaque Variable so downstream config can address members manually (the "black box" path documented in AbCipTagDefinition's docstring). AbCipDriver.InitializeAsync now iterates tags + for every Structure tag with non-empty Members synthesises a child AbCipTagDefinition per member (composed full-reference Parent.Member + composed TagPath parent.member passed through to libplctag as a normal symbolic read). Per-member Writable/WriteIdempotent metadata propagates so IWritable correctly rejects writes to members flagged non-writable even when the parent tag is writable — each member stands alone from the resilience + authz perspective. DiscoverAsync gains a matching branch — Structure with Members emits an intermediate folder named after the parent tag + one Variable per member under it (browse name = member.Name, FullName = Parent.Member). Members with Writable=false surface SecurityClassification.ViewOnly, WriteIdempotent flag passes through to the DriverAttributeInfo. Structure without Members falls through to the normal single-Variable path. Whole-UDT read optimization (one libplctag call returns the packed buffer + client-side member decode) is deferred — needs the CIP Template Object class 0x6C reader which is blocked on the same libplctag 1.5.2 TagInfoPlcMapper gap that deferred the real @tags walker in PR 5. AbCipTemplateCache shipped in PR 5 is the drop-in point when that reader lands. Per-member reads today are N native round-trips; whole-UDT optimisation is a perf win, not a correctness gap. 7 new unit tests in AbCipUdtMemberTests — UDT fan-out to Variable children under folder with correct SecurityClassification + WriteIdempotent propagation, member reads via synthesised full-reference with correct per-member values, member writes routing to correct TagPath, member Writable=false flag correctly blocking IWritable, Structure without Members falls back to single Variable, empty Members list treated identically to null, UDT tags coexist with flat tags in the discovery output. Total AbCip unit tests now 130/130 passing (+7 from PR 5's 123). Modbus + other drivers untouched; full solution builds 0 errors. Unblocks PR 7 (ISubscribable) — the poll engine already works with member-level full references.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 17:09:06 -04:00

218 lines
8.7 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipUdtMemberTests
{
[Fact]
public async Task UDT_with_declared_members_fans_out_to_member_variables()
{
var builder = new RecordingBuilder();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
new AbCipTagDefinition(
Name: "Motor1",
DeviceHostAddress: "ab://10.0.0.5/1,0",
TagPath: "Motor1",
DataType: AbCipDataType.Structure,
Members:
[
new AbCipStructureMember("Speed", AbCipDataType.DInt),
new AbCipStructureMember("Running", AbCipDataType.Bool, Writable: false),
new AbCipStructureMember("SetPoint", AbCipDataType.Real, WriteIdempotent: true),
]),
],
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "Motor1");
var variables = builder.Variables.Select(v => (v.BrowseName, v.Info.FullName)).ToList();
variables.ShouldContain(("Speed", "Motor1.Speed"));
variables.ShouldContain(("Running", "Motor1.Running"));
variables.ShouldContain(("SetPoint", "Motor1.SetPoint"));
builder.Variables.Single(v => v.BrowseName == "Running").Info.SecurityClass
.ShouldBe(SecurityClassification.ViewOnly);
builder.Variables.Single(v => v.BrowseName == "SetPoint").Info.WriteIdempotent
.ShouldBeTrue();
}
[Fact]
public async Task UDT_members_resolvable_for_read_via_synthesised_full_reference()
{
var factory = new FakeAbCipTagFactory
{
Customise = p => p.TagName switch
{
"Motor1.Speed" => new FakeAbCipTag(p) { Value = 1800 },
"Motor1.Running" => new FakeAbCipTag(p) { Value = true },
_ => new FakeAbCipTag(p),
},
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure,
Members:
[
new AbCipStructureMember("Speed", AbCipDataType.DInt),
new AbCipStructureMember("Running", AbCipDataType.Bool),
]),
],
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var snapshots = await drv.ReadAsync(["Motor1.Speed", "Motor1.Running"], CancellationToken.None);
snapshots[0].Value.ShouldBe(1800);
snapshots[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
snapshots[1].Value.ShouldBe(true);
snapshots[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
}
[Fact]
public async Task UDT_member_write_routes_through_synthesised_tagpath()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure,
Members:
[
new AbCipStructureMember("SetPoint", AbCipDataType.Real),
]),
],
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("Motor1.SetPoint", 42.5f)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
factory.Tags["Motor1.SetPoint"].Value.ShouldBe(42.5f);
}
[Fact]
public async Task UDT_member_read_write_honours_member_Writable_flag()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure,
Members:
[
new AbCipStructureMember("Status", AbCipDataType.DInt, Writable: false),
]),
],
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("Motor1.Status", 1)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
}
[Fact]
public async Task Structure_tag_without_members_is_emitted_as_single_variable()
{
// Fallback path: a Structure tag with no declared Members still appears as a Variable so
// downstream configuration can address it manually. This matches the "black box" note in
// AbCipTagDefinition's docstring.
var builder = new RecordingBuilder();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbCipTagDefinition("OpaqueUdt", "ab://10.0.0.5/1,0", "OpaqueUdt", AbCipDataType.Structure)],
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.ShouldContain(v => v.BrowseName == "OpaqueUdt");
builder.Folders.ShouldNotContain(f => f.BrowseName == "OpaqueUdt");
}
[Fact]
public async Task Empty_Members_list_is_treated_like_null()
{
var builder = new RecordingBuilder();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbCipTagDefinition("EmptyUdt", "ab://10.0.0.5/1,0", "E", AbCipDataType.Structure, Members: [])],
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldNotContain(f => f.BrowseName == "EmptyUdt");
builder.Variables.ShouldContain(v => v.BrowseName == "EmptyUdt");
}
[Fact]
public async Task UDT_members_mixed_with_flat_tags_coexist()
{
var builder = new RecordingBuilder();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
new AbCipTagDefinition("FlatA", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt),
new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure,
Members:
[
new AbCipStructureMember("Speed", AbCipDataType.DInt),
]),
new AbCipTagDefinition("FlatB", "ab://10.0.0.5/1,0", "B", AbCipDataType.Real),
],
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.Select(v => v.BrowseName).ShouldBe(["FlatA", "Speed", "FlatB"], ignoreOrder: true);
}
// ---- helpers ----
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
public void AddProperty(string _, DriverDataType __, object? ___) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
}
}