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 { [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"); } [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"); } [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)); } [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)); } [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); } [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); } }