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
199 lines
6.6 KiB
C#
199 lines
6.6 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 L5kParserTests
|
|
{
|
|
[Fact]
|
|
public void Controller_scope_TAG_block_parses_name_datatype_externalaccess()
|
|
{
|
|
const string body = """
|
|
TAG
|
|
Motor1_Speed : DINT (Description := "Motor 1 set point", ExternalAccess := Read/Write) := 0;
|
|
Tank_Level : REAL (ExternalAccess := Read Only) := 0.0;
|
|
END_TAG
|
|
""";
|
|
|
|
var doc = L5kParser.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].ProgramScope.ShouldBeNull();
|
|
doc.Tags[0].ExternalAccess.ShouldBe("Read/Write");
|
|
doc.Tags[0].Description.ShouldBe("Motor 1 set point");
|
|
doc.Tags[0].AliasFor.ShouldBeNull();
|
|
|
|
doc.Tags[1].Name.ShouldBe("Tank_Level");
|
|
doc.Tags[1].DataType.ShouldBe("REAL");
|
|
doc.Tags[1].ExternalAccess.ShouldBe("Read Only");
|
|
}
|
|
|
|
[Fact]
|
|
public void Program_scope_TAG_block_carries_program_name()
|
|
{
|
|
const string body = """
|
|
PROGRAM MainProgram (Class := Standard)
|
|
TAG
|
|
StepIndex : DINT := 0;
|
|
Running : BOOL := 0;
|
|
END_TAG
|
|
END_PROGRAM
|
|
""";
|
|
|
|
var doc = L5kParser.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_is_flagged()
|
|
{
|
|
const string body = """
|
|
TAG
|
|
Motor1 : DINT := 0;
|
|
Motor1_Alias : DINT (AliasFor := "Motor1", ExternalAccess := Read/Write);
|
|
END_TAG
|
|
""";
|
|
|
|
var doc = L5kParser.Parse(new StringL5kSource(body));
|
|
|
|
var alias = doc.Tags.Single(t => t.Name == "Motor1_Alias");
|
|
alias.AliasFor.ShouldBe("Motor1");
|
|
}
|
|
|
|
[Fact]
|
|
public void DATATYPE_block_collects_member_lines()
|
|
{
|
|
const string body = """
|
|
DATATYPE TankUDT (FamilyType := NoFamily)
|
|
MEMBER Level : REAL (ExternalAccess := Read/Write) := 0.0;
|
|
MEMBER Pressure : REAL := 0.0;
|
|
MEMBER Active : BOOL := 0;
|
|
END_DATATYPE
|
|
""";
|
|
|
|
var doc = L5kParser.Parse(new StringL5kSource(body));
|
|
|
|
doc.DataTypes.Count.ShouldBe(1);
|
|
var udt = doc.DataTypes[0];
|
|
udt.Name.ShouldBe("TankUDT");
|
|
udt.Members.Count.ShouldBe(3);
|
|
udt.Members[0].Name.ShouldBe("Level");
|
|
udt.Members[0].DataType.ShouldBe("REAL");
|
|
udt.Members[0].ExternalAccess.ShouldBe("Read/Write");
|
|
udt.Members[1].Name.ShouldBe("Pressure");
|
|
udt.Members[2].Name.ShouldBe("Active");
|
|
udt.Members[2].DataType.ShouldBe("BOOL");
|
|
}
|
|
|
|
[Fact]
|
|
public void DATATYPE_member_with_array_dim_keeps_type_clean()
|
|
{
|
|
const string body = """
|
|
DATATYPE BatchUDT
|
|
MEMBER Recipe : DINT[16] := 0;
|
|
MEMBER Name : STRING := "";
|
|
END_DATATYPE
|
|
""";
|
|
|
|
var doc = L5kParser.Parse(new StringL5kSource(body));
|
|
|
|
var udt = doc.DataTypes[0];
|
|
var recipe = udt.Members.First(m => m.Name == "Recipe");
|
|
recipe.DataType.ShouldBe("DINT");
|
|
recipe.ArrayDim.ShouldBe(16);
|
|
|
|
var nameMember = udt.Members.First(m => m.Name == "Name");
|
|
nameMember.DataType.ShouldBe("STRING");
|
|
nameMember.ArrayDim.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Block_comments_are_stripped_before_parsing()
|
|
{
|
|
const string body = """
|
|
(* This is a long
|
|
multi-line comment with TAG and END_TAG inside, parser must skip *)
|
|
TAG
|
|
Real_Tag : DINT := 0;
|
|
END_TAG
|
|
""";
|
|
|
|
var doc = L5kParser.Parse(new StringL5kSource(body));
|
|
doc.Tags.Count.ShouldBe(1);
|
|
doc.Tags[0].Name.ShouldBe("Real_Tag");
|
|
}
|
|
|
|
[Fact]
|
|
public void Unknown_sections_are_skipped_silently()
|
|
{
|
|
const string body = """
|
|
CONFIG SomeConfig (Class := Standard)
|
|
ConfigData := 0;
|
|
END_CONFIG
|
|
MOTION_GROUP Motion1
|
|
Member := whatever;
|
|
END_MOTION_GROUP
|
|
TAG
|
|
Real_Tag : DINT := 0;
|
|
END_TAG
|
|
""";
|
|
|
|
var doc = L5kParser.Parse(new StringL5kSource(body));
|
|
doc.Tags.Count.ShouldBe(1);
|
|
doc.Tags[0].Name.ShouldBe("Real_Tag");
|
|
}
|
|
|
|
[Fact]
|
|
public void AOI_definition_block_collects_parameters_with_Usage()
|
|
{
|
|
// PR abcip-2.6 — ADD_ON_INSTRUCTION_DEFINITION blocks with PARAMETER entries carrying
|
|
// Usage := Input / Output / InOut. The parser surfaces them as L5kDataType members so
|
|
// AOI-typed tags pick up a layout the same way UDT-typed tags do.
|
|
const string body = """
|
|
ADD_ON_INSTRUCTION_DEFINITION MyValveAoi (Revision := "1.0")
|
|
PARAMETERS
|
|
PARAMETER Cmd : BOOL (Usage := Input) := 0;
|
|
PARAMETER Status : DINT (Usage := Output, ExternalAccess := Read Only) := 0;
|
|
PARAMETER Buffer : DINT (Usage := InOut) := 0;
|
|
PARAMETER Internal : DINT := 0;
|
|
END_PARAMETERS
|
|
LOCAL_TAGS
|
|
Working : DINT := 0;
|
|
END_LOCAL_TAGS
|
|
END_ADD_ON_INSTRUCTION_DEFINITION
|
|
""";
|
|
|
|
var doc = L5kParser.Parse(new StringL5kSource(body));
|
|
|
|
var aoi = doc.DataTypes.Single(d => d.Name == "MyValveAoi");
|
|
aoi.Members.Count.ShouldBe(4);
|
|
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");
|
|
aoi.Members.Single(m => m.Name == "Internal").Usage.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Multi_line_TAG_entry_is_concatenated()
|
|
{
|
|
const string body = """
|
|
TAG
|
|
Motor1 : DINT (Description := "Long description spanning",
|
|
ExternalAccess := Read/Write) := 0;
|
|
END_TAG
|
|
""";
|
|
|
|
var doc = L5kParser.Parse(new StringL5kSource(body));
|
|
doc.Tags.Count.ShouldBe(1);
|
|
doc.Tags[0].Description.ShouldBe("Long description spanning");
|
|
doc.Tags[0].ExternalAccess.ShouldBe("Read/Write");
|
|
}
|
|
}
|