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);
}
}