AOI-aware browse paths: AOI instances now fan out under directional sub-folders (Inputs/, Outputs/, InOut/) instead of a flat layout. The sub-folders only appear when at least one member carries a non-Local AoiQualifier, so plain UDT tags keep the pre-2.6 flat structure. - Add AoiQualifier enum (Local / Input / Output / InOut) + new property on AbCipStructureMember (defaults to Local). - L5K parser learns ADD_ON_INSTRUCTION_DEFINITION blocks; PARAMETER entries' Usage attribute flows through L5kMember.Usage. - L5X parser captures the Usage attribute on <Parameter> elements. - L5kIngest maps Usage strings (Input/Output/InOut) to AoiQualifier; null + unknown values map to Local. - AbCipDriver.DiscoverAsync groups directional members under Inputs / Outputs / InOut sub-folders when any member is non-Local. - Tests for L5K AOI block parsing, L5X Usage capture, ingest mapping (both formats), and AOI-vs-plain UDT discovery fan-out. Closes #234
260 lines
10 KiB
C#
260 lines
10 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
|
|
|
[Trait("Category", "Unit")]
|
|
public sealed class L5xParserTests
|
|
{
|
|
[Fact]
|
|
public void Controller_scope_Tag_elements_parse_with_metadata()
|
|
{
|
|
const string body = """
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<RSLogix5000Content SchemaRevision="1.0" SoftwareRevision="32.00">
|
|
<Controller Name="MyController" ProcessorType="1756-L83E">
|
|
<Tags>
|
|
<Tag Name="Motor1_Speed" TagType="Base" DataType="DINT" ExternalAccess="Read/Write">
|
|
<Description><![CDATA[Motor 1 set point]]></Description>
|
|
</Tag>
|
|
<Tag Name="Tank_Level" TagType="Base" DataType="REAL" ExternalAccess="Read Only" />
|
|
</Tags>
|
|
</Controller>
|
|
</RSLogix5000Content>
|
|
""";
|
|
|
|
var doc = L5xParser.Parse(new StringL5kSource(body));
|
|
|
|
doc.Tags.Count.ShouldBe(2);
|
|
doc.Tags[0].Name.ShouldBe("Motor1_Speed");
|
|
doc.Tags[0].DataType.ShouldBe("DINT");
|
|
doc.Tags[0].ExternalAccess.ShouldBe("Read/Write");
|
|
doc.Tags[0].Description.ShouldBe("Motor 1 set point");
|
|
doc.Tags[0].ProgramScope.ShouldBeNull();
|
|
doc.Tags[0].AliasFor.ShouldBeNull();
|
|
|
|
doc.Tags[1].Name.ShouldBe("Tank_Level");
|
|
doc.Tags[1].ExternalAccess.ShouldBe("Read Only");
|
|
}
|
|
|
|
[Fact]
|
|
public void Program_scope_Tag_elements_carry_program_name()
|
|
{
|
|
const string body = """
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<RSLogix5000Content>
|
|
<Controller Name="C">
|
|
<Programs>
|
|
<Program Name="MainProgram" Class="Standard">
|
|
<Tags>
|
|
<Tag Name="StepIndex" TagType="Base" DataType="DINT" />
|
|
<Tag Name="Running" TagType="Base" DataType="BOOL" />
|
|
</Tags>
|
|
</Program>
|
|
</Programs>
|
|
</Controller>
|
|
</RSLogix5000Content>
|
|
""";
|
|
|
|
var doc = L5xParser.Parse(new StringL5kSource(body));
|
|
|
|
doc.Tags.Count.ShouldBe(2);
|
|
doc.Tags.ShouldAllBe(t => t.ProgramScope == "MainProgram");
|
|
doc.Tags.Select(t => t.Name).ShouldBe(["StepIndex", "Running"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Alias_tag_carries_AliasFor_and_is_skipped_on_ingest()
|
|
{
|
|
const string body = """
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<RSLogix5000Content>
|
|
<Controller Name="C">
|
|
<Tags>
|
|
<Tag Name="Real" TagType="Base" DataType="DINT" />
|
|
<Tag Name="Aliased" TagType="Alias" AliasFor="Real" ExternalAccess="Read/Write" />
|
|
</Tags>
|
|
</Controller>
|
|
</RSLogix5000Content>
|
|
""";
|
|
|
|
var doc = L5xParser.Parse(new StringL5kSource(body));
|
|
var alias = doc.Tags.Single(t => t.Name == "Aliased");
|
|
alias.AliasFor.ShouldBe("Real");
|
|
|
|
var ingestResult = new L5kIngest { DefaultDeviceHostAddress = "ab://10.0.0.1/0,1" }.Ingest(doc);
|
|
ingestResult.SkippedAliasCount.ShouldBe(1);
|
|
ingestResult.Tags.ShouldAllBe(t => t.Name != "Aliased");
|
|
}
|
|
|
|
[Fact]
|
|
public void DataType_block_collects_member_elements_and_skips_hidden_zzzz_host()
|
|
{
|
|
const string body = """
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<RSLogix5000Content>
|
|
<Controller Name="C">
|
|
<DataTypes>
|
|
<DataType Name="TankUDT" Class="User">
|
|
<Members>
|
|
<Member Name="ZZZZZZZZZZTankUDT0" DataType="SINT" Hidden="true" />
|
|
<Member Name="Level" DataType="REAL" ExternalAccess="Read/Write" />
|
|
<Member Name="Active" DataType="BIT" Target="ZZZZZZZZZZTankUDT0" BitNumber="0" />
|
|
</Members>
|
|
</DataType>
|
|
</DataTypes>
|
|
</Controller>
|
|
</RSLogix5000Content>
|
|
""";
|
|
|
|
var doc = L5xParser.Parse(new StringL5kSource(body));
|
|
|
|
doc.DataTypes.Count.ShouldBe(1);
|
|
var udt = doc.DataTypes[0];
|
|
udt.Name.ShouldBe("TankUDT");
|
|
udt.Members.Count.ShouldBe(2);
|
|
udt.Members.ShouldContain(m => m.Name == "Level" && m.DataType == "REAL");
|
|
udt.Members.ShouldContain(m => m.Name == "Active");
|
|
}
|
|
|
|
[Fact]
|
|
public void UDT_typed_tag_picks_up_member_layout_through_ingest()
|
|
{
|
|
const string body = """
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<RSLogix5000Content>
|
|
<Controller Name="C">
|
|
<DataTypes>
|
|
<DataType Name="TankUDT" Class="User">
|
|
<Members>
|
|
<Member Name="Level" DataType="REAL" />
|
|
<Member Name="Pressure" DataType="REAL" />
|
|
</Members>
|
|
</DataType>
|
|
</DataTypes>
|
|
<Tags>
|
|
<Tag Name="Tank1" TagType="Base" DataType="TankUDT" />
|
|
</Tags>
|
|
</Controller>
|
|
</RSLogix5000Content>
|
|
""";
|
|
|
|
var doc = L5xParser.Parse(new StringL5kSource(body));
|
|
var result = new L5kIngest { DefaultDeviceHostAddress = "ab://10.0.0.1/0,1" }.Ingest(doc);
|
|
|
|
var tag = result.Tags.Single();
|
|
tag.Name.ShouldBe("Tank1");
|
|
tag.DataType.ShouldBe(AbCipDataType.Structure);
|
|
tag.Members.ShouldNotBeNull();
|
|
tag.Members!.Count.ShouldBe(2);
|
|
tag.Members.Select(m => m.Name).ShouldBe(["Level", "Pressure"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void AOI_definition_surfaces_as_datatype_with_visible_parameters()
|
|
{
|
|
// EnableIn / EnableOut on real exports carry Hidden="true" — the parser must skip those
|
|
// so AOI-typed tags don't end up with phantom EnableIn/EnableOut members.
|
|
const string body = """
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<RSLogix5000Content>
|
|
<Controller Name="C">
|
|
<AddOnInstructionDefinitions>
|
|
<AddOnInstructionDefinition Name="MyValveAoi" Revision="1.0">
|
|
<Parameters>
|
|
<Parameter Name="EnableIn" TagType="Base" DataType="BOOL" Usage="Input" Hidden="true" />
|
|
<Parameter Name="EnableOut" TagType="Base" DataType="BOOL" Usage="Output" Hidden="true" />
|
|
<Parameter Name="Cmd" TagType="Base" DataType="BOOL" Usage="Input" />
|
|
<Parameter Name="Status" TagType="Base" DataType="DINT" Usage="Output" ExternalAccess="Read Only" />
|
|
</Parameters>
|
|
</AddOnInstructionDefinition>
|
|
</AddOnInstructionDefinitions>
|
|
<Tags>
|
|
<Tag Name="Valve_001" TagType="Base" DataType="MyValveAoi" />
|
|
</Tags>
|
|
</Controller>
|
|
</RSLogix5000Content>
|
|
""";
|
|
|
|
var doc = L5xParser.Parse(new StringL5kSource(body));
|
|
// AOI definition should appear as a "DataType" entry alongside any UDTs.
|
|
var aoi = doc.DataTypes.Single(d => d.Name == "MyValveAoi");
|
|
aoi.Members.Count.ShouldBe(2);
|
|
aoi.Members.Select(m => m.Name).ShouldBe(["Cmd", "Status"]);
|
|
|
|
var result = new L5kIngest { DefaultDeviceHostAddress = "ab://10.0.0.1/0,1" }.Ingest(doc);
|
|
var tag = result.Tags.Single();
|
|
tag.Name.ShouldBe("Valve_001");
|
|
tag.DataType.ShouldBe(AbCipDataType.Structure);
|
|
tag.Members.ShouldNotBeNull();
|
|
tag.Members!.Select(m => m.Name).ShouldBe(["Cmd", "Status"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void AOI_parameter_Usage_attribute_is_captured()
|
|
{
|
|
// PR abcip-2.6 — Usage attribute on <Parameter> elements (Input / Output / InOut) flows
|
|
// through to L5kMember.Usage so the ingest layer can map it to AoiQualifier.
|
|
const string body = """
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<RSLogix5000Content>
|
|
<Controller Name="C">
|
|
<AddOnInstructionDefinitions>
|
|
<AddOnInstructionDefinition Name="MyAoi" Revision="1.0">
|
|
<Parameters>
|
|
<Parameter Name="Cmd" DataType="BOOL" Usage="Input" />
|
|
<Parameter Name="Status" DataType="DINT" Usage="Output" />
|
|
<Parameter Name="Buffer" DataType="DINT" Usage="InOut" />
|
|
</Parameters>
|
|
</AddOnInstructionDefinition>
|
|
</AddOnInstructionDefinitions>
|
|
</Controller>
|
|
</RSLogix5000Content>
|
|
""";
|
|
|
|
var doc = L5xParser.Parse(new StringL5kSource(body));
|
|
var aoi = doc.DataTypes.Single(d => d.Name == "MyAoi");
|
|
aoi.Members.Single(m => m.Name == "Cmd").Usage.ShouldBe("Input");
|
|
aoi.Members.Single(m => m.Name == "Status").Usage.ShouldBe("Output");
|
|
aoi.Members.Single(m => m.Name == "Buffer").Usage.ShouldBe("InOut");
|
|
}
|
|
|
|
[Fact]
|
|
public void Empty_or_minimal_document_returns_empty_bundle_without_throwing()
|
|
{
|
|
const string body = """
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<RSLogix5000Content>
|
|
<Controller Name="C" />
|
|
</RSLogix5000Content>
|
|
""";
|
|
|
|
var doc = L5xParser.Parse(new StringL5kSource(body));
|
|
doc.Tags.Count.ShouldBe(0);
|
|
doc.DataTypes.Count.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public void Missing_external_access_defaults_to_writable_through_ingest()
|
|
{
|
|
// L5X: ExternalAccess attribute absent → ingest treats as default (writable, not skipped).
|
|
const string body = """
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<RSLogix5000Content>
|
|
<Controller Name="C">
|
|
<Tags>
|
|
<Tag Name="Plain" TagType="Base" DataType="DINT" />
|
|
</Tags>
|
|
</Controller>
|
|
</RSLogix5000Content>
|
|
""";
|
|
|
|
var doc = L5xParser.Parse(new StringL5kSource(body));
|
|
var result = new L5kIngest { DefaultDeviceHostAddress = "ab://10.0.0.1/0,1" }.Ingest(doc);
|
|
|
|
result.Tags.Single().Writable.ShouldBeTrue();
|
|
result.SkippedNoAccessCount.ShouldBe(0);
|
|
}
|
|
}
|