Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kIngestTests.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

251 lines
8.9 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 L5kIngestTests
{
private const string DeviceHost = "ab://10.10.10.1/0,1";
[Fact]
public void Atomic_controller_scope_tag_becomes_AbCipTagDefinition()
{
const string body = """
TAG
Motor1_Speed : DINT (ExternalAccess := Read/Write) := 0;
END_TAG
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
var ingest = new L5kIngest { DefaultDeviceHostAddress = DeviceHost };
var result = ingest.Ingest(doc);
result.Tags.Count.ShouldBe(1);
var tag = result.Tags[0];
tag.Name.ShouldBe("Motor1_Speed");
tag.DeviceHostAddress.ShouldBe(DeviceHost);
tag.TagPath.ShouldBe("Motor1_Speed");
tag.DataType.ShouldBe(AbCipDataType.DInt);
tag.Writable.ShouldBeTrue();
tag.Members.ShouldBeNull();
}
[Fact]
public void Program_scope_tag_uses_Program_prefix_and_compound_name()
{
const string body = """
PROGRAM MainProgram
TAG
StepIndex : DINT := 0;
END_TAG
END_PROGRAM
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
result.Tags.Count.ShouldBe(1);
result.Tags[0].Name.ShouldBe("MainProgram.StepIndex");
result.Tags[0].TagPath.ShouldBe("Program:MainProgram.StepIndex");
}
[Fact]
public void Alias_tag_is_skipped()
{
const string body = """
TAG
Real : DINT := 0;
Aliased : DINT (AliasFor := "Real");
END_TAG
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
result.SkippedAliasCount.ShouldBe(1);
result.Tags.Count.ShouldBe(1);
result.Tags.ShouldAllBe(t => t.Name != "Aliased");
}
[Fact]
public void ExternalAccess_None_tag_is_skipped()
{
const string body = """
TAG
Hidden : DINT (ExternalAccess := None) := 0;
Visible : DINT := 0;
END_TAG
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
result.SkippedNoAccessCount.ShouldBe(1);
result.Tags.Single().Name.ShouldBe("Visible");
}
[Fact]
public void ExternalAccess_ReadOnly_tag_becomes_non_writable()
{
const string body = """
TAG
Sensor : REAL (ExternalAccess := Read Only) := 0.0;
END_TAG
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
result.Tags.Single().Writable.ShouldBeFalse();
}
[Fact]
public void UDT_typed_tag_picks_up_member_layout_from_DATATYPE_block()
{
const string body = """
DATATYPE TankUDT
MEMBER Level : REAL := 0.0;
MEMBER Active : BOOL := 0;
END_DATATYPE
TAG
Tank1 : TankUDT := [0.0, 0];
END_TAG
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.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[0].Name.ShouldBe("Level");
tag.Members[0].DataType.ShouldBe(AbCipDataType.Real);
tag.Members[1].Name.ShouldBe("Active");
tag.Members[1].DataType.ShouldBe(AbCipDataType.Bool);
}
[Fact]
public void Unknown_datatype_falls_through_as_structure_with_no_members()
{
const string body = """
TAG
Mystery : SomeUnknownType := 0;
END_TAG
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
var tag = result.Tags.Single();
tag.DataType.ShouldBe(AbCipDataType.Structure);
tag.Members.ShouldBeNull();
}
[Fact]
public void Ingest_throws_when_DefaultDeviceHostAddress_missing()
{
var doc = new L5kDocument(new[] { new L5kTag("X", "DINT", null, null, null, null) }, Array.Empty<L5kDataType>());
Should.Throw<InvalidOperationException>(() => new L5kIngest().Ingest(doc));
}
[Fact]
public void AOI_member_Usage_maps_to_AoiQualifier_through_ingest()
{
// PR abcip-2.6 — L5K AOI parameters carry a Usage := Input / Output / InOut attribute.
// Ingest must map those values onto AbCipStructureMember.AoiQualifier so the discovery
// layer can group AOI members under sub-folders. Plain DATATYPE members get Local.
const string body = """
ADD_ON_INSTRUCTION_DEFINITION ValveAoi
PARAMETERS
PARAMETER Cmd : BOOL (Usage := Input) := 0;
PARAMETER Status : DINT (Usage := Output) := 0;
PARAMETER Buffer : DINT (Usage := InOut) := 0;
PARAMETER Local1 : DINT := 0;
END_PARAMETERS
END_ADD_ON_INSTRUCTION_DEFINITION
DATATYPE PlainUdt
MEMBER Speed : DINT := 0;
END_DATATYPE
TAG
Valve_001 : ValveAoi;
Tank1 : PlainUdt;
END_TAG
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
var aoiTag = result.Tags.Single(t => t.Name == "Valve_001");
aoiTag.Members.ShouldNotBeNull();
aoiTag.Members!.Single(m => m.Name == "Cmd").AoiQualifier.ShouldBe(AoiQualifier.Input);
aoiTag.Members.Single(m => m.Name == "Status").AoiQualifier.ShouldBe(AoiQualifier.Output);
aoiTag.Members.Single(m => m.Name == "Buffer").AoiQualifier.ShouldBe(AoiQualifier.InOut);
aoiTag.Members.Single(m => m.Name == "Local1").AoiQualifier.ShouldBe(AoiQualifier.Local);
// Plain UDT members default to Local — no Usage attribute to map.
var plainTag = result.Tags.Single(t => t.Name == "Tank1");
plainTag.Members.ShouldNotBeNull();
plainTag.Members!.Single().AoiQualifier.ShouldBe(AoiQualifier.Local);
}
[Fact]
public void L5x_AOI_member_Usage_maps_to_AoiQualifier_through_ingest()
{
// Same mapping as the L5K case above, exercised through the L5X parser to confirm both
// formats land at the same downstream representation.
const string body = """
<?xml version="1.0" encoding="UTF-8"?>
<RSLogix5000Content>
<Controller Name="C">
<AddOnInstructionDefinitions>
<AddOnInstructionDefinition Name="MyAoi">
<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>
<Tags>
<Tag Name="Valve_001" TagType="Base" DataType="MyAoi" />
</Tags>
</Controller>
</RSLogix5000Content>
""";
var doc = L5xParser.Parse(new StringL5kSource(body));
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
var aoiTag = result.Tags.Single();
aoiTag.Members.ShouldNotBeNull();
aoiTag.Members!.Single(m => m.Name == "Cmd").AoiQualifier.ShouldBe(AoiQualifier.Input);
aoiTag.Members.Single(m => m.Name == "Status").AoiQualifier.ShouldBe(AoiQualifier.Output);
aoiTag.Members.Single(m => m.Name == "Buffer").AoiQualifier.ShouldBe(AoiQualifier.InOut);
}
[Fact]
public void NamePrefix_is_applied_to_imported_tags()
{
const string body = """
TAG
Speed : DINT := 0;
END_TAG
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
var result = new L5kIngest
{
DefaultDeviceHostAddress = DeviceHost,
NamePrefix = "PLC1_",
}.Ingest(doc);
result.Tags.Single().Name.ShouldBe("PLC1_Speed");
result.Tags.Single().TagPath.ShouldBe("Speed"); // path on the PLC stays unchanged
}
}