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. Now bind-time /// validation in ModbusDriverFactoryExtensions rejects the misconfiguration with a /// clear diagnostic. /// (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 { /// Verifies that string tags with zero length are rejected during factory creation. [Fact] public void Factory_rejects_String_tag_with_StringLength_zero_via_structured_form() { const string json = """ { "host": "10.0.0.10", "tags": [ { "name": "Greeting", "region": "HoldingRegisters", "address": 100, "dataType": "String", "stringLength": 0 } ] } """; var ex = Should.Throw( () => ModbusDriverFactoryExtensions.CreateInstance("modbus-1", json)); ex.Message.ShouldContain("StringLength"); ex.Message.ShouldContain("Greeting"); } /// Verifies that omitted string length defaults to zero and is rejected. [Fact] public void Factory_rejects_String_tag_with_StringLength_zero_via_missing_field() { // No stringLength → defaults to 0. Same misconfiguration via a different DTO shape. const string json = """ { "host": "10.0.0.10", "tags": [ { "name": "Greeting", "region": "HoldingRegisters", "address": 100, "dataType": "String" } ] } """; var ex = Should.Throw( () => ModbusDriverFactoryExtensions.CreateInstance("modbus-1", json)); ex.Message.ShouldContain("StringLength"); } /// Verifies that string tags with length one are accepted. [Fact] public void Factory_accepts_String_tag_with_StringLength_one() { const string json = """ { "host": "10.0.0.10", "tags": [ { "name": "Greeting", "region": "HoldingRegisters", "address": 100, "dataType": "String", "stringLength": 1 } ] } """; Should.NotThrow(() => ModbusDriverFactoryExtensions.CreateInstance("modbus-1", json)); } /// Verifies that non-string tags are unaffected by string length zero. [Fact] public void Factory_accepts_non_String_tag_with_StringLength_zero() { // The validation only kicks in for String tags — Int16 tags with StringLength=0 are normal. const string json = """ { "host": "10.0.0.10", "tags": [ { "name": "Level", "region": "HoldingRegisters", "address": 100, "dataType": "Int16" } ] } """; Should.NotThrow(() => ModbusDriverFactoryExtensions.CreateInstance("modbus-1", json)); } /// 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); } }