Threads tag/UDT-member descriptions captured by the L5K (#346) and L5X (#347) parsers through AbCipTagDefinition + AbCipStructureMember into DriverAttributeInfo, so the address-space builder sets the OPC UA Description attribute on each Variable node. L5kMember and L5xParser also now capture per-member descriptions (via the (Description := "...") attribute block on L5K and the <Description> child on L5X), and L5kIngest forwards them. DriverNodeManager surfaces DriverAttributeInfo.Description as the Variable's Description property. Description is added as a trailing optional parameter on DriverAttributeInfo (default null) so every other driver continues to construct the record unchanged. Closes #231
180 lines
6.7 KiB
C#
180 lines
6.7 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
|
|
|
/// <summary>
|
|
/// Task #231 — verifies that tag/member descriptions parsed from L5K and L5X exports thread
|
|
/// through <see cref="AbCipTagDefinition.Description"/> /
|
|
/// <see cref="AbCipStructureMember.Description"/> + land on
|
|
/// <see cref="DriverAttributeInfo.Description"/> on the produced address-space variables, so
|
|
/// downstream OPC UA Variable nodes carry the source-project comment as their Description
|
|
/// attribute.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class AbCipDescriptionThreadingTests
|
|
{
|
|
private const string DeviceHost = "ab://10.0.0.5/1,0";
|
|
|
|
[Fact]
|
|
public void L5kParser_captures_member_description_from_attribute_block()
|
|
{
|
|
const string body = """
|
|
DATATYPE MyUdt
|
|
MEMBER Speed : DINT (Description := "Belt speed in RPM");
|
|
END_DATATYPE
|
|
""";
|
|
var doc = L5kParser.Parse(new StringL5kSource(body));
|
|
|
|
var member = doc.DataTypes.Single().Members.Single();
|
|
member.Name.ShouldBe("Speed");
|
|
member.Description.ShouldBe("Belt speed in RPM");
|
|
}
|
|
|
|
[Fact]
|
|
public void L5xParser_captures_member_description_child_node()
|
|
{
|
|
const string xml = """
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<RSLogix5000Content>
|
|
<Controller>
|
|
<DataTypes>
|
|
<DataType Name="MyUdt">
|
|
<Members>
|
|
<Member Name="Speed" DataType="DINT" Dimension="0" ExternalAccess="Read/Write">
|
|
<Description><![CDATA[Belt speed in RPM]]></Description>
|
|
</Member>
|
|
</Members>
|
|
</DataType>
|
|
</DataTypes>
|
|
</Controller>
|
|
</RSLogix5000Content>
|
|
""";
|
|
var doc = L5xParser.Parse(new StringL5kSource(xml));
|
|
|
|
doc.DataTypes.Single().Members.Single().Description.ShouldBe("Belt speed in RPM");
|
|
}
|
|
|
|
[Fact]
|
|
public void L5kIngest_threads_tag_and_member_descriptions_into_AbCipTagDefinition()
|
|
{
|
|
const string body = """
|
|
DATATYPE MotorBlock
|
|
MEMBER Speed : DINT (Description := "Setpoint RPM");
|
|
MEMBER Status : DINT;
|
|
END_DATATYPE
|
|
TAG
|
|
Motor1 : MotorBlock (Description := "Conveyor motor 1") := [];
|
|
END_TAG
|
|
""";
|
|
var doc = L5kParser.Parse(new StringL5kSource(body));
|
|
|
|
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
|
|
|
|
var tag = result.Tags.Single();
|
|
tag.Description.ShouldBe("Conveyor motor 1");
|
|
tag.Members.ShouldNotBeNull();
|
|
var members = tag.Members!.ToDictionary(m => m.Name);
|
|
members["Speed"].Description.ShouldBe("Setpoint RPM");
|
|
members["Status"].Description.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DiscoverAsync_sets_Description_on_DriverAttributeInfo_for_atomic_tag()
|
|
{
|
|
var builder = new RecordingBuilder();
|
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
|
{
|
|
Devices = [new AbCipDeviceOptions(DeviceHost)],
|
|
Tags =
|
|
[
|
|
new AbCipTagDefinition(
|
|
Name: "Speed",
|
|
DeviceHostAddress: DeviceHost,
|
|
TagPath: "Motor1.Speed",
|
|
DataType: AbCipDataType.DInt,
|
|
Description: "Belt speed in RPM"),
|
|
new AbCipTagDefinition(
|
|
Name: "NoDescription",
|
|
DeviceHostAddress: DeviceHost,
|
|
TagPath: "X",
|
|
DataType: AbCipDataType.DInt),
|
|
],
|
|
}, "drv-1");
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
await drv.DiscoverAsync(builder, CancellationToken.None);
|
|
|
|
builder.Variables.Single(v => v.BrowseName == "Speed").Info.Description
|
|
.ShouldBe("Belt speed in RPM");
|
|
// Tags without descriptions leave Info.Description null (back-compat path).
|
|
builder.Variables.Single(v => v.BrowseName == "NoDescription").Info.Description
|
|
.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DiscoverAsync_sets_Description_on_DriverAttributeInfo_for_UDT_members()
|
|
{
|
|
var builder = new RecordingBuilder();
|
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
|
{
|
|
Devices = [new AbCipDeviceOptions(DeviceHost)],
|
|
Tags =
|
|
[
|
|
new AbCipTagDefinition(
|
|
Name: "Motor1",
|
|
DeviceHostAddress: DeviceHost,
|
|
TagPath: "Motor1",
|
|
DataType: AbCipDataType.Structure,
|
|
Members:
|
|
[
|
|
new AbCipStructureMember(
|
|
Name: "Speed",
|
|
DataType: AbCipDataType.DInt,
|
|
Description: "Setpoint RPM"),
|
|
new AbCipStructureMember(
|
|
Name: "Status",
|
|
DataType: AbCipDataType.DInt),
|
|
]),
|
|
],
|
|
}, "drv-1");
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
await drv.DiscoverAsync(builder, CancellationToken.None);
|
|
|
|
builder.Variables.Single(v => v.BrowseName == "Speed").Info.Description
|
|
.ShouldBe("Setpoint RPM");
|
|
builder.Variables.Single(v => v.BrowseName == "Status").Info.Description
|
|
.ShouldBeNull();
|
|
}
|
|
|
|
// ---- 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) { }
|
|
}
|
|
}
|
|
}
|