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
251 lines
8.9 KiB
C#
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
|
|
}
|
|
}
|