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()); Should.Throw(() => 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 = """ """; 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 } }