using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests; [Trait("Category", "Unit")] public sealed class MelsecAddressTests { // --- X / Y hex vs octal family trap --- [Theory] [InlineData("X0", (ushort)0)] [InlineData("X9", (ushort)9)] [InlineData("XA", (ushort)10)] // hex [InlineData("XF", (ushort)15)] [InlineData("X10", (ushort)16)] // hex 0x10 = decimal 16 [InlineData("X20", (ushort)32)] // hex 0x20 = decimal 32 — the classic MELSEC-Q trap [InlineData("X1FF", (ushort)511)] [InlineData("x10", (ushort)16)] // lowercase prefix public void XInputToDiscrete_QLiQR_parses_hex(string x, ushort expected) => MelsecAddress.XInputToDiscrete(x, MelsecFamily.Q_L_iQR).ShouldBe(expected); [Theory] [InlineData("X0", (ushort)0)] [InlineData("X7", (ushort)7)] [InlineData("X10", (ushort)8)] // octal 10 = decimal 8 [InlineData("X20", (ushort)16)] // octal 20 = decimal 16 — SAME string, DIFFERENT value on FX [InlineData("X777", (ushort)511)] public void XInputToDiscrete_FiQF_parses_octal(string x, ushort expected) => MelsecAddress.XInputToDiscrete(x, MelsecFamily.F_iQF).ShouldBe(expected); [Theory] [InlineData("Y0", (ushort)0)] [InlineData("Y1F", (ushort)31)] public void YOutputToCoil_QLiQR_parses_hex(string y, ushort expected) => MelsecAddress.YOutputToCoil(y, MelsecFamily.Q_L_iQR).ShouldBe(expected); [Theory] [InlineData("Y0", (ushort)0)] [InlineData("Y17", (ushort)15)] public void YOutputToCoil_FiQF_parses_octal(string y, ushort expected) => MelsecAddress.YOutputToCoil(y, MelsecFamily.F_iQF).ShouldBe(expected); [Fact] public void Same_address_string_decodes_differently_between_families() { // This is the headline quirk: "X20" in GX Works means one thing on Q-series and // another on FX-series. The driver's family selector is the only defence. MelsecAddress.XInputToDiscrete("X20", MelsecFamily.Q_L_iQR).ShouldBe((ushort)32); MelsecAddress.XInputToDiscrete("X20", MelsecFamily.F_iQF).ShouldBe((ushort)16); } [Theory] [InlineData("X8")] // 8 is non-octal [InlineData("X12G")] // G is non-hex public void XInputToDiscrete_FiQF_rejects_non_octal(string bad) => Should.Throw(() => MelsecAddress.XInputToDiscrete(bad, MelsecFamily.F_iQF)); [Theory] [InlineData("X12G")] public void XInputToDiscrete_QLiQR_rejects_non_hex(string bad) => Should.Throw(() => MelsecAddress.XInputToDiscrete(bad, MelsecFamily.Q_L_iQR)); [Fact] public void XInputToDiscrete_honors_bank_base_from_assignment_block() { // Real-world QJ71MT91 assignment blocks commonly place X at DI 8192+ when other // ranges take the low Modbus addresses. Helper must add the base cleanly. MelsecAddress.XInputToDiscrete("X10", MelsecFamily.Q_L_iQR, xBankBase: 8192).ShouldBe((ushort)(8192 + 16)); } // --- M-relay (decimal, both families) --- [Theory] [InlineData("M0", (ushort)0)] [InlineData("M10", (ushort)10)] // M addresses are DECIMAL, not hex or octal [InlineData("M511", (ushort)511)] [InlineData("m99", (ushort)99)] // lowercase public void MRelayToCoil_parses_decimal(string m, ushort expected) => MelsecAddress.MRelayToCoil(m).ShouldBe(expected); [Fact] public void MRelayToCoil_honors_bank_base() => MelsecAddress.MRelayToCoil("M0", mBankBase: 512).ShouldBe((ushort)512); [Fact] public void MRelayToCoil_rejects_non_numeric() => Should.Throw(() => MelsecAddress.MRelayToCoil("M1F")); // --- D-register (decimal, both families) --- [Theory] [InlineData("D0", (ushort)0)] [InlineData("D100", (ushort)100)] [InlineData("d1023", (ushort)1023)] public void DRegisterToHolding_parses_decimal(string d, ushort expected) => MelsecAddress.DRegisterToHolding(d).ShouldBe(expected); [Fact] public void DRegisterToHolding_honors_bank_base() => MelsecAddress.DRegisterToHolding("D10", dBankBase: 4096).ShouldBe((ushort)4106); [Fact] public void DRegisterToHolding_rejects_empty() => Should.Throw(() => MelsecAddress.DRegisterToHolding("D")); // --- overflow --- [Fact] public void XInputToDiscrete_overflow_throws() { // 0xFFFF + base 1 = 0x10000 — past ushort. Should.Throw(() => MelsecAddress.XInputToDiscrete("XFFFF", MelsecFamily.Q_L_iQR, xBankBase: 1)); } }