Adds Import/L5xParser.cs that consumes Studio 5000 L5X (XML) controller exports via System.Xml.XPath and produces the same L5kDocument bundle as L5kParser, so L5kIngest handles both formats interchangeably. - Controller-scope and program-scope <Tag> elements with Name, DataType, TagType, ExternalAccess, AliasFor, and <Description> child. - <DataType>/<Members>/<Member> with Hidden BOOL-host (ZZZZZZZZZZ*) skip. - AddOnInstructionDefinitions surfaced as L5kDataType entries so AOI-typed tags pick up a member layout the same way UDT-typed tags do; hidden EnableIn/EnableOut parameters skipped. Full directional Input/Output/InOut modelling stays deferred to PR 2.6. AbCipDriverOptions gains parallel L5xImports collection (mirrors L5kImports field-for-field). InitializeAsync funnels both through one shared MergeImport helper that differs only in the parser delegate. Tests: 8 L5X fixtures cover controller- and program-scope tags, alias skip, UDT layout fan-out, AOI-typed tag, ZZZZZZZZZZ host skip, hidden AOI param skip, missing-ExternalAccess default, and an empty-controller no-throw. Closes #230
231 lines
8.8 KiB
C#
231 lines
8.8 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 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);
|
|
}
|
|
}
|