using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.AbCip; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; [Trait("Category", "Unit")] public sealed class AbCipTagPathTests { [Fact] public void Controller_scope_single_segment() { var p = AbCipTagPath.TryParse("Motor1_Speed"); p.ShouldNotBeNull(); p.ProgramScope.ShouldBeNull(); p.Segments.Count.ShouldBe(1); p.Segments[0].Name.ShouldBe("Motor1_Speed"); p.Segments[0].Subscripts.ShouldBeEmpty(); p.BitIndex.ShouldBeNull(); p.ToLibplctagName().ShouldBe("Motor1_Speed"); } [Fact] public void Program_scope_parses() { var p = AbCipTagPath.TryParse("Program:MainProgram.StepIndex"); p.ShouldNotBeNull(); p.ProgramScope.ShouldBe("MainProgram"); p.Segments.Single().Name.ShouldBe("StepIndex"); p.ToLibplctagName().ShouldBe("Program:MainProgram.StepIndex"); } [Fact] public void Structured_member_access_splits_segments() { var p = AbCipTagPath.TryParse("Motor1.Speed.Setpoint"); p.ShouldNotBeNull(); p.Segments.Select(s => s.Name).ShouldBe(["Motor1", "Speed", "Setpoint"]); p.ToLibplctagName().ShouldBe("Motor1.Speed.Setpoint"); } [Fact] public void Single_dim_array_subscript() { var p = AbCipTagPath.TryParse("Data[7]"); p.ShouldNotBeNull(); p.Segments.Single().Name.ShouldBe("Data"); p.Segments.Single().Subscripts.ShouldBe([7]); p.ToLibplctagName().ShouldBe("Data[7]"); } [Fact] public void Multi_dim_array_subscript() { var p = AbCipTagPath.TryParse("Matrix[1,2,3]"); p.ShouldNotBeNull(); p.Segments.Single().Subscripts.ShouldBe([1, 2, 3]); p.ToLibplctagName().ShouldBe("Matrix[1,2,3]"); } [Fact] public void Bit_in_dint_captured_as_bit_index() { var p = AbCipTagPath.TryParse("Flags.3"); p.ShouldNotBeNull(); p.Segments.Single().Name.ShouldBe("Flags"); p.BitIndex.ShouldBe(3); p.ToLibplctagName().ShouldBe("Flags.3"); } [Fact] public void Bit_in_dint_after_member() { var p = AbCipTagPath.TryParse("Motor.Status.12"); p.ShouldNotBeNull(); p.Segments.Select(s => s.Name).ShouldBe(["Motor", "Status"]); p.BitIndex.ShouldBe(12); p.ToLibplctagName().ShouldBe("Motor.Status.12"); } [Fact] public void Bit_index_32_rejected_out_of_range() { // 32 exceeds the DINT bit width — treated as a member name rather than bit selector, // which fails ident validation and returns null. AbCipTagPath.TryParse("Flags.32").ShouldBeNull(); } [Fact] public void Program_scope_with_members_and_subscript_and_bit() { var p = AbCipTagPath.TryParse("Program:MainProgram.Motors[0].Status.5"); p.ShouldNotBeNull(); p.ProgramScope.ShouldBe("MainProgram"); p.Segments.Select(s => s.Name).ShouldBe(["Motors", "Status"]); p.Segments[0].Subscripts.ShouldBe([0]); p.BitIndex.ShouldBe(5); p.ToLibplctagName().ShouldBe("Program:MainProgram.Motors[0].Status.5"); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] [InlineData("Program:")] // empty scope [InlineData("Program:MP")] // no body after scope [InlineData("1InvalidStart")] // ident starts with digit [InlineData("Bad Name")] // space in ident [InlineData("Motor[]")] // empty subscript [InlineData("Motor[-1]")] // negative subscript [InlineData("Motor[a]")] // non-numeric subscript [InlineData("Motor[")] // unbalanced bracket [InlineData("Motor.")] // trailing dot [InlineData(".Motor")] // leading dot public void Invalid_shapes_return_null(string? input) { AbCipTagPath.TryParse(input).ShouldBeNull(); } [Fact] public void Ident_with_underscore_accepted() { AbCipTagPath.TryParse("_private_tag")!.Segments.Single().Name.ShouldBe("_private_tag"); } [Fact] public void Slice_basic_inclusive_range() { var p = AbCipTagPath.TryParse("Data[0..15]"); p.ShouldNotBeNull(); p.Slice.ShouldNotBeNull(); p.Slice!.Start.ShouldBe(0); p.Slice.End.ShouldBe(15); p.Slice.Count.ShouldBe(16); p.BitIndex.ShouldBeNull(); p.Segments.Single().Name.ShouldBe("Data"); p.Segments.Single().Subscripts.ShouldBeEmpty(); p.ToLibplctagName().ShouldBe("Data[0..15]"); // Slice array name omits the `..End` so libplctag sees an anchored read at the start // index; pair with ElementCount to cover the whole range. p.ToLibplctagSliceArrayName().ShouldBe("Data[0]"); } [Fact] public void Slice_with_program_scope_and_member_chain() { var p = AbCipTagPath.TryParse("Program:MainProgram.Motors.Data[3..7]"); p.ShouldNotBeNull(); p.ProgramScope.ShouldBe("MainProgram"); p.Segments.Select(s => s.Name).ShouldBe(["Motors", "Data"]); p.Slice!.Start.ShouldBe(3); p.Slice.End.ShouldBe(7); p.ToLibplctagName().ShouldBe("Program:MainProgram.Motors.Data[3..7]"); p.ToLibplctagSliceArrayName().ShouldBe("Program:MainProgram.Motors.Data[3]"); } [Fact] public void Slice_zero_length_single_element_allowed() { // [5..5] is a one-element slice — degenerate but legal (a single read of one element). var p = AbCipTagPath.TryParse("Data[5..5]"); p.ShouldNotBeNull(); p.Slice!.Count.ShouldBe(1); } [Theory] [InlineData("Data[5..3]")] // M < N [InlineData("Data[-1..5]")] // negative start [InlineData("Data[0..15].Member")] // slice + sub-element [InlineData("Data[0..15].3")] // slice + bit index [InlineData("Data[0..15,1]")] // slice cannot be multi-dim [InlineData("Data[0..15,2..3]")] // multi-dim slice not supported [InlineData("Data[..5]")] // missing start [InlineData("Data[5..]")] // missing end [InlineData("Data[a..5]")] // non-numeric start public void Invalid_slice_shapes_return_null(string input) { AbCipTagPath.TryParse(input).ShouldBeNull(); } [Fact] public void ToLibplctagName_recomposes_round_trip() { var cases = new[] { "Motor1_Speed", "Program:Main.Counter", "Array[5]", "Matrix[1,2]", "Obj.Member.Sub", "Flags.0", "Program:P.Obj[2].Flags.15", }; foreach (var c in cases) { var parsed = AbCipTagPath.TryParse(c); parsed.ShouldNotBeNull(c); parsed.ToLibplctagName().ShouldBe(c); } } }