Auto: abcip-2.2 — L5X (XML) parser + ingest
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
This commit is contained in:
230
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5xParserTests.cs
Normal file
230
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5xParserTests.cs
Normal file
@@ -0,0 +1,230 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user