using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.Modbus; namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests; /// /// Driver.Modbus.Addressing-008: boundary and overflow edge cases for the address-arithmetic /// helpers and the parser input-validation paths. These cover the risk surface cited in the /// code review: overflow in DL205 / MELSEC helpers, empty trailing parser fields (finding /// -002), and coverage of (finding -001 /// regression guard), which was previously unreachable from the parser. /// [Trait("Category", "Unit")] public sealed class ModbusAddressEdgeCaseTests { // ── Parser: empty trailing-field rejection (Driver.Modbus.Addressing-002) ────────────── [Fact] public void Parser_3field_empty_third_field_rejected() { // "40001:F:" — trailing colon with nothing after it must produce a diagnostic, not // silently parse as a scalar (Enumerable.All returns true for an empty sequence). var ok = ModbusAddressParser.TryParse("40001:F:", out _, out var error); ok.ShouldBeFalse(); error.ShouldNotBeNullOrEmpty(); error!.ShouldContain("empty"); } [Fact] public void Parser_4field_empty_third_field_accepted_as_default_order() { // "40001:F::5" — empty order field in 4-field form IS valid (means default byte order). // This is a different case from the 3-field "40001:F:" empty trailing colon. var ok = ModbusAddressParser.TryParse("40001:F::5", out var result, out _); ok.ShouldBeTrue(); result!.ByteOrder.ShouldBe(ModbusByteOrder.BigEndian); result.ArrayCount.ShouldBe(5); } // ── Parser: misplaced type code gives better diagnostic (Driver.Modbus.Addressing-003) ─ [Fact] public void Parser_3field_misplaced_type_in_third_field_gives_helpful_error() { // "40001:S:BOOL" — BOOL is a 4-letter type code typed in the byte-order field. // The parser should mention that field 3 is a byte order, not a type. var ok = ModbusAddressParser.TryParse("40001:S:BOOL", out _, out var error); ok.ShouldBeFalse(); error.ShouldNotBeNullOrEmpty(); // The error should guide the user toward the correct field (field 2 for type). error!.ShouldContain("field 2", Case.Insensitive); } // ── Parser: multi-dot input (Driver.Modbus.Addressing-004) ────────────────────────────── [Fact] public void Parser_multi_dot_input_rejected_with_clear_error() { // "40001.5.3" — multiple dots should not silently parse bit as "5.3". var ok = ModbusAddressParser.TryParse("40001.5.3", out _, out var error); ok.ShouldBeFalse(); error.ShouldNotBeNullOrEmpty(); } [Fact] public void Parser_decimal_point_typo_400_01_gives_precise_error() { // "400.01" — looks like a Modicon decimal typo. The bit suffix "01" is valid (bit index // 1), but "400" fails Modicon validation with a length error — NOT a bit-index error. // Verify the parser fails (any diagnostic is acceptable; we just check it fails). var ok = ModbusAddressParser.TryParse("400.01", out _, out var error); ok.ShouldBeFalse(); error.ShouldNotBeNullOrEmpty(); } // ── DirectLogicAddress overflow and boundary (Driver.Modbus.Addressing-008) ──────────── [Fact] public void UserVMemoryToPdu_overflow_throws_OverflowException() { // V200000 in octal = 65536 decimal — just beyond ushort.MaxValue. Should.Throw(() => DirectLogicAddress.UserVMemoryToPdu("V200000")); } [Fact] public void AddOctalOffset_overflow_via_Y_throws_OverflowException() { // Y prefix with an octal value that pushes YOutputBaseCoil (2048) past 0xFFFF. // YOutputBaseCoil = 2048; we need octal digits that decode to > 65535 - 2048 = 63487. // 63488 in octal = 174000; "Y174000" should overflow. Should.Throw(() => DirectLogicAddress.YOutputToCoil("Y174000")); } [Fact] public void AddOctalOffset_overflow_via_C_throws_OverflowException() { // CRelayBaseCoil = 3072; we need offset > 65535 - 3072 = 62463. // 62464 in octal = 172000; "C172000" should overflow. Should.Throw(() => DirectLogicAddress.CRelayToCoil("C172000")); } [Fact] public void SystemVMemoryToPdu_is_exercised_and_returns_correct_base() { // Direct coverage of SystemVMemoryToPdu — previously unreachable from the parser // before Driver.Modbus.Addressing-001 was fixed (VMemoryToPdu now calls it). DirectLogicAddress.SystemVMemoryToPdu(0).ShouldBe(DirectLogicAddress.SystemVMemoryBasePdu); DirectLogicAddress.SystemVMemoryToPdu(1).ShouldBe((ushort)(DirectLogicAddress.SystemVMemoryBasePdu + 1)); } [Fact] public void SystemVMemoryToPdu_overflow_throws_OverflowException() { // An offset that pushes SystemVMemoryBasePdu (0x2100 = 8448) past 0xFFFF. // 0xFFFF - 0x2100 + 1 = 57088 (0xDF00) should overflow. Should.Throw(() => DirectLogicAddress.SystemVMemoryToPdu(0xDF00)); } // ── MelsecAddress overflow / boundary (Driver.Modbus.Addressing-008) ───────────────── [Fact] public void MelsecAddress_ParseHex_overflow_throws_OverflowException() { // X address in Q-family (hex): "X10000" = 0x10000 = 65536, overflows ushort. Should.Throw(() => MelsecAddress.XInputToDiscrete("X10000", MelsecFamily.Q_L_iQR)); } [Fact] public void MelsecAddress_DRegisterToHolding_overflow_throws_OverflowException() { // D65536 + base 0 = 65536, overflows ushort.MaxValue. Should.Throw(() => MelsecAddress.DRegisterToHolding("D65536")); } [Fact] public void MelsecAddress_MRelayToCoil_overflow_throws_OverflowException() { // M65535 with base 1 = 65536, overflows. Should.Throw(() => MelsecAddress.MRelayToCoil("M65535", mBankBase: 1)); } [Fact] public void MelsecAddress_DRegisterToHolding_bank_base_overflow_throws_OverflowException() { // D0 with a bank base that itself overflows: base 65535 + D1 = 65536. Should.Throw(() => MelsecAddress.DRegisterToHolding("D1", dBankBase: 65535)); } // ── TryParse never throws (Driver.Modbus.Addressing-006) ───────────────────────────────── // // The TryParse contract is that it converts every parse failure into a structured (false, // error) return — config-bind hot paths depend on this. The family-native catch was previously // narrow (ArgumentException / OverflowException only); any future helper change that threw a // different exception type (e.g. FormatException from a ushort.Parse swap) would escape as an // unhandled exception out of a TryParse method. These tests assert the defensive contract // across a broad set of pathological inputs. [Theory] [InlineData("V")] // V prefix with no digits [InlineData("V99999999999999")] // overflow in user V-memory octal decode [InlineData("V200000")] // overflow in user V-memory octal decode [InlineData("V77777777")] // octal way past 0xFFFF in system bank [InlineData("Y")] // Y prefix with no digits [InlineData("Y8888")] // non-octal digit [InlineData("Y174000")] // octal offset overflows YOutputBaseCoil + value [InlineData("C")] // C prefix alone [InlineData("C99999999")] // overflow in C-relay [InlineData("X")] // X prefix alone [InlineData("X8")] // non-octal digit [InlineData("SP")] // SP prefix alone [InlineData("SP9")] // non-octal digit [InlineData("Z123")] // unknown DL205 prefix public void DL205_TryParse_NeverThrows_ReturnsStructuredError(string addr) { // Defensive contract: any helper failure must surface as (false, non-null error), never // as an unhandled exception out of TryParse. var ok = ModbusAddressParser.TryParse(addr, ModbusFamily.DL205, MelsecFamily.Q_L_iQR, out var result, out var error); ok.ShouldBeFalse(); result.ShouldBeNull(); error.ShouldNotBeNullOrEmpty(); } [Theory] [InlineData("D")] // D prefix alone — no digits [InlineData("D-1")] // negative — would fail ushort.TryParse, must not throw [InlineData("D65536")] // overflow [InlineData("DABC")] // non-decimal digits in D [InlineData("MABC")] // non-decimal digits in M [InlineData("X10000")] // hex overflow (Q-family) [InlineData("XZZZZ")] // non-hex digit (Q-family) [InlineData("Y10000")] // hex overflow (Q-family) public void MELSEC_TryParse_NeverThrows_ReturnsStructuredError(string addr) { var ok = ModbusAddressParser.TryParse(addr, ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR, out var result, out var error); ok.ShouldBeFalse(); result.ShouldBeNull(); error.ShouldNotBeNullOrEmpty(); } // ── ModbusStringByteOrder is grammar-out-of-scope (Driver.Modbus.Addressing-007) ──────── // // ModbusStringByteOrder (HighByteFirst / LowByteFirst) is the DL205 low-byte-first packing // knob. It is intentionally NOT expressible through the address grammar — there is no token // form to set it and ParsedModbusAddress has no field for it. The string byte order is // configurable only via the structured tag form (ModbusTagDefinition.StringByteOrder), which // is the canonical config path. These tests pin that contract so a future grammar change // can't quietly add a token that conflicts with the array-count slot. [Fact] public void Parser_STR_grammar_does_not_carry_StringByteOrder() { // STR20 parses fine — but the result has no StringByteOrder field (the property does // not exist on ParsedModbusAddress). The string byte order must be set on the structured // tag definition, not the grammar string. var ok = ModbusAddressParser.TryParse("40001:STR20", out var result, out _); ok.ShouldBeTrue(); result!.DataType.ShouldBe(ModbusDataType.String); result.StringLength.ShouldBe((ushort)20); // Compile-time assertion: ParsedModbusAddress does not expose StringByteOrder. // Searching for a property by reflection would let us assert "no such field": typeof(ParsedModbusAddress) .GetProperty("StringByteOrder") .ShouldBeNull(); } [Fact] public void Parser_rejects_unknown_string_byte_order_token_in_grammar() { // A user trying to express low-byte-first via a grammar suffix like "LOWB" or "HIGH" in // the byte-order slot gets the standard "Unknown byte order" diagnostic — the parser is // explicit that field 3 is the multi-register word/byte order, not the per-string byte // order. The structured tag form is the only configuration path for ModbusStringByteOrder. var ok = ModbusAddressParser.TryParse("40001:STR20:LOWB", out _, out var error); ok.ShouldBeFalse(); error.ShouldNotBeNullOrEmpty(); error!.ShouldContain("byte order", Case.Insensitive); } }