Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5xParserTests.cs
Joseph Doherty e3c0750f7d Auto: abcip-2.6 — AOI input/output handling
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
2026-04-25 18:58:49 -04:00

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);
}
}