using System.Buffers.Binary; using System.Text; 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 CipSymbolObjectDecoderTests { /// /// Build one Symbol Object entry in the byte layout /// instance_id(u32) symbol_type(u16) element_length(u16) array_dims(u32×3) name_len(u16) name[len] pad. /// private static byte[] BuildEntry( uint instanceId, ushort symbolType, ushort elementLength, (uint, uint, uint) arrayDims, string name) { var nameBytes = Encoding.ASCII.GetBytes(name); var nameLen = nameBytes.Length; var totalLen = 22 + nameLen; if ((totalLen & 1) != 0) totalLen++; // pad to even var buf = new byte[totalLen]; BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(0), instanceId); BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(4), symbolType); BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(6), elementLength); BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(8), arrayDims.Item1); BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(12), arrayDims.Item2); BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(16), arrayDims.Item3); BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(20), (ushort)nameLen); Buffer.BlockCopy(nameBytes, 0, buf, 22, nameLen); return buf; } private static byte[] Concat(params byte[][] chunks) { var total = chunks.Sum(c => c.Length); var result = new byte[total]; var pos = 0; foreach (var c in chunks) { Buffer.BlockCopy(c, 0, result, pos, c.Length); pos += c.Length; } return result; } [Fact] public void Single_DInt_entry_decodes_to_scalar_DInt_tag() { var bytes = BuildEntry( instanceId: 42, symbolType: 0xC4, elementLength: 4, arrayDims: (0, 0, 0), name: "Counter"); var tags = CipSymbolObjectDecoder.Decode(bytes).ToList(); tags.Count.ShouldBe(1); tags[0].Name.ShouldBe("Counter"); tags[0].ProgramScope.ShouldBeNull(); tags[0].DataType.ShouldBe(AbCipDataType.DInt); tags[0].IsSystemTag.ShouldBeFalse(); } [Theory] [InlineData((byte)0xC1, AbCipDataType.Bool)] [InlineData((byte)0xC2, AbCipDataType.SInt)] [InlineData((byte)0xC3, AbCipDataType.Int)] [InlineData((byte)0xC4, AbCipDataType.DInt)] [InlineData((byte)0xC5, AbCipDataType.LInt)] [InlineData((byte)0xC6, AbCipDataType.USInt)] [InlineData((byte)0xC7, AbCipDataType.UInt)] [InlineData((byte)0xC8, AbCipDataType.UDInt)] [InlineData((byte)0xC9, AbCipDataType.ULInt)] [InlineData((byte)0xCA, AbCipDataType.Real)] [InlineData((byte)0xCB, AbCipDataType.LReal)] [InlineData((byte)0xD0, AbCipDataType.String)] public void Every_known_atomic_type_code_maps_to_correct_AbCipDataType(byte typeCode, AbCipDataType expected) { CipSymbolObjectDecoder.MapTypeCode(typeCode).ShouldBe(expected); } [Fact] public void Unknown_type_code_returns_null_so_caller_treats_as_opaque() { CipSymbolObjectDecoder.MapTypeCode(0xFF).ShouldBeNull(); } [Fact] public void Struct_flag_overrides_type_code_and_yields_Structure() { // 0x8000 (struct) + 0x1234 (template instance id in lower 12 bits; uses 0x234) var bytes = BuildEntry( instanceId: 5, symbolType: 0x8000 | 0x0234, elementLength: 16, arrayDims: (0, 0, 0), name: "Motor1"); var tag = CipSymbolObjectDecoder.Decode(bytes).Single(); tag.DataType.ShouldBe(AbCipDataType.Structure); } [Fact] public void System_flag_surfaces_as_IsSystemTag_true() { var bytes = BuildEntry( instanceId: 99, symbolType: 0x1000 | 0xC4, // system flag + DINT elementLength: 4, arrayDims: (0, 0, 0), name: "__Reserved_1"); var tag = CipSymbolObjectDecoder.Decode(bytes).Single(); tag.IsSystemTag.ShouldBeTrue(); tag.DataType.ShouldBe(AbCipDataType.DInt); } [Fact] public void Program_scope_name_splits_prefix_into_ProgramScope() { var bytes = BuildEntry( instanceId: 1, symbolType: 0xC4, elementLength: 4, arrayDims: (0, 0, 0), name: "Program:MainProgram.StepIndex"); var tag = CipSymbolObjectDecoder.Decode(bytes).Single(); tag.ProgramScope.ShouldBe("MainProgram"); tag.Name.ShouldBe("StepIndex"); } [Fact] public void Multiple_entries_decode_in_wire_order_with_even_padding() { // Name "Abc" is 3 bytes — triggers the even-pad branch between entries. var bytes = Concat( BuildEntry(1, 0xC4, 4, (0, 0, 0), "Abc"), // DINT named "Abc" (3-byte name, pads to 4) BuildEntry(2, 0xCA, 4, (0, 0, 0), "Pi")); // REAL named "Pi" var tags = CipSymbolObjectDecoder.Decode(bytes).ToList(); tags.Count.ShouldBe(2); tags[0].Name.ShouldBe("Abc"); tags[0].DataType.ShouldBe(AbCipDataType.DInt); tags[1].Name.ShouldBe("Pi"); tags[1].DataType.ShouldBe(AbCipDataType.Real); } [Fact] public void Truncated_buffer_stops_decoding_gracefully() { var full = BuildEntry(7, 0xC4, 4, (0, 0, 0), "Counter"); // Deliberately chop off the last 5 bytes — decoder should bail cleanly, not throw. var truncated = full.Take(full.Length - 5).ToArray(); CipSymbolObjectDecoder.Decode(truncated).ToList().Count.ShouldBeLessThan(1); // 0 — didn't parse the broken entry } [Fact] public void Empty_buffer_yields_no_tags() { CipSymbolObjectDecoder.Decode([]).ShouldBeEmpty(); } [Theory] [InlineData("Counter", null, "Counter")] [InlineData("Program:MainProgram.Step", "MainProgram", "Step")] [InlineData("Program:MyProg.a.b.c", "MyProg", "a.b.c")] [InlineData("Program:", null, "Program:")] // malformed — no dot [InlineData("Program:OnlyProg", null, "Program:OnlyProg")] [InlineData("Motor.Status.Running", null, "Motor.Status.Running")] public void SplitProgramScope_handles_every_shape(string input, string? expectedScope, string expectedName) { var (scope, name) = CipSymbolObjectDecoder.SplitProgramScope(input); scope.ShouldBe(expectedScope); name.ShouldBe(expectedName); } }