using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.Modbus; namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests; [Trait("Category", "Unit")] public sealed class ModbusAddressParserTests { // ----- Bare Modicon-only forms inherit #136 behaviour; one sanity row per region. ----- [Theory] [InlineData("40001", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16)] [InlineData("400001", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16)] [InlineData("30001", ModbusRegion.InputRegisters, 0, ModbusDataType.Int16)] [InlineData("00001", ModbusRegion.Coils, 0, ModbusDataType.Bool)] [InlineData("10001", ModbusRegion.DiscreteInputs, 0, ModbusDataType.Bool)] [InlineData("465536", ModbusRegion.HoldingRegisters, 65535, ModbusDataType.Int16)] public void Bare_Modicon_Defaults_DataType_From_Region(string addr, ModbusRegion region, int offset, ModbusDataType type) { var p = ModbusAddressParser.Parse(addr); p.Region.ShouldBe(region); p.Offset.ShouldBe((ushort)offset); p.DataType.ShouldBe(type); p.Bit.ShouldBeNull(); p.ArrayCount.ShouldBeNull(); p.ByteOrder.ShouldBe(ModbusByteOrder.BigEndian); } // ----- Mnemonic forms — HR / IR / C / DI ----- [Theory] [InlineData("HR1", ModbusRegion.HoldingRegisters, 0)] [InlineData("HR65536", ModbusRegion.HoldingRegisters, 65535)] [InlineData("IR1", ModbusRegion.InputRegisters, 0)] [InlineData("C100", ModbusRegion.Coils, 99)] [InlineData("DI1", ModbusRegion.DiscreteInputs, 0)] [InlineData("hr1", ModbusRegion.HoldingRegisters, 0)] // lowercase [InlineData("Ir50", ModbusRegion.InputRegisters, 49)] // mixed case public void Mnemonic_Region_Forms_Parse(string addr, ModbusRegion region, int offset) { var p = ModbusAddressParser.Parse(addr); p.Region.ShouldBe(region); p.Offset.ShouldBe((ushort)offset); } // ----- Bit suffix .N ----- [Theory] [InlineData("40001.0", 0)] [InlineData("40001.5", 5)] [InlineData("40001.15", 15)] [InlineData("HR1.7", 7)] public void Bit_Suffix_Implies_BitInRegister(string addr, int expectedBit) { var p = ModbusAddressParser.Parse(addr); p.Bit.ShouldBe((byte)expectedBit); p.DataType.ShouldBe(ModbusDataType.BitInRegister); } [Fact] public void Bit_Plus_Explicit_Type_Rejected() { Should.Throw(() => ModbusAddressParser.Parse("40001.5:F")) .Message.ShouldContain("Bit suffix"); } [Fact] public void Bit_Above_15_Rejected() { Should.Throw(() => ModbusAddressParser.Parse("40001.16")) .Message.ShouldContain("0..15"); } // ----- Type codes ----- [Theory] [InlineData("40001:BOOL", ModbusDataType.Bool)] [InlineData("40001:I", ModbusDataType.Int16)] [InlineData("40001:UI", ModbusDataType.UInt16)] [InlineData("40001:DI", ModbusDataType.Int32)] [InlineData("40001:L", ModbusDataType.Int32)] [InlineData("40001:UDI", ModbusDataType.UInt32)] [InlineData("40001:UL", ModbusDataType.UInt32)] [InlineData("40001:LI", ModbusDataType.Int64)] [InlineData("40001:ULI", ModbusDataType.UInt64)] [InlineData("40001:F", ModbusDataType.Float32)] [InlineData("40001:D", ModbusDataType.Float64)] [InlineData("40001:BCD", ModbusDataType.Bcd16)] [InlineData("40001:LBCD", ModbusDataType.Bcd32)] [InlineData("40001:f", ModbusDataType.Float32)] // lowercase public void Type_Codes_Parse(string addr, ModbusDataType expected) { ModbusAddressParser.Parse(addr).DataType.ShouldBe(expected); } [Theory] [InlineData("40001:STR1", 1)] [InlineData("40001:STR20", 20)] [InlineData("40001:STR255", 255)] public void STR_Type_Carries_Length(string addr, int expectedLen) { var p = ModbusAddressParser.Parse(addr); p.DataType.ShouldBe(ModbusDataType.String); p.StringLength.ShouldBe((ushort)expectedLen); } [Fact] public void STR_Without_Length_Rejected() { Should.Throw(() => ModbusAddressParser.Parse("40001:STR")) .Message.ShouldContain("STR"); } [Fact] public void STR_Length_Zero_Rejected() { Should.Throw(() => ModbusAddressParser.Parse("40001:STR0")) .Message.ShouldContain("positive"); } [Fact] public void Unknown_Type_Code_Rejected_With_Catalog() { Should.Throw(() => ModbusAddressParser.Parse("40001:WIDGET")) .Message.ShouldContain("Valid: BOOL, I,"); } // ----- Region-type compatibility ----- [Fact] public void Coils_With_Float_Type_Rejected() { Should.Throw(() => ModbusAddressParser.Parse("00001:F")) .Message.ShouldContain("only supports Bool"); } [Fact] public void DiscreteInputs_With_Int_Type_Rejected() { Should.Throw(() => ModbusAddressParser.Parse("10001:I")) .Message.ShouldContain("only supports Bool"); } // ----- Byte order modifiers — all four ----- [Theory] [InlineData("40001:F:ABCD", ModbusByteOrder.BigEndian)] [InlineData("40001:F:CDAB", ModbusByteOrder.WordSwap)] [InlineData("40001:F:BADC", ModbusByteOrder.ByteSwap)] [InlineData("40001:F:DCBA", ModbusByteOrder.FullReverse)] [InlineData("40001:F:cdab", ModbusByteOrder.WordSwap)] // lowercase public void Byte_Order_Modifiers_Parse(string addr, ModbusByteOrder expected) { ModbusAddressParser.Parse(addr).ByteOrder.ShouldBe(expected); } [Fact] public void Unknown_Byte_Order_Rejected_With_Catalog() { Should.Throw(() => ModbusAddressParser.Parse("40001:F:WXYZ")) .Message.ShouldContain("Valid: ABCD, CDAB, BADC, DCBA"); } [Fact] public void Empty_Order_Field_Means_Default() { // 40001:I::5 → Int16 array, no order override, default (BigEndian). var p = ModbusAddressParser.Parse("40001:I::5"); p.ByteOrder.ShouldBe(ModbusByteOrder.BigEndian); p.ArrayCount.ShouldBe(5); } // ----- Array count ----- [Theory] [InlineData("40001:I:ABCD:1", 1)] [InlineData("40001:F:5", 5)] [InlineData("40001:F:CDAB:10", 10)] [InlineData("40001:DI:100", 100)] public void Array_Count_Parses(string addr, int expectedCount) { ModbusAddressParser.Parse(addr).ArrayCount.ShouldBe(expectedCount); } [Fact] public void Array_Count_Zero_Rejected() { Should.Throw(() => ModbusAddressParser.Parse("40001:F:ABCD:0")) .Message.ShouldContain("positive"); } [Fact] public void Array_Count_NonNumeric_Rejected() { Should.Throw(() => ModbusAddressParser.Parse("40001:F:ABCD:five")) .Message.ShouldContain("positive"); } [Fact] public void Bit_Plus_Array_Rejected() { Should.Throw(() => ModbusAddressParser.Parse("40001.5:::5")) .Message.ShouldContain("Bit suffix and array count"); } // ----- Composition / examples ----- [Fact] public void Worked_Example_Float_With_Word_Swap() { var p = ModbusAddressParser.Parse("40001:F:CDAB"); p.Region.ShouldBe(ModbusRegion.HoldingRegisters); p.Offset.ShouldBe((ushort)0); p.DataType.ShouldBe(ModbusDataType.Float32); p.ByteOrder.ShouldBe(ModbusByteOrder.WordSwap); p.ArrayCount.ShouldBeNull(); } [Fact] public void Worked_Example_Int16_Array() { var p = ModbusAddressParser.Parse("40001:I::10"); p.Region.ShouldBe(ModbusRegion.HoldingRegisters); p.DataType.ShouldBe(ModbusDataType.Int16); p.ArrayCount.ShouldBe(10); p.ByteOrder.ShouldBe(ModbusByteOrder.BigEndian); } [Fact] public void Worked_Example_Float_Array_Word_Swap_6_Digit() { var p = ModbusAddressParser.Parse("465500:F:CDAB:5"); p.Region.ShouldBe(ModbusRegion.HoldingRegisters); p.Offset.ShouldBe((ushort)65499); p.DataType.ShouldBe(ModbusDataType.Float32); p.ByteOrder.ShouldBe(ModbusByteOrder.WordSwap); p.ArrayCount.ShouldBe(5); } [Fact] public void Worked_Example_String_With_Length() { var p = ModbusAddressParser.Parse("40001:STR20"); p.DataType.ShouldBe(ModbusDataType.String); p.StringLength.ShouldBe((ushort)20); p.ArrayCount.ShouldBeNull(); // strings ARE multi-register but they are not "array of string" } [Fact] public void TryParse_Returns_Diagnostic_On_Failure() { ModbusAddressParser.TryParse("garbage", out var p, out var err).ShouldBeFalse(); p.ShouldBeNull(); err.ShouldNotBeNull(); } [Fact] public void TryParse_Returns_Result_On_Success() { ModbusAddressParser.TryParse("HR1:F:CDAB:3", out var p, out var err).ShouldBeTrue(); p.ShouldNotBeNull(); err.ShouldBeNull(); p!.Region.ShouldBe(ModbusRegion.HoldingRegisters); p.DataType.ShouldBe(ModbusDataType.Float32); p.ByteOrder.ShouldBe(ModbusByteOrder.WordSwap); p.ArrayCount.ShouldBe(3); } [Fact] public void Too_Many_Colons_Rejected() { Should.Throw(() => ModbusAddressParser.Parse("40001:F:CDAB:5:extra")) .Message.ShouldContain("too many"); } }