Auto: abcip-2.1 — L5K parser + ingest
Pure-text parser for Studio 5000 L5K controller exports. Recognises TAG/END_TAG, DATATYPE/END_DATATYPE, and PROGRAM/END_PROGRAM blocks, strips (* ... *) comments, and tolerates multi-line entries + unknown sections (CONFIG, MOTION_GROUP, etc.). Output records — L5kTag, L5kDataType, L5kMember — feed L5kIngest which converts to AbCipTagDefinition + AbCipStructureMember. Alias tags and ExternalAccess=None tags are skipped per Kepware precedent. AbCipDriverOptions gains an L5kImports collection (AbCipL5kImportOptions records — file path or inline text + per-import device + name prefix). InitializeAsync merges the imports into the declared Tags map, with declared tags winning on Name conflicts so operators can override import results without editing the L5K source. Tests cover controller-scope TAG, program-scope TAG, alias-tag flag, DATATYPE with member array dims, comment stripping, unknown-section skipping, multi-line entries, and the full ingest path including ExternalAccess=None / ReadOnly / UDT-typed tag fanout. Closes #229 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
175
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kIngestTests.cs
Normal file
175
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kIngestTests.cs
Normal file
@@ -0,0 +1,175 @@
|
||||
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 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
|
||||
}
|
||||
}
|
||||
168
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kParserTests.cs
Normal file
168
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kParserTests.cs
Normal file
@@ -0,0 +1,168 @@
|
||||
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 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user