using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests; /// /// Regression coverage for Driver.Modbus-009: two configuration edge cases that previously /// silently produced wrong wire behaviour. /// (1) StringLength = 0 for a String-typed tag — used to flow into an FC03 /// with quantity 0, a spec-illegal request the PLC rejects with exception 03. In v3 the guard /// moved to the pure mapper (): a String /// tag with stringLength < 1 fails to map (⇒ the driver skips it / surfaces /// BadNodeIdUnknown) instead of the factory throwing at bind time. /// (2) Sub-second values on ModbusKeepAliveOptions.Time / /// Interval — the int-cast in EnableKeepAlive truncated 500 ms to /// 0, which most OSes interpret as "use the default", silently defeating the /// configured timing. ModbusTcpTransport.ClampToWholeSeconds rounds up to a minimum /// of 1 second. /// [Trait("Category", "Unit")] public sealed class ModbusEdgeCaseValidationTests { private const string RawPath = "cell/modbus/dev1/Greeting"; /// Verifies that a String tag with an explicit zero length fails to map. [Fact] public void Mapper_rejects_String_tag_with_StringLength_zero_explicit() { const string json = """{"region":"HoldingRegisters","address":100,"dataType":"String","stringLength":0}"""; ModbusTagDefinitionFactory.FromTagConfig(json, RawPath, out _).ShouldBeFalse(); } /// Verifies that a String tag with an omitted length (defaults to zero) fails to map. [Fact] public void Mapper_rejects_String_tag_with_StringLength_zero_missing_field() { // No stringLength → defaults to 0. Same misconfiguration via a different blob shape. const string json = """{"region":"HoldingRegisters","address":100,"dataType":"String"}"""; ModbusTagDefinitionFactory.FromTagConfig(json, RawPath, out _).ShouldBeFalse(); } /// Verifies that a String tag with length one maps successfully. [Fact] public void Mapper_accepts_String_tag_with_StringLength_one() { const string json = """{"region":"HoldingRegisters","address":100,"dataType":"String","stringLength":1}"""; ModbusTagDefinitionFactory.FromTagConfig(json, RawPath, out var def).ShouldBeTrue(); def.StringLength.ShouldBe((ushort)1); } /// Verifies that non-String tags are unaffected by a zero string length. [Fact] public void Mapper_accepts_non_String_tag_with_StringLength_zero() { // The guard only kicks in for String tags — Int16 tags with StringLength=0 are normal. const string json = """{"region":"HoldingRegisters","address":100,"dataType":"Int16"}"""; ModbusTagDefinitionFactory.FromTagConfig(json, RawPath, out _).ShouldBeTrue(); } /// Verifies that sub-second time spans are rounded up to at least one second. /// The input duration in milliseconds. /// The expected clamped value in whole seconds. [Theory] [InlineData(0, 1)] // zero clamps up to 1 [InlineData(500, 1)] // 500 ms rounds up to 1 [InlineData(999, 1)] // just under 1s rounds up to 1 [InlineData(1_000, 1)] // exactly 1s passes through [InlineData(1_500, 2)] // 1.5s rounds up to 2 [InlineData(30_000, 30)] // historical PR 53 default — unchanged [InlineData(60_000, 60)] public void ClampToWholeSeconds_rounds_up_to_at_least_one_second(int ms, int expected) { ModbusTcpTransport.ClampToWholeSeconds(TimeSpan.FromMilliseconds(ms)).ShouldBe(expected); } /// Verifies that negative time spans are treated as one second. [Fact] public void ClampToWholeSeconds_treats_negative_TimeSpan_as_one_second() { // Defensive — operators occasionally configure a negative TimeSpan thinking it disables // the feature. The OS would reject the negative int — clamping to 1 keeps the socket // valid until the operator fixes the config. ModbusTcpTransport.ClampToWholeSeconds(TimeSpan.FromSeconds(-5)).ShouldBe(1); } }