Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/MelsecAddressTests.cs
Joseph Doherty d4c1873998 Phase 3 PR 59 -- MelsecAddress helper for MELSEC X/Y hex-vs-octal family trap + D/M bank bases. Adds MelsecAddress static class with XInputToDiscrete, YOutputToCoil, MRelayToCoil, DRegisterToHolding helpers and a MelsecFamily enum {Q_L_iQR, F_iQF} that drives whether X/Y addresses are parsed as hex (Q-series convention) or octal (FX-series convention). This is the #1 MELSEC driver bug source per docs/v2/mitsubishi.md: the string 'X20' on a MELSEC-Q means DI 32 (hex 0x20) while the same string on an FX3U means DI 16 (octal 0o20). The helper forces the caller to name the family explicitly; no 'sensible default' because wrong defaults just move the bug. Key design decisions: (1) Family is an enum argument, not a helper-level static-selector, because real deployments have BOTH Q-series and FX-series PLCs on the same gateway -- one driver instance per device means family must be per-tag, not per-driver. (2) Bank base is a ushort argument defaulting to 0. Real QJ71MT91/LJ71MT91 assignment blocks commonly place X at DI 8192+, Y at coil 8192+, etc. to leave the low-address range for D-registers; the helper takes the site's configured base as runtime config rather than a compile-time constant. Matches the 'driver opt-in per tag' pattern DirectLogicAddress established for DL260. (3) M-relay and D-register are DECIMAL on every MELSEC family -- docs explicitly; the MELSEC confusion is only about X/Y, not about data registers or internal relays. Helpers reject non-numeric M/D addresses and honor bank bases the same way. (4) Parser walks digits manually for both hex and octal (instead of int.Parse with NumberStyles) so non-hex / non-octal characters give a clear ArgumentException with the offending char + family name. Prevents a subtle class of bugs where int.Parse('X20', Hex) silently returns 32 even for F_iQF callers. Unit tests (MelsecAddressTests, 34 facts): XInputToDiscrete_QLiQR_parses_hex theory (X0, X9, XA, XF, X10, X20, X1FF + lowercase); XInputToDiscrete_FiQF_parses_octal theory (X0, X7, X10, X20, X777); YOutputToCoil equivalents; Same_address_string_decodes_differently_between_families (the headline trap, X20 => 32 on Q vs 16 on FX); reject-non-octal / reject-non-hex / reject-empty / overflow facts; honors-bank-base for X and M and D. 176/176 Modbus.Tests pass (143 prior + 34 new Melsec). No driver core changes -- this is purely a new helper class in the Driver.Modbus project. PR 60 wires it into integration tests against the mitsubishi pymodbus profile.
2026-04-18 23:04:52 -04:00

117 lines
4.6 KiB
C#

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<ArgumentException>(() => MelsecAddress.XInputToDiscrete(bad, MelsecFamily.F_iQF));
[Theory]
[InlineData("X12G")]
public void XInputToDiscrete_QLiQR_rejects_non_hex(string bad)
=> Should.Throw<ArgumentException>(() => 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<ArgumentException>(() => 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<ArgumentException>(() => MelsecAddress.DRegisterToHolding("D"));
// --- overflow ---
[Fact]
public void XInputToDiscrete_overflow_throws()
{
// 0xFFFF + base 1 = 0x10000 — past ushort.
Should.Throw<OverflowException>(() =>
MelsecAddress.XInputToDiscrete("XFFFF", MelsecFamily.Q_L_iQR, xBankBase: 1));
}
}