docs: backfill XML documentation across 756 files
v2-ci / build (push) Failing after 1m43s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
v2-ci / build (push) Failing after 1m43s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Adds <summary>, <param>, <typeparam>, and <inheritdoc/> tags to public members surfaced by commentchecker — resolves 5,847 of 5,869 issues (99.6%) across three /fixdocs passes.
This commit is contained in:
@@ -6,6 +6,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DirectLogicAddressTests
|
||||
{
|
||||
/// <summary>Tests that octal V-prefixed addresses are correctly converted to PDU values.</summary>
|
||||
/// <param name="v">V-prefixed octal address string.</param>
|
||||
/// <param name="expected">Expected PDU address value.</param>
|
||||
[Theory]
|
||||
[InlineData("V0", (ushort)0x0000)]
|
||||
[InlineData("V1", (ushort)0x0001)]
|
||||
@@ -18,6 +21,9 @@ public sealed class DirectLogicAddressTests
|
||||
public void UserVMemoryToPdu_converts_octal_V_prefix(string v, ushort expected)
|
||||
=> DirectLogicAddress.UserVMemoryToPdu(v).ShouldBe(expected);
|
||||
|
||||
/// <summary>Tests that user memory addresses accept bare, prefixed, and padded forms.</summary>
|
||||
/// <param name="v">Address string in various formats.</param>
|
||||
/// <param name="expected">Expected PDU address value.</param>
|
||||
[Theory]
|
||||
[InlineData("0", (ushort)0)]
|
||||
[InlineData("2000", (ushort)0x0400)]
|
||||
@@ -26,6 +32,8 @@ public sealed class DirectLogicAddressTests
|
||||
public void UserVMemoryToPdu_accepts_bare_or_prefixed_or_padded(string v, ushort expected)
|
||||
=> DirectLogicAddress.UserVMemoryToPdu(v).ShouldBe(expected);
|
||||
|
||||
/// <summary>Tests that non-octal digits in user memory addresses are rejected.</summary>
|
||||
/// <param name="v">Address string with invalid octal digits.</param>
|
||||
[Theory]
|
||||
[InlineData("V8")] // 8 is not a valid octal digit
|
||||
[InlineData("V19")]
|
||||
@@ -36,6 +44,8 @@ public sealed class DirectLogicAddressTests
|
||||
.Message.ShouldContain("octal");
|
||||
}
|
||||
|
||||
/// <summary>Tests that empty input for user memory addresses is rejected.</summary>
|
||||
/// <param name="v">Empty or whitespace-only address string.</param>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
@@ -44,6 +54,7 @@ public sealed class DirectLogicAddressTests
|
||||
public void UserVMemoryToPdu_rejects_empty_input(string? v)
|
||||
=> Should.Throw<ArgumentException>(() => DirectLogicAddress.UserVMemoryToPdu(v!));
|
||||
|
||||
/// <summary>Tests that user memory overflow is rejected.</summary>
|
||||
[Fact]
|
||||
public void UserVMemoryToPdu_overflow_rejected()
|
||||
{
|
||||
@@ -51,6 +62,7 @@ public sealed class DirectLogicAddressTests
|
||||
Should.Throw<OverflowException>(() => DirectLogicAddress.UserVMemoryToPdu("V200000"));
|
||||
}
|
||||
|
||||
/// <summary>Tests that system memory base PDU is correctly set to 0x2100.</summary>
|
||||
[Fact]
|
||||
public void SystemVMemoryBasePdu_is_0x2100_for_V40400()
|
||||
{
|
||||
@@ -60,6 +72,7 @@ public sealed class DirectLogicAddressTests
|
||||
DirectLogicAddress.SystemVMemoryToPdu(0).ShouldBe((ushort)0x2100);
|
||||
}
|
||||
|
||||
/// <summary>Tests that system memory addresses correctly offset within bank.</summary>
|
||||
[Fact]
|
||||
public void SystemVMemoryToPdu_offsets_within_bank()
|
||||
{
|
||||
@@ -67,6 +80,7 @@ public sealed class DirectLogicAddressTests
|
||||
DirectLogicAddress.SystemVMemoryToPdu(0x100).ShouldBe((ushort)0x2200);
|
||||
}
|
||||
|
||||
/// <summary>Tests that system memory overflow is rejected.</summary>
|
||||
[Fact]
|
||||
public void SystemVMemoryToPdu_rejects_overflow()
|
||||
{
|
||||
@@ -77,6 +91,9 @@ public sealed class DirectLogicAddressTests
|
||||
|
||||
// --- Bit memory: Y-output, C-relay, X-input, SP-special ---
|
||||
|
||||
/// <summary>Tests that Y-output addresses correctly add octal offset to 2048.</summary>
|
||||
/// <param name="y">Y-prefixed octal address string.</param>
|
||||
/// <param name="expected">Expected coil address value.</param>
|
||||
[Theory]
|
||||
[InlineData("Y0", (ushort)2048)]
|
||||
[InlineData("Y1", (ushort)2049)]
|
||||
@@ -87,6 +104,9 @@ public sealed class DirectLogicAddressTests
|
||||
public void YOutputToCoil_adds_octal_offset_to_2048(string y, ushort expected)
|
||||
=> DirectLogicAddress.YOutputToCoil(y).ShouldBe(expected);
|
||||
|
||||
/// <summary>Tests that C-relay addresses correctly add octal offset to 3072.</summary>
|
||||
/// <param name="c">C-prefixed octal address string.</param>
|
||||
/// <param name="expected">Expected coil address value.</param>
|
||||
[Theory]
|
||||
[InlineData("C0", (ushort)3072)]
|
||||
[InlineData("C1", (ushort)3073)]
|
||||
@@ -95,6 +115,9 @@ public sealed class DirectLogicAddressTests
|
||||
public void CRelayToCoil_adds_octal_offset_to_3072(string c, ushort expected)
|
||||
=> DirectLogicAddress.CRelayToCoil(c).ShouldBe(expected);
|
||||
|
||||
/// <summary>Tests that X-input addresses correctly add octal offset to 0.</summary>
|
||||
/// <param name="x">X-prefixed octal address string.</param>
|
||||
/// <param name="expected">Expected discrete address value.</param>
|
||||
[Theory]
|
||||
[InlineData("X0", (ushort)0)]
|
||||
[InlineData("X17", (ushort)15)]
|
||||
@@ -102,6 +125,9 @@ public sealed class DirectLogicAddressTests
|
||||
public void XInputToDiscrete_adds_octal_offset_to_0(string x, ushort expected)
|
||||
=> DirectLogicAddress.XInputToDiscrete(x).ShouldBe(expected);
|
||||
|
||||
/// <summary>Tests that special-purpose bit addresses correctly add octal offset to 1024.</summary>
|
||||
/// <param name="sp">SP-prefixed octal address string.</param>
|
||||
/// <param name="expected">Expected discrete address value.</param>
|
||||
[Theory]
|
||||
[InlineData("SP0", (ushort)1024)]
|
||||
[InlineData("SP7", (ushort)1031)]
|
||||
@@ -110,6 +136,8 @@ public sealed class DirectLogicAddressTests
|
||||
public void SpecialToDiscrete_adds_octal_offset_to_1024(string sp, ushort expected)
|
||||
=> DirectLogicAddress.SpecialToDiscrete(sp).ShouldBe(expected);
|
||||
|
||||
/// <summary>Tests that non-octal digits in bit addresses are rejected.</summary>
|
||||
/// <param name="bad">Bit address with invalid octal digits.</param>
|
||||
[Theory]
|
||||
[InlineData("Y8")]
|
||||
[InlineData("C9")]
|
||||
@@ -122,6 +150,8 @@ public sealed class DirectLogicAddressTests
|
||||
else DirectLogicAddress.XInputToDiscrete(bad);
|
||||
});
|
||||
|
||||
/// <summary>Tests that empty input for bit addresses is rejected.</summary>
|
||||
/// <param name="bad">Empty or incomplete bit address.</param>
|
||||
[Theory]
|
||||
[InlineData("Y")]
|
||||
[InlineData("C")]
|
||||
@@ -129,10 +159,12 @@ public sealed class DirectLogicAddressTests
|
||||
public void Bit_address_rejects_empty(string bad)
|
||||
=> Should.Throw<ArgumentException>(() => DirectLogicAddress.YOutputToCoil(bad));
|
||||
|
||||
/// <summary>Tests that Y-output addresses accept lowercase prefix.</summary>
|
||||
[Fact]
|
||||
public void YOutputToCoil_accepts_lowercase_prefix()
|
||||
=> DirectLogicAddress.YOutputToCoil("y0").ShouldBe((ushort)2048);
|
||||
|
||||
/// <summary>Tests that C-relay addresses accept bare octal without C prefix.</summary>
|
||||
[Fact]
|
||||
public void CRelayToCoil_accepts_bare_octal_without_C_prefix()
|
||||
=> DirectLogicAddress.CRelayToCoil("0").ShouldBe((ushort)3072);
|
||||
|
||||
@@ -8,6 +8,7 @@ public sealed class MelsecAddressTests
|
||||
{
|
||||
// --- X / Y hex vs octal family trap ---
|
||||
|
||||
/// <summary>Verifies that Q-series and iQR family X inputs parse as hexadecimal.</summary>
|
||||
[Theory]
|
||||
[InlineData("X0", (ushort)0)]
|
||||
[InlineData("X9", (ushort)9)]
|
||||
@@ -20,6 +21,7 @@ public sealed class MelsecAddressTests
|
||||
public void XInputToDiscrete_QLiQR_parses_hex(string x, ushort expected)
|
||||
=> MelsecAddress.XInputToDiscrete(x, MelsecFamily.Q_L_iQR).ShouldBe(expected);
|
||||
|
||||
/// <summary>Verifies that F-series and iQF family X inputs parse as octal.</summary>
|
||||
[Theory]
|
||||
[InlineData("X0", (ushort)0)]
|
||||
[InlineData("X7", (ushort)7)]
|
||||
@@ -29,18 +31,23 @@ public sealed class MelsecAddressTests
|
||||
public void XInputToDiscrete_FiQF_parses_octal(string x, ushort expected)
|
||||
=> MelsecAddress.XInputToDiscrete(x, MelsecFamily.F_iQF).ShouldBe(expected);
|
||||
|
||||
/// <summary>Verifies that Q-series and iQR family Y outputs parse as hexadecimal.</summary>
|
||||
[Theory]
|
||||
[InlineData("Y0", (ushort)0)]
|
||||
[InlineData("Y1F", (ushort)31)]
|
||||
public void YOutputToCoil_QLiQR_parses_hex(string y, ushort expected)
|
||||
=> MelsecAddress.YOutputToCoil(y, MelsecFamily.Q_L_iQR).ShouldBe(expected);
|
||||
|
||||
/// <summary>Verifies that F-series and iQF family Y outputs parse as octal.</summary>
|
||||
[Theory]
|
||||
[InlineData("Y0", (ushort)0)]
|
||||
[InlineData("Y17", (ushort)15)]
|
||||
public void YOutputToCoil_FiQF_parses_octal(string y, ushort expected)
|
||||
=> MelsecAddress.YOutputToCoil(y, MelsecFamily.F_iQF).ShouldBe(expected);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the same address string decodes to different values depending on the family.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Same_address_string_decodes_differently_between_families()
|
||||
{
|
||||
@@ -50,17 +57,22 @@ public sealed class MelsecAddressTests
|
||||
MelsecAddress.XInputToDiscrete("X20", MelsecFamily.F_iQF).ShouldBe((ushort)16);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that non-octal X input addresses are rejected for F-series and iQF families.</summary>
|
||||
[Theory]
|
||||
[InlineData("X8")] // 8 is non-octal
|
||||
[InlineData("X12G")] // G is non-hex
|
||||
public void XInputToDiscrete_FiQF_rejects_non_octal(string bad)
|
||||
=> Should.Throw<ArgumentException>(() => MelsecAddress.XInputToDiscrete(bad, MelsecFamily.F_iQF));
|
||||
|
||||
/// <summary>Verifies that non-hexadecimal X input addresses are rejected for Q-series and iQR families.</summary>
|
||||
[Theory]
|
||||
[InlineData("X12G")]
|
||||
public void XInputToDiscrete_QLiQR_rejects_non_hex(string bad)
|
||||
=> Should.Throw<ArgumentException>(() => MelsecAddress.XInputToDiscrete(bad, MelsecFamily.Q_L_iQR));
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the bank base from assignment blocks is honored for X input addresses.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void XInputToDiscrete_honors_bank_base_from_assignment_block()
|
||||
{
|
||||
@@ -71,6 +83,7 @@ public sealed class MelsecAddressTests
|
||||
|
||||
// --- M-relay (decimal, both families) ---
|
||||
|
||||
/// <summary>Verifies that M relay addresses parse as decimal.</summary>
|
||||
[Theory]
|
||||
[InlineData("M0", (ushort)0)]
|
||||
[InlineData("M10", (ushort)10)] // M addresses are DECIMAL, not hex or octal
|
||||
@@ -79,16 +92,23 @@ public sealed class MelsecAddressTests
|
||||
public void MRelayToCoil_parses_decimal(string m, ushort expected)
|
||||
=> MelsecAddress.MRelayToCoil(m).ShouldBe(expected);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the bank base is honored for M relay addresses.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MRelayToCoil_honors_bank_base()
|
||||
=> MelsecAddress.MRelayToCoil("M0", mBankBase: 512).ShouldBe((ushort)512);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that non-numeric M relay addresses are rejected.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MRelayToCoil_rejects_non_numeric()
|
||||
=> Should.Throw<ArgumentException>(() => MelsecAddress.MRelayToCoil("M1F"));
|
||||
|
||||
// --- D-register (decimal, both families) ---
|
||||
|
||||
/// <summary>Verifies that D register addresses parse as decimal.</summary>
|
||||
[Theory]
|
||||
[InlineData("D0", (ushort)0)]
|
||||
[InlineData("D100", (ushort)100)]
|
||||
@@ -96,16 +116,25 @@ public sealed class MelsecAddressTests
|
||||
public void DRegisterToHolding_parses_decimal(string d, ushort expected)
|
||||
=> MelsecAddress.DRegisterToHolding(d).ShouldBe(expected);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the bank base is honored for D register addresses.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DRegisterToHolding_honors_bank_base()
|
||||
=> MelsecAddress.DRegisterToHolding("D10", dBankBase: 4096).ShouldBe((ushort)4106);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that empty D register addresses are rejected.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DRegisterToHolding_rejects_empty()
|
||||
=> Should.Throw<ArgumentException>(() => MelsecAddress.DRegisterToHolding("D"));
|
||||
|
||||
// --- overflow ---
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that X input addresses with overflow are rejected.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void XInputToDiscrete_overflow_throws()
|
||||
{
|
||||
|
||||
@@ -21,6 +21,7 @@ public sealed class ModbusArrayTests
|
||||
return (drv, fake);
|
||||
}
|
||||
|
||||
/// <summary>Verifies reading an Int16 array returns a typed array.</summary>
|
||||
[Fact]
|
||||
public async Task Read_Int16_Array_Returns_Typed_Array()
|
||||
{
|
||||
@@ -34,6 +35,7 @@ public sealed class ModbusArrayTests
|
||||
arr.ShouldBe(new short[] { 100, 101, 102, 103, 104 });
|
||||
}
|
||||
|
||||
/// <summary>Verifies reading a Float32 array with word swap returns a typed array.</summary>
|
||||
[Fact]
|
||||
public async Task Read_Float32_Array_Returns_Typed_Array_With_WordSwap()
|
||||
{
|
||||
@@ -60,6 +62,7 @@ public sealed class ModbusArrayTests
|
||||
arr.ShouldBe(src);
|
||||
}
|
||||
|
||||
/// <summary>Verifies reading a coil array returns a bool array.</summary>
|
||||
[Fact]
|
||||
public async Task Read_Coil_Array_Returns_Bool_Array()
|
||||
{
|
||||
@@ -74,6 +77,7 @@ public sealed class ModbusArrayTests
|
||||
arr.ShouldBe(new[] { true, false, true, false, true, false, true, false, true, false });
|
||||
}
|
||||
|
||||
/// <summary>Verifies writing an Int16 array lands contiguously in the register bank.</summary>
|
||||
[Fact]
|
||||
public async Task Write_Int16_Array_Lands_Contiguous_In_Bank()
|
||||
{
|
||||
@@ -91,6 +95,7 @@ public sealed class ModbusArrayTests
|
||||
fake.HoldingRegisters[50 + i].ShouldBe((ushort)write[i]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies writing a coil array packs bits in LSB-first order.</summary>
|
||||
[Fact]
|
||||
public async Task Write_Coil_Array_Packs_LSB_First()
|
||||
{
|
||||
@@ -108,6 +113,7 @@ public sealed class ModbusArrayTests
|
||||
fake.Coils[i].ShouldBe(pattern[i]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies writing an array with mismatched length surfaces an error.</summary>
|
||||
[Fact]
|
||||
public async Task Write_Array_Mismatch_Length_Surfaces_Error()
|
||||
{
|
||||
@@ -122,6 +128,7 @@ public sealed class ModbusArrayTests
|
||||
results[0].StatusCode.ShouldNotBe(0u);
|
||||
}
|
||||
|
||||
/// <summary>Verifies discovery surfaces IsArray and ArrayDim correctly.</summary>
|
||||
[Fact]
|
||||
public async Task Discovery_Surfaces_IsArray_And_ArrayDim()
|
||||
{
|
||||
@@ -137,6 +144,7 @@ public sealed class ModbusArrayTests
|
||||
captured[0].ArrayDim.ShouldBe(8u);
|
||||
}
|
||||
|
||||
/// <summary>Verifies scalar tag discovery keeps IsArray false.</summary>
|
||||
[Fact]
|
||||
public async Task Scalar_Tag_Discovery_Stays_NonArray()
|
||||
{
|
||||
@@ -152,19 +160,43 @@ public sealed class ModbusArrayTests
|
||||
captured[0].ArrayDim.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Recording address space builder for capturing discovered attributes.</summary>
|
||||
/// <param name="captured">List to capture discovered attributes into.</param>
|
||||
private sealed class RecordingBuilder(List<DriverAttributeInfo> captured) : IAddressSpaceBuilder
|
||||
{
|
||||
/// <summary>Creates a folder in the address space.</summary>
|
||||
/// <param name="browseName">The browse name of the folder.</param>
|
||||
/// <param name="displayName">The display name of the folder.</param>
|
||||
/// <returns>This builder instance.</returns>
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
||||
|
||||
/// <summary>Creates a variable in the address space.</summary>
|
||||
/// <param name="browseName">The browse name of the variable.</param>
|
||||
/// <param name="displayName">The display name of the variable.</param>
|
||||
/// <param name="attributeInfo">The attribute information.</param>
|
||||
/// <returns>A variable handle.</returns>
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
{
|
||||
captured.Add(attributeInfo);
|
||||
return new StubHandle(browseName);
|
||||
}
|
||||
|
||||
/// <summary>Adds a property to the current node.</summary>
|
||||
/// <param name="browseName">The browse name of the property.</param>
|
||||
/// <param name="dataType">The data type of the property.</param>
|
||||
/// <param name="value">The property value.</param>
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||
|
||||
/// <summary>Stub variable handle for testing.</summary>
|
||||
/// <param name="fullRef">The full reference of the handle.</param>
|
||||
private sealed class StubHandle(string fullRef) : IVariableHandle
|
||||
{
|
||||
/// <summary>Gets the full reference.</summary>
|
||||
public string FullReference => fullRef;
|
||||
|
||||
/// <summary>Marks this variable as an alarm condition.</summary>
|
||||
/// <param name="info">The alarm condition information.</param>
|
||||
/// <returns>An alarm condition sink.</returns>
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
|
||||
=> throw new NotSupportedException("RecordingBuilder doesn't model alarms");
|
||||
}
|
||||
|
||||
@@ -14,8 +14,16 @@ public sealed class ModbusBitRmwTests
|
||||
public readonly ushort[] HoldingRegisters = new ushort[256];
|
||||
public readonly List<byte[]> Pdus = new();
|
||||
|
||||
/// <summary>Connects asynchronously (no-op for fake).</summary>
|
||||
/// <param name="ct">Cancellation token (unused).</param>
|
||||
/// <returns>A completed task.</returns>
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
/// <summary>Sends a Modbus PDU and returns a response.</summary>
|
||||
/// <param name="unitId">The Modbus unit ID (unused).</param>
|
||||
/// <param name="pdu">The protocol data unit to send.</param>
|
||||
/// <param name="ct">Cancellation token (unused).</param>
|
||||
/// <returns>A task containing the response PDU.</returns>
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
Pdus.Add(pdu);
|
||||
@@ -45,6 +53,8 @@ public sealed class ModbusBitRmwTests
|
||||
return Task.FromException<byte[]>(new NotSupportedException($"FC 0x{pdu[0]:X2} not supported by fake"));
|
||||
}
|
||||
|
||||
/// <summary>Disposes asynchronously (no-op for fake).</summary>
|
||||
/// <returns>A completed task.</returns>
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -60,6 +70,7 @@ public sealed class ModbusBitRmwTests
|
||||
return (new ModbusDriver(opts, "modbus-1", _ => fake), fake);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that setting a bit reads the current register, ORs the bit, and writes back.</summary>
|
||||
[Fact]
|
||||
public async Task Bit_set_reads_current_register_ORs_bit_writes_back()
|
||||
{
|
||||
@@ -78,6 +89,7 @@ public sealed class ModbusBitRmwTests
|
||||
fake.Pdus[1][0].ShouldBe((byte)0x06);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that clearing a bit reads the current register, ANDs the bit off, and writes back.</summary>
|
||||
[Fact]
|
||||
public async Task Bit_clear_reads_current_register_ANDs_bit_off_writes_back()
|
||||
{
|
||||
@@ -91,6 +103,7 @@ public sealed class ModbusBitRmwTests
|
||||
fake.HoldingRegisters[10].ShouldBe((ushort)0b1111_1111_1111_0111); // bit 3 cleared, rest preserved
|
||||
}
|
||||
|
||||
/// <summary>Verifies that concurrent bit writes to the same register preserve all updates via serialization.</summary>
|
||||
[Fact]
|
||||
public async Task Concurrent_bit_writes_to_same_register_preserve_all_updates()
|
||||
{
|
||||
@@ -109,6 +122,7 @@ public sealed class ModbusBitRmwTests
|
||||
fake.HoldingRegisters[20].ShouldBe((ushort)0xFF); // all 8 bits set
|
||||
}
|
||||
|
||||
/// <summary>Verifies that bit writes to different registers proceed in parallel without contention.</summary>
|
||||
[Fact]
|
||||
public async Task Bit_write_on_different_registers_proceeds_in_parallel_without_contention()
|
||||
{
|
||||
@@ -125,6 +139,7 @@ public sealed class ModbusBitRmwTests
|
||||
fake.HoldingRegisters[50 + i].ShouldBe((ushort)0x01);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that bit writes preserve other bits in the same register.</summary>
|
||||
[Fact]
|
||||
public async Task Bit_write_preserves_other_bits_in_the_same_register()
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusByteOrderTests
|
||||
{
|
||||
/// <summary>Verifies that Int32 values decode correctly with ByteSwap (BADC) byte order.</summary>
|
||||
[Fact]
|
||||
public void Int32_ByteSwap_decodes_BADC_layout()
|
||||
{
|
||||
@@ -21,6 +22,7 @@ public sealed class ModbusByteOrderTests
|
||||
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe(0x12345678);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Int32 values decode correctly with FullReverse (DCBA) byte order.</summary>
|
||||
[Fact]
|
||||
public void Int32_FullReverse_decodes_DCBA_layout()
|
||||
{
|
||||
@@ -32,6 +34,8 @@ public sealed class ModbusByteOrderTests
|
||||
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe(0x12345678);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Float32 values roundtrip correctly with all byte orders.</summary>
|
||||
/// <param name="order">The byte order to test.</param>
|
||||
[Theory]
|
||||
[InlineData(ModbusByteOrder.BigEndian)]
|
||||
[InlineData(ModbusByteOrder.WordSwap)]
|
||||
@@ -45,6 +49,8 @@ public sealed class ModbusByteOrderTests
|
||||
ModbusDriver.DecodeRegister(wire, tag).ShouldBe(3.14159f);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Float64 values roundtrip correctly with all byte orders.</summary>
|
||||
/// <param name="order">The byte order to test.</param>
|
||||
[Theory]
|
||||
[InlineData(ModbusByteOrder.BigEndian)]
|
||||
[InlineData(ModbusByteOrder.WordSwap)]
|
||||
@@ -58,6 +64,8 @@ public sealed class ModbusByteOrderTests
|
||||
ModbusDriver.DecodeRegister(wire, tag).ShouldBe(2.718281828459045d);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Int32 values roundtrip correctly with all byte orders.</summary>
|
||||
/// <param name="order">The byte order to test.</param>
|
||||
[Theory]
|
||||
[InlineData(ModbusByteOrder.BigEndian)]
|
||||
[InlineData(ModbusByteOrder.WordSwap)]
|
||||
|
||||
@@ -13,12 +13,17 @@ public sealed class ModbusCapTests
|
||||
/// </summary>
|
||||
private sealed class RecordingTransport : IModbusTransport
|
||||
{
|
||||
/// <summary>Gets the simulated holding register storage.</summary>
|
||||
public readonly ushort[] HoldingRegisters = new ushort[1024];
|
||||
/// <summary>Gets the list of all FC03 read requests made to the transport.</summary>
|
||||
public readonly List<(ushort Address, ushort Quantity)> Fc03Requests = new();
|
||||
/// <summary>Gets the list of all FC16 write requests made to the transport.</summary>
|
||||
public readonly List<(ushort Address, ushort Quantity)> Fc16Requests = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
var fc = pdu[0];
|
||||
@@ -50,9 +55,11 @@ public sealed class ModbusCapTests
|
||||
return Task.FromException<byte[]>(new ModbusException(fc, 0x01, $"fc={fc} unsupported"));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a read within the cap issues a single FC03 request.</summary>
|
||||
[Fact]
|
||||
public async Task Read_within_cap_issues_single_FC03_request()
|
||||
{
|
||||
@@ -69,6 +76,7 @@ public sealed class ModbusCapTests
|
||||
transport.Fc03Requests[0].Quantity.ShouldBe((ushort)20);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a read above cap splits into two FC03 requests.</summary>
|
||||
[Fact]
|
||||
public async Task Read_above_cap_splits_into_two_FC03_requests()
|
||||
{
|
||||
@@ -103,6 +111,7 @@ public sealed class ModbusCapTests
|
||||
s[0].ShouldBe('A'); // register[100] high byte
|
||||
}
|
||||
|
||||
/// <summary>Verifies that read cap honors Mitsubishi lower cap of 64 registers.</summary>
|
||||
[Fact]
|
||||
public async Task Read_cap_honors_Mitsubishi_lower_cap_of_64()
|
||||
{
|
||||
@@ -121,6 +130,7 @@ public sealed class ModbusCapTests
|
||||
transport.Fc03Requests[1].Quantity.ShouldBe((ushort)36);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that write exceeding cap throws instead of splitting.</summary>
|
||||
[Fact]
|
||||
public async Task Write_exceeding_cap_throws_instead_of_splitting()
|
||||
{
|
||||
@@ -144,6 +154,7 @@ public sealed class ModbusCapTests
|
||||
transport.Fc16Requests.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that write within cap proceeds normally.</summary>
|
||||
[Fact]
|
||||
public async Task Write_within_cap_proceeds_normally()
|
||||
{
|
||||
|
||||
+11
@@ -20,9 +20,13 @@ public sealed class ModbusCoalescingAutoRecoveryTests
|
||||
/// </summary>
|
||||
private sealed class ProtectedHoleTransport : IModbusTransport
|
||||
{
|
||||
/// <summary>Gets or sets the register address at which reads should fail with an exception.</summary>
|
||||
public ushort ProtectedAddress { get; set; } = ushort.MaxValue;
|
||||
/// <summary>Gets the list of all read requests made to the transport.</summary>
|
||||
public readonly List<(byte Fc, ushort Address, ushort Quantity)> Reads = new();
|
||||
/// <inheritdoc />
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
/// <inheritdoc />
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
@@ -45,9 +49,11 @@ public sealed class ModbusCoalescingAutoRecoveryTests
|
||||
default: return Task.FromResult(new byte[] { pdu[0], 0, 0 });
|
||||
}
|
||||
}
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the first failure falls back to per-tag reads in the same scan.</summary>
|
||||
[Fact]
|
||||
public async Task First_Failure_Falls_Back_To_PerTag_Same_Scan()
|
||||
{
|
||||
@@ -75,6 +81,7 @@ public sealed class ModbusCoalescingAutoRecoveryTests
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the second scan skips coalesced reads of prohibited ranges.</summary>
|
||||
[Fact]
|
||||
public async Task Second_Scan_Skips_Coalesced_Read_Of_Prohibited_Range()
|
||||
{
|
||||
@@ -104,6 +111,7 @@ public sealed class ModbusCoalescingAutoRecoveryTests
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that reprobe clears prohibition when the range becomes healthy.</summary>
|
||||
[Fact]
|
||||
public async Task Reprobe_Clears_Prohibition_When_Range_Becomes_Healthy()
|
||||
{
|
||||
@@ -133,6 +141,7 @@ public sealed class ModbusCoalescingAutoRecoveryTests
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that reprobe leaves prohibition in place when the range is still bad.</summary>
|
||||
[Fact]
|
||||
public async Task Reprobe_Leaves_Prohibition_When_Range_Is_Still_Bad()
|
||||
{
|
||||
@@ -156,6 +165,7 @@ public sealed class ModbusCoalescingAutoRecoveryTests
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that GetAutoProhibitedRanges surfaces an operator-visible snapshot.</summary>
|
||||
[Fact]
|
||||
public async Task GetAutoProhibitedRanges_Surfaces_Operator_Visible_Snapshot()
|
||||
{
|
||||
@@ -188,6 +198,7 @@ public sealed class ModbusCoalescingAutoRecoveryTests
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that tags outside prohibited ranges still coalesce.</summary>
|
||||
[Fact]
|
||||
public async Task Tags_Outside_Prohibited_Range_Still_Coalesce()
|
||||
{
|
||||
|
||||
@@ -20,8 +20,17 @@ public sealed class ModbusCoalescingBisectionTests
|
||||
/// </summary>
|
||||
private sealed class ProtectedHoleTransport : IModbusTransport
|
||||
{
|
||||
/// <summary>Gets or sets the protected address that will cause read failures.</summary>
|
||||
public ushort ProtectedAddress { get; set; } = ushort.MaxValue;
|
||||
/// <summary>Simulates connecting to the Modbus device.</summary>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A completed task.</returns>
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
/// <summary>Simulates sending a Modbus PDU and failing if the protected address is accessed.</summary>
|
||||
/// <param name="unitId">The Modbus unit ID.</param>
|
||||
/// <param name="pdu">The protocol data unit.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>The response PDU or an exception if the protected address is accessed.</returns>
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
@@ -39,9 +48,12 @@ public sealed class ModbusCoalescingBisectionTests
|
||||
default: return Task.FromResult(new byte[] { pdu[0], 0, 0 });
|
||||
}
|
||||
}
|
||||
/// <summary>Disposes the transport asynchronously.</summary>
|
||||
/// <returns>A completed value task.</returns>
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that bisection narrows a multi-register prohibition on each reprobe cycle.</summary>
|
||||
[Fact]
|
||||
public async Task Bisection_Narrows_Multi_Register_Prohibition_Per_Reprobe()
|
||||
{
|
||||
@@ -85,6 +97,7 @@ public sealed class ModbusCoalescingBisectionTests
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the prohibition is cleared when both bisected halves succeed in recovery.</summary>
|
||||
[Fact]
|
||||
public async Task Bisection_Clears_When_Both_Halves_Are_Healthy()
|
||||
{
|
||||
@@ -113,6 +126,7 @@ public sealed class ModbusCoalescingBisectionTests
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the prohibition splits into two entries when both bisected halves still fail.</summary>
|
||||
[Fact]
|
||||
public async Task Bisection_Splits_Into_Two_When_Both_Halves_Still_Fail()
|
||||
{
|
||||
@@ -146,7 +160,15 @@ public sealed class ModbusCoalescingBisectionTests
|
||||
private sealed class TwoHoleTransport : IModbusTransport
|
||||
{
|
||||
public readonly HashSet<ushort> ProtectedAddresses = new();
|
||||
/// <summary>Simulates connecting to the Modbus device.</summary>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A completed task.</returns>
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
/// <summary>Simulates sending a Modbus PDU and failing if any protected address is accessed.</summary>
|
||||
/// <param name="unitId">The Modbus unit ID.</param>
|
||||
/// <param name="pdu">The protocol data unit.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>The response PDU or an exception if a protected address is accessed.</returns>
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
@@ -166,6 +188,8 @@ public sealed class ModbusCoalescingBisectionTests
|
||||
default: return Task.FromResult(new byte[] { pdu[0], 0, 0 });
|
||||
}
|
||||
}
|
||||
/// <summary>Disposes the transport asynchronously.</summary>
|
||||
/// <returns>A completed value task.</returns>
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,13 @@ public sealed class ModbusCoalescingTests
|
||||
private sealed class CountingTransport : IModbusTransport
|
||||
{
|
||||
public readonly List<(byte Unit, byte Fc, ushort Address, ushort Quantity)> Reads = new();
|
||||
/// <summary>Establishes a connection asynchronously.</summary>
|
||||
/// <param name="ct">Cancellation token for the operation.</param>
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
/// <summary>Sends a Modbus PDU and receives the response.</summary>
|
||||
/// <param name="unitId">The Modbus unit identifier.</param>
|
||||
/// <param name="pdu">The Protocol Data Unit to send.</param>
|
||||
/// <param name="ct">Cancellation token for the operation.</param>
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
@@ -32,9 +38,11 @@ public sealed class ModbusCoalescingTests
|
||||
default: return Task.FromResult(new byte[] { pdu[0], 0, 0 });
|
||||
}
|
||||
}
|
||||
/// <summary>Disposes the transport asynchronously.</summary>
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that MaxReadGap=0 defaults to per-tag reads without coalescing.</summary>
|
||||
[Fact]
|
||||
public async Task MaxReadGap_Zero_Defaults_To_Per_Tag_Reads()
|
||||
{
|
||||
@@ -53,6 +61,7 @@ public sealed class ModbusCoalescingTests
|
||||
fc03Reads.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that MaxReadGap bridges adjacent tags into a single read.</summary>
|
||||
[Fact]
|
||||
public async Task MaxReadGap_Bridges_Two_Adjacent_Tags_Into_One_Read()
|
||||
{
|
||||
@@ -74,6 +83,7 @@ public sealed class ModbusCoalescingTests
|
||||
fc03Reads[0].Quantity.ShouldBe((ushort)5); // 100..104
|
||||
}
|
||||
|
||||
/// <summary>Verifies that MaxReadGap splits blocks when gaps exceed threshold.</summary>
|
||||
[Fact]
|
||||
public async Task MaxReadGap_Splits_When_Gap_Exceeds_Threshold()
|
||||
{
|
||||
@@ -93,6 +103,7 @@ public sealed class ModbusCoalescingTests
|
||||
fc03Reads.Count.ShouldBe(2); // T1+T2 coalesced; T3 alone
|
||||
}
|
||||
|
||||
/// <summary>Verifies that tags with CoalesceProhibited are read separately.</summary>
|
||||
[Fact]
|
||||
public async Task CoalesceProhibited_Tag_Reads_Alone()
|
||||
{
|
||||
@@ -113,6 +124,7 @@ public sealed class ModbusCoalescingTests
|
||||
fc03Reads.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that coalescing does not cross unit ID boundaries.</summary>
|
||||
[Fact]
|
||||
public async Task Coalescing_Does_Not_Cross_UnitId_Boundaries()
|
||||
{
|
||||
@@ -132,6 +144,7 @@ public sealed class ModbusCoalescingTests
|
||||
fc03Reads.Select(r => r.Unit).Distinct().Count().ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that coalescing splits blocks exceeding MaxRegistersPerRead.</summary>
|
||||
[Fact]
|
||||
public async Task Coalescing_Splits_Block_That_Exceeds_MaxRegistersPerRead()
|
||||
{
|
||||
@@ -152,6 +165,7 @@ public sealed class ModbusCoalescingTests
|
||||
fc03Reads.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that coalesced reads surface each tag value independently.</summary>
|
||||
[Fact]
|
||||
public async Task Coalesced_Read_Surfaces_Each_Tag_Value_Independently()
|
||||
{
|
||||
|
||||
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusConnectionOptionsTests
|
||||
{
|
||||
/// <summary>Verifies that defaults match historical behaviour.</summary>
|
||||
[Fact]
|
||||
public void Defaults_Match_Historical_Behaviour()
|
||||
{
|
||||
@@ -28,6 +29,7 @@ public sealed class ModbusConnectionOptionsTests
|
||||
opts.Reconnect.BackoffMultiplier.ShouldBe(2.0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that factory reads KeepAlive knobs from JSON.</summary>
|
||||
[Fact]
|
||||
public void Factory_Reads_KeepAlive_Knobs_From_Json()
|
||||
{
|
||||
@@ -50,6 +52,7 @@ public sealed class ModbusConnectionOptionsTests
|
||||
opts.KeepAlive.RetryCount.ShouldBe(5);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that factory reads IdleDisconnect from JSON.</summary>
|
||||
[Fact]
|
||||
public void Factory_Reads_IdleDisconnect_From_Json()
|
||||
{
|
||||
@@ -62,6 +65,7 @@ public sealed class ModbusConnectionOptionsTests
|
||||
opts.IdleDisconnectTimeout.ShouldBe(TimeSpan.FromMinutes(2));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that factory reads Reconnect backoff from JSON.</summary>
|
||||
[Fact]
|
||||
public void Factory_Reads_Reconnect_Backoff_From_Json()
|
||||
{
|
||||
@@ -82,6 +86,7 @@ public sealed class ModbusConnectionOptionsTests
|
||||
opts.Reconnect.BackoffMultiplier.ShouldBe(1.5);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that factory with empty JSON uses all defaults.</summary>
|
||||
[Fact]
|
||||
public void Factory_With_Empty_Json_Uses_All_Defaults()
|
||||
{
|
||||
|
||||
@@ -11,6 +11,8 @@ public sealed class ModbusDataTypeTests
|
||||
/// <summary>
|
||||
/// Register-count lookup is per-tag now (strings need StringLength; Int64/Float64 need 4).
|
||||
/// </summary>
|
||||
/// <param name="t">The Modbus data type to test.</param>
|
||||
/// <param name="expected">The expected register count for the data type.</param>
|
||||
[Theory]
|
||||
[InlineData(ModbusDataType.BitInRegister, 1)]
|
||||
[InlineData(ModbusDataType.Int16, 1)]
|
||||
@@ -27,6 +29,11 @@ public sealed class ModbusDataTypeTests
|
||||
ModbusDriver.RegisterCount(tag).ShouldBe((ushort)expected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that string register count calculation rounds up character count to register pairs.
|
||||
/// </summary>
|
||||
/// <param name="chars">The number of characters in the string.</param>
|
||||
/// <param name="expectedRegs">The expected number of registers required.</param>
|
||||
[Theory]
|
||||
[InlineData(0, 1)] // 0 chars → still 1 byte / 1 register (pathological but well-defined: length 0 is 0 bytes)
|
||||
[InlineData(1, 1)]
|
||||
@@ -43,6 +50,9 @@ public sealed class ModbusDataTypeTests
|
||||
|
||||
// --- Int32 / UInt32 / Float32 with byte-order variants ---
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Int32 with big-endian byte order decodes ABCD register layout correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Int32_BigEndian_decodes_ABCD_layout()
|
||||
{
|
||||
@@ -53,6 +63,9 @@ public sealed class ModbusDataTypeTests
|
||||
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe(0x12345678);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Int32 with word-swap byte order decodes CDAB register layout correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Int32_WordSwap_decodes_CDAB_layout()
|
||||
{
|
||||
@@ -64,6 +77,9 @@ public sealed class ModbusDataTypeTests
|
||||
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe(0x12345678);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Float32 with word-swap byte order encodes and decodes consistently.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Float32_WordSwap_encode_decode_roundtrips()
|
||||
{
|
||||
@@ -76,6 +92,9 @@ public sealed class ModbusDataTypeTests
|
||||
|
||||
// --- Int64 / UInt64 / Float64 ---
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Int64 with big-endian byte order encodes and decodes consistently.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Int64_BigEndian_roundtrips()
|
||||
{
|
||||
@@ -86,6 +105,9 @@ public sealed class ModbusDataTypeTests
|
||||
ModbusDriver.DecodeRegister(wire, tag).ShouldBe(0x0123456789ABCDEFL);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that UInt64 with word-swap byte order reverses four words correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UInt64_WordSwap_reverses_four_words()
|
||||
{
|
||||
@@ -104,6 +126,9 @@ public sealed class ModbusDataTypeTests
|
||||
roundtrip.ShouldBe(wireWS);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Float64 with word-swap byte order encodes and decodes consistently.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Float64_roundtrips_under_word_swap()
|
||||
{
|
||||
@@ -116,6 +141,12 @@ public sealed class ModbusDataTypeTests
|
||||
|
||||
// --- BitInRegister ---
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that BitInRegister correctly extracts a bit at the specified index.
|
||||
/// </summary>
|
||||
/// <param name="raw">The raw register value containing the bit.</param>
|
||||
/// <param name="bitIndex">The index of the bit to extract.</param>
|
||||
/// <param name="expected">The expected bit value.</param>
|
||||
[Theory]
|
||||
[InlineData(0b0000_0000_0000_0001, 0, true)]
|
||||
[InlineData(0b0000_0000_0000_0001, 1, false)]
|
||||
@@ -131,6 +162,9 @@ public sealed class ModbusDataTypeTests
|
||||
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that BitInRegister correctly rejects direct EncodeRegister calls.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BitInRegister_EncodeRegister_still_rejects_direct_calls()
|
||||
{
|
||||
@@ -145,6 +179,9 @@ public sealed class ModbusDataTypeTests
|
||||
|
||||
// --- String ---
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that String decodes ASCII characters packed two per register.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void String_decodes_ASCII_packed_two_chars_per_register()
|
||||
{
|
||||
@@ -155,6 +192,9 @@ public sealed class ModbusDataTypeTests
|
||||
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe("HELLO!");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that String decode truncates at the first null terminator.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void String_decode_truncates_at_first_nul()
|
||||
{
|
||||
@@ -164,6 +204,9 @@ public sealed class ModbusDataTypeTests
|
||||
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe("Hi");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that String encode pads remaining bytes with null terminators.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void String_encode_nul_pads_remaining_bytes()
|
||||
{
|
||||
@@ -178,6 +221,9 @@ public sealed class ModbusDataTypeTests
|
||||
|
||||
// --- DL205 low-byte-first strings (AutomationDirect DirectLOGIC quirk) ---
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that String with low-byte-first byte order decodes DL205-packed strings correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void String_LowByteFirst_decodes_DL205_packed_Hello()
|
||||
{
|
||||
@@ -189,6 +235,9 @@ public sealed class ModbusDataTypeTests
|
||||
ModbusDriver.DecodeRegister(wire, tag).ShouldBe("Hello");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that String with low-byte-first byte order truncates at the first null terminator.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void String_LowByteFirst_decode_truncates_at_first_nul()
|
||||
{
|
||||
@@ -199,6 +248,9 @@ public sealed class ModbusDataTypeTests
|
||||
ModbusDriver.DecodeRegister(wire, tag).ShouldBe("Hi");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that String with low-byte-first byte order encodes and decodes consistently.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void String_LowByteFirst_encode_round_trips_with_decode()
|
||||
{
|
||||
@@ -210,6 +262,9 @@ public sealed class ModbusDataTypeTests
|
||||
ModbusDriver.DecodeRegister(wire, tag).ShouldBe("Hello");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that String with high-byte-first and low-byte-first produce different results for the same wire data.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void String_HighByteFirst_and_LowByteFirst_differ_on_same_wire()
|
||||
{
|
||||
@@ -225,6 +280,11 @@ public sealed class ModbusDataTypeTests
|
||||
|
||||
// --- BCD (binary-coded decimal, DL205/DL260 default numeric encoding) ---
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that 16-bit BCD decoding produces the expected decimal value.
|
||||
/// </summary>
|
||||
/// <param name="raw">The raw 16-bit BCD value.</param>
|
||||
/// <param name="expected">The expected decoded decimal value.</param>
|
||||
[Theory]
|
||||
[InlineData(0x0000u, 0u)]
|
||||
[InlineData(0x0001u, 1u)]
|
||||
@@ -235,6 +295,9 @@ public sealed class ModbusDataTypeTests
|
||||
public void DecodeBcd_16_bit_decodes_expected_decimal(uint raw, uint expected)
|
||||
=> ModbusDriver.DecodeBcd(raw, nibbles: 4).ShouldBe(expected);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that BCD decoding rejects nibble values above nine.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DecodeBcd_rejects_nibbles_above_nine()
|
||||
{
|
||||
@@ -242,6 +305,11 @@ public sealed class ModbusDataTypeTests
|
||||
.Message.ShouldContain("Non-BCD nibble");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that 16-bit BCD encoding produces the expected nibble pattern.
|
||||
/// </summary>
|
||||
/// <param name="value">The decimal value to encode.</param>
|
||||
/// <param name="expected">The expected 16-bit BCD encoding.</param>
|
||||
[Theory]
|
||||
[InlineData(0u, 0x0000u)]
|
||||
[InlineData(5u, 0x0005u)]
|
||||
@@ -251,6 +319,9 @@ public sealed class ModbusDataTypeTests
|
||||
public void EncodeBcd_16_bit_encodes_expected_nibbles(uint value, uint expected)
|
||||
=> ModbusDriver.EncodeBcd(value, nibbles: 4).ShouldBe(expected);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Bcd16 decodes DL205 register value 0x1234 as decimal 1234.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Bcd16_decodes_DL205_register_1234_as_decimal_1234()
|
||||
{
|
||||
@@ -263,6 +334,9 @@ public sealed class ModbusDataTypeTests
|
||||
ModbusDriver.DecodeRegister(new byte[] { 0x12, 0x34 }, int16Tag).ShouldBe((short)0x1234);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Bcd16 encodes and decodes consistently.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Bcd16_encode_round_trips_with_decode()
|
||||
{
|
||||
@@ -272,6 +346,9 @@ public sealed class ModbusDataTypeTests
|
||||
ModbusDriver.DecodeRegister(wire, tag).ShouldBe(4321);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Bcd16 encode rejects values that exceed four decimal digits.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Bcd16_encode_rejects_out_of_range_values()
|
||||
{
|
||||
@@ -280,6 +357,9 @@ public sealed class ModbusDataTypeTests
|
||||
.Message.ShouldContain("4 decimal digits");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Bcd32 decodes eight decimal digits in big-endian byte order.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Bcd32_decodes_8_digits_big_endian()
|
||||
{
|
||||
@@ -288,6 +368,9 @@ public sealed class ModbusDataTypeTests
|
||||
ModbusDriver.DecodeRegister(new byte[] { 0x12, 0x34, 0x56, 0x78 }, tag).ShouldBe(12_345_678);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Bcd32 with word-swap byte order handles CDAB register layout correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Bcd32_word_swap_handles_CDAB_layout()
|
||||
{
|
||||
@@ -298,6 +381,9 @@ public sealed class ModbusDataTypeTests
|
||||
ModbusDriver.DecodeRegister(new byte[] { 0x56, 0x78, 0x12, 0x34 }, tag).ShouldBe(12_345_678);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Bcd32 encodes and decodes consistently.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Bcd32_encode_round_trips_with_decode()
|
||||
{
|
||||
@@ -307,6 +393,9 @@ public sealed class ModbusDataTypeTests
|
||||
ModbusDriver.DecodeRegister(wire, tag).ShouldBe(87_654_321);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that BCD register count matches the underlying data width.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Bcd_RegisterCount_matches_underlying_width()
|
||||
{
|
||||
|
||||
@@ -21,11 +21,18 @@ public sealed class ModbusDriverTests
|
||||
public readonly ushort[] InputRegisters = new ushort[256];
|
||||
public readonly bool[] Coils = new bool[256];
|
||||
public readonly bool[] DiscreteInputs = new bool[256];
|
||||
/// <summary>Gets or sets a value indicating whether connect operations should fail.</summary>
|
||||
public bool ForceConnectFail { get; set; }
|
||||
|
||||
/// <summary>Initiates a connection to the Modbus server.</summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
public Task ConnectAsync(CancellationToken ct)
|
||||
=> ForceConnectFail ? Task.FromException(new InvalidOperationException("connect refused")) : Task.CompletedTask;
|
||||
|
||||
/// <summary>Sends a Modbus PDU and receives the response.</summary>
|
||||
/// <param name="unitId">Modbus unit ID.</param>
|
||||
/// <param name="pdu">Protocol data unit bytes to send.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
var fc = pdu[0];
|
||||
@@ -104,6 +111,7 @@ public sealed class ModbusDriverTests
|
||||
return new byte[] { 0x0F, pdu[1], pdu[2], pdu[3], pdu[4] };
|
||||
}
|
||||
|
||||
/// <summary>Disposes the transport asynchronously.</summary>
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -115,6 +123,7 @@ public sealed class ModbusDriverTests
|
||||
return (drv, fake);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Initialize connects and populates the tag map.</summary>
|
||||
[Fact]
|
||||
public async Task Initialize_connects_and_populates_tag_map()
|
||||
{
|
||||
@@ -125,6 +134,7 @@ public sealed class ModbusDriverTests
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that reading Int16 holding registers returns big-endian values correctly.</summary>
|
||||
[Fact]
|
||||
public async Task Read_Int16_holding_register_returns_BigEndian_value()
|
||||
{
|
||||
@@ -137,6 +147,7 @@ public sealed class ModbusDriverTests
|
||||
r[0].StatusCode.ShouldBe(0u);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that reading Float32 values spans two registers in big-endian format.</summary>
|
||||
[Fact]
|
||||
public async Task Read_Float32_spans_two_registers_BigEndian()
|
||||
{
|
||||
@@ -153,6 +164,7 @@ public sealed class ModbusDriverTests
|
||||
r[0].Value.ShouldBe(25.5f);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that reading coils returns boolean values.</summary>
|
||||
[Fact]
|
||||
public async Task Read_Coil_returns_boolean()
|
||||
{
|
||||
@@ -164,6 +176,7 @@ public sealed class ModbusDriverTests
|
||||
r[0].Value.ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that reading unknown tags returns BadNodeIdUnknown status instead of throwing.</summary>
|
||||
[Fact]
|
||||
public async Task Unknown_tag_returns_BadNodeIdUnknown_not_an_exception()
|
||||
{
|
||||
@@ -174,6 +187,7 @@ public sealed class ModbusDriverTests
|
||||
r[0].StatusCode.ShouldBe(0x80340000u);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that writing UInt16 holding registers round-trips correctly.</summary>
|
||||
[Fact]
|
||||
public async Task Write_UInt16_holding_register_roundtrips()
|
||||
{
|
||||
@@ -185,6 +199,7 @@ public sealed class ModbusDriverTests
|
||||
fake.HoldingRegisters[20].ShouldBe((ushort)42000);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that writing Float32 values uses function code 16 (WriteMultipleRegisters).</summary>
|
||||
[Fact]
|
||||
public async Task Write_Float32_uses_FC16_WriteMultipleRegisters()
|
||||
{
|
||||
@@ -202,6 +217,7 @@ public sealed class ModbusDriverTests
|
||||
BinaryPrimitives.ReadSingleBigEndian(raw).ShouldBe(25.5f);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that writing to input registers returns BadNotWritable status.</summary>
|
||||
[Fact]
|
||||
public async Task Write_to_InputRegister_returns_BadNotWritable()
|
||||
{
|
||||
@@ -212,6 +228,7 @@ public sealed class ModbusDriverTests
|
||||
r[0].StatusCode.ShouldBe(0x803B0000u);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Discover streams one folder per driver with a variable per tag.</summary>
|
||||
[Fact]
|
||||
public async Task Discover_streams_one_folder_per_driver_with_a_variable_per_tag()
|
||||
{
|
||||
@@ -232,6 +249,7 @@ public sealed class ModbusDriverTests
|
||||
builder.Variables.ShouldContain(v => v.BrowseName == "Run" && v.Info.DriverDataType == DriverDataType.Boolean);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Discover propagates WriteIdempotent from tag to attribute info.</summary>
|
||||
[Fact]
|
||||
public async Task Discover_propagates_WriteIdempotent_from_tag_to_attribute_info()
|
||||
{
|
||||
@@ -251,22 +269,50 @@ public sealed class ModbusDriverTests
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
/// <summary>Records discovered address space structure for testing.</summary>
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
/// <summary>Gets the list of discovered folders.</summary>
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||
|
||||
/// <summary>Gets the list of discovered variables.</summary>
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||
|
||||
/// <summary>Records a folder in the address space.</summary>
|
||||
/// <param name="browseName">Folder browse name.</param>
|
||||
/// <param name="displayName">Folder display name.</param>
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{ Folders.Add((browseName, displayName)); return this; }
|
||||
|
||||
/// <summary>Records a variable in the address space.</summary>
|
||||
/// <param name="browseName">Variable browse name.</param>
|
||||
/// <param name="displayName">Variable display name.</param>
|
||||
/// <param name="info">Driver attribute information.</param>
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||
|
||||
/// <summary>Adds a property (no-op for recording).</summary>
|
||||
/// <param name="_">Property name (unused).</param>
|
||||
/// <param name="__">Property data type (unused).</param>
|
||||
/// <param name="___">Property value (unused).</param>
|
||||
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||
|
||||
/// <summary>Handle to a discovered variable.</summary>
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
/// <summary>Gets the full reference name.</summary>
|
||||
public string FullReference => fullRef;
|
||||
|
||||
/// <summary>Marks this variable as an alarm condition.</summary>
|
||||
/// <param name="info">Alarm condition information.</param>
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
|
||||
/// <summary>No-op alarm condition sink for testing.</summary>
|
||||
private sealed class NullSink : IAlarmConditionSink
|
||||
{
|
||||
/// <summary>Handles alarm transitions (no-op).</summary>
|
||||
/// <param name="args">Alarm event arguments.</param>
|
||||
public void OnTransition(AlarmEventArgs args) { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusEdgeCaseValidationTests
|
||||
{
|
||||
/// <summary>Verifies that string tags with zero length are rejected during factory creation.</summary>
|
||||
[Fact]
|
||||
public void Factory_rejects_String_tag_with_StringLength_zero_via_structured_form()
|
||||
{
|
||||
@@ -36,6 +37,7 @@ public sealed class ModbusEdgeCaseValidationTests
|
||||
ex.Message.ShouldContain("Greeting");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that omitted string length defaults to zero and is rejected.</summary>
|
||||
[Fact]
|
||||
public void Factory_rejects_String_tag_with_StringLength_zero_via_missing_field()
|
||||
{
|
||||
@@ -53,6 +55,7 @@ public sealed class ModbusEdgeCaseValidationTests
|
||||
ex.Message.ShouldContain("StringLength");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that string tags with length one are accepted.</summary>
|
||||
[Fact]
|
||||
public void Factory_accepts_String_tag_with_StringLength_one()
|
||||
{
|
||||
@@ -67,6 +70,7 @@ public sealed class ModbusEdgeCaseValidationTests
|
||||
Should.NotThrow(() => ModbusDriverFactoryExtensions.CreateInstance("modbus-1", json));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that non-string tags are unaffected by string length zero.</summary>
|
||||
[Fact]
|
||||
public void Factory_accepts_non_String_tag_with_StringLength_zero()
|
||||
{
|
||||
@@ -82,6 +86,9 @@ public sealed class ModbusEdgeCaseValidationTests
|
||||
Should.NotThrow(() => ModbusDriverFactoryExtensions.CreateInstance("modbus-1", json));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that sub-second time spans are rounded up to at least one second.</summary>
|
||||
/// <param name="ms">The input duration in milliseconds.</param>
|
||||
/// <param name="expected">The expected clamped value in whole seconds.</param>
|
||||
[Theory]
|
||||
[InlineData(0, 1)] // zero clamps up to 1
|
||||
[InlineData(500, 1)] // 500 ms rounds up to 1
|
||||
@@ -95,6 +102,7 @@ public sealed class ModbusEdgeCaseValidationTests
|
||||
ModbusTcpTransport.ClampToWholeSeconds(TimeSpan.FromMilliseconds(ms)).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that negative time spans are treated as one second.</summary>
|
||||
[Fact]
|
||||
public void ClampToWholeSeconds_treats_negative_TimeSpan_as_one_second()
|
||||
{
|
||||
|
||||
@@ -14,6 +14,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusExceptionMapperTests
|
||||
{
|
||||
/// <summary>Verifies that Modbus exception codes map to informative OPC UA status codes.</summary>
|
||||
/// <param name="code">Modbus exception code.</param>
|
||||
/// <param name="expected">Expected OPC UA status code.</param>
|
||||
[Theory]
|
||||
[InlineData((byte)0x01, 0x803D0000u)] // Illegal Function → BadNotSupported
|
||||
[InlineData((byte)0x02, 0x803C0000u)] // Illegal Data Address → BadOutOfRange
|
||||
@@ -27,14 +30,25 @@ public sealed class ModbusExceptionMapperTests
|
||||
public void MapModbusExceptionToStatus_returns_informative_status(byte code, uint expected)
|
||||
=> ModbusDriver.MapModbusExceptionToStatus(code).ShouldBe(expected);
|
||||
|
||||
/// <summary>Test transport that raises Modbus exceptions on send.</summary>
|
||||
private sealed class ExceptionRaisingTransport(byte exceptionCode) : IModbusTransport
|
||||
{
|
||||
/// <summary>Completes immediately.</summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
/// <summary>Returns a failed task with a Modbus exception.</summary>
|
||||
/// <param name="unitId">Modbus unit identifier.</param>
|
||||
/// <param name="pdu">Protocol data unit to send.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
=> Task.FromException<byte[]>(new ModbusException(pdu[0], exceptionCode, $"fc={pdu[0]} code={exceptionCode}"));
|
||||
|
||||
/// <summary>Completes immediately.</summary>
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that exception 0x02 surfaces as BadOutOfRange, not BadInternalError.</summary>
|
||||
[Fact]
|
||||
public async Task Read_surface_exception_02_as_BadOutOfRange_not_BadInternalError()
|
||||
{
|
||||
@@ -48,6 +62,7 @@ public sealed class ModbusExceptionMapperTests
|
||||
results[0].StatusCode.ShouldBe(0x803C0000u, "FC03 at an unmapped register must bubble out as BadOutOfRange so operators can spot a bad tag config");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that exception 0x04 surfaces as BadDeviceFailure.</summary>
|
||||
[Fact]
|
||||
public async Task Write_surface_exception_04_as_BadDeviceFailure()
|
||||
{
|
||||
@@ -64,14 +79,25 @@ public sealed class ModbusExceptionMapperTests
|
||||
writes[0].StatusCode.ShouldBe(0x808B0000u, "FC06 returning exception 04 (CPU in PROGRAM mode) maps to BadDeviceFailure");
|
||||
}
|
||||
|
||||
/// <summary>Test transport that raises non-Modbus exceptions on send.</summary>
|
||||
private sealed class NonModbusFailureTransport : IModbusTransport
|
||||
{
|
||||
/// <summary>Completes immediately.</summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
/// <summary>Returns a failed task with a non-Modbus exception.</summary>
|
||||
/// <param name="unitId">Modbus unit identifier.</param>
|
||||
/// <param name="pdu">Protocol data unit to send.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
=> Task.FromException<byte[]>(new EndOfStreamException("socket closed mid-response"));
|
||||
|
||||
/// <summary>Completes immediately.</summary>
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that non-Modbus transport failures surface as BadCommunicationError.</summary>
|
||||
[Fact]
|
||||
public async Task Read_non_modbus_failure_maps_to_BadCommunicationError_not_BadInternalError()
|
||||
{
|
||||
|
||||
@@ -23,8 +23,14 @@ public sealed class ModbusLifecycleHygieneTests
|
||||
public int DisposeCount;
|
||||
public int SendCount;
|
||||
|
||||
/// <summary>Establishes a connection asynchronously.</summary>
|
||||
/// <param name="ct">Cancellation token for the operation.</param>
|
||||
public Task ConnectAsync(CancellationToken ct) { Interlocked.Increment(ref ConnectCount); return Task.CompletedTask; }
|
||||
|
||||
/// <summary>Sends a Modbus PDU and receives the response.</summary>
|
||||
/// <param name="unitId">The Modbus unit identifier.</param>
|
||||
/// <param name="pdu">The Protocol Data Unit to send.</param>
|
||||
/// <param name="ct">Cancellation token for the operation.</param>
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref SendCount);
|
||||
@@ -65,6 +71,7 @@ public sealed class ModbusLifecycleHygieneTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Disposes the transport asynchronously.</summary>
|
||||
public ValueTask DisposeAsync() { Interlocked.Increment(ref DisposeCount); return ValueTask.CompletedTask; }
|
||||
}
|
||||
|
||||
@@ -79,6 +86,7 @@ public sealed class ModbusLifecycleHygieneTests
|
||||
|
||||
// -------------------- Finding -002 / -012 (2) --------------------
|
||||
|
||||
/// <summary>Verifies that reinitialization clears stale tag cache entries.</summary>
|
||||
[Fact]
|
||||
public async Task Reinitialize_clears_stale_tagsByName_entries()
|
||||
{
|
||||
@@ -99,6 +107,7 @@ public sealed class ModbusLifecycleHygieneTests
|
||||
GetTagsByName(drv).Count.ShouldBe(0, "Shutdown must clear the tag cache so the next Initialize starts clean");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that reinitialization clears the deadband and write-suppression caches.</summary>
|
||||
[Fact]
|
||||
public async Task Reinitialize_clears_lastPublished_and_lastWritten_caches()
|
||||
{
|
||||
@@ -139,6 +148,7 @@ public sealed class ModbusLifecycleHygieneTests
|
||||
|
||||
// -------------------- Finding -004 / -012 (4) --------------------
|
||||
|
||||
/// <summary>Verifies that DisposeAsync without Shutdown stops probe loops and tears down the transport.</summary>
|
||||
[Fact]
|
||||
public async Task DisposeAsync_without_explicit_Shutdown_tears_down_probe_loop_and_transport()
|
||||
{
|
||||
@@ -176,6 +186,7 @@ public sealed class ModbusLifecycleHygieneTests
|
||||
(sendsAtRest - sendsAfterDispose).ShouldBeLessThanOrEqualTo(1, "background loops must stop after DisposeAsync");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that DisposeAsync disposes the poll engine so subscriptions stop.</summary>
|
||||
[Fact]
|
||||
public async Task DisposeAsync_disposes_the_pollEngine_so_subscriptions_stop()
|
||||
{
|
||||
@@ -221,7 +232,13 @@ public sealed class ModbusLifecycleHygieneTests
|
||||
/// <summary>How many bytes to return — anything < 2 + bytecount is malformed.</summary>
|
||||
public int ResponseBytes { get; set; } = 1; // just the fc byte, no bytecount
|
||||
|
||||
/// <summary>Establishes a connection asynchronously.</summary>
|
||||
/// <param name="ct">Cancellation token for the operation.</param>
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
/// <summary>Sends a Modbus PDU and receives the response.</summary>
|
||||
/// <param name="unitId">The Modbus unit identifier.</param>
|
||||
/// <param name="pdu">The Protocol Data Unit to send.</param>
|
||||
/// <param name="ct">Cancellation token for the operation.</param>
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
var resp = new byte[ResponseBytes];
|
||||
@@ -229,9 +246,11 @@ public sealed class ModbusLifecycleHygieneTests
|
||||
if (ResponseBytes >= 2) resp[1] = 4; // claim 4 bytes of payload but provide none
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
/// <summary>Disposes the transport asynchronously.</summary>
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that short response PDUs surface as BadCommunicationError, not IndexOutOfRangeException.</summary>
|
||||
[Fact]
|
||||
public async Task Short_response_PDU_surfaces_as_BadCommunicationError_not_an_IndexOutOfRangeException()
|
||||
{
|
||||
@@ -249,6 +268,7 @@ public sealed class ModbusLifecycleHygieneTests
|
||||
r[0].Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that response payloads truncated below declared byte count surface as BadCommunicationError.</summary>
|
||||
[Fact]
|
||||
public async Task Response_payload_truncated_below_declared_byteCount_surfaces_as_BadCommunicationError()
|
||||
{
|
||||
@@ -267,6 +287,7 @@ public sealed class ModbusLifecycleHygieneTests
|
||||
r[0].StatusCode.ShouldBe(0x80050000u);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that DecodeBitArray rejects an empty bitmap with InvalidDataException.</summary>
|
||||
[Fact]
|
||||
public void DecodeBitArray_rejects_an_empty_bitmap_with_InvalidDataException()
|
||||
{
|
||||
@@ -296,9 +317,16 @@ public sealed class ModbusLifecycleHygieneTests
|
||||
/// </summary>
|
||||
private sealed class EmptyBitTransport : IModbusTransport
|
||||
{
|
||||
/// <summary>Returns a completed task without performing any connection.</summary>
|
||||
/// <param name="ct">The cancellation token for the operation.</param>
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
/// <summary>Returns a response with zero-byte payload to simulate empty bitmap.</summary>
|
||||
/// <param name="unitId">The Modbus unit ID.</param>
|
||||
/// <param name="pdu">The protocol data unit being sent.</param>
|
||||
/// <param name="ct">The cancellation token for the operation.</param>
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
=> Task.FromResult(new byte[] { pdu[0], 0 });
|
||||
/// <summary>Completes the disposal without doing any work.</summary>
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,20 +15,52 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusLoggerInjectionTests
|
||||
{
|
||||
/// <summary>Test logger that captures log entries.</summary>
|
||||
private sealed class CapturingLogger : ILogger<ModbusDriver>
|
||||
{
|
||||
public readonly List<(LogLevel Level, string Message)> Entries = new();
|
||||
/// <summary>Begins a scope for logging.</summary>
|
||||
/// <typeparam name="TState">The type of the state.</typeparam>
|
||||
/// <param name="state">The state object.</param>
|
||||
/// <returns>A disposable scope instance.</returns>
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||
/// <summary>Determines if logging is enabled for the specified level.</summary>
|
||||
/// <param name="logLevel">The log level to check.</param>
|
||||
/// <returns>True if logging is enabled for the specified level.</returns>
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
/// <summary>Logs a message with the specified level, event ID, state, exception, and formatter.</summary>
|
||||
/// <typeparam name="TState">The type of the state.</typeparam>
|
||||
/// <param name="logLevel">The log level.</param>
|
||||
/// <param name="eventId">The event ID.</param>
|
||||
/// <param name="state">The state object.</param>
|
||||
/// <param name="exception">The exception, if any.</param>
|
||||
/// <param name="formatter">The function to format the log message.</param>
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
=> Entries.Add((logLevel, formatter(state, exception)));
|
||||
private sealed class NullScope : IDisposable { public static readonly NullScope Instance = new(); public void Dispose() { } }
|
||||
/// <summary>Disposes the logger.</summary>
|
||||
public void Dispose() { }
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static readonly NullScope Instance = new();
|
||||
/// <inheritdoc />
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Test transport with a protected address hole.</summary>
|
||||
private sealed class ProtectedHoleTransport : IModbusTransport
|
||||
{
|
||||
/// <summary>Gets or sets the protected address.</summary>
|
||||
public ushort ProtectedAddress { get; set; } = 102;
|
||||
/// <summary>Simulates connecting to the Modbus device.</summary>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A completed task.</returns>
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
/// <summary>Simulates sending a Modbus PDU.</summary>
|
||||
/// <param name="unitId">The Modbus unit ID.</param>
|
||||
/// <param name="pdu">The protocol data unit.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>The response PDU.</returns>
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
@@ -39,9 +71,12 @@ public sealed class ModbusLoggerInjectionTests
|
||||
resp[0] = pdu[0]; resp[1] = (byte)(qty * 2);
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
/// <summary>Disposes the transport asynchronously.</summary>
|
||||
/// <returns>A completed value task.</returns>
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Verifies first failure emits single warning and subsequent refires stay quiet.</summary>
|
||||
[Fact]
|
||||
public async Task First_Failure_Emits_Single_Warning_Subsequent_Refire_Stays_Quiet()
|
||||
{
|
||||
@@ -72,6 +107,7 @@ public sealed class ModbusLoggerInjectionTests
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Verifies reprobe clearing prohibition emits information log.</summary>
|
||||
[Fact]
|
||||
public async Task Reprobe_Clearing_Prohibition_Emits_Information_Log()
|
||||
{
|
||||
|
||||
@@ -14,7 +14,13 @@ public sealed class ModbusMultiUnitTests
|
||||
private sealed class UnitCapturingTransport : IModbusTransport
|
||||
{
|
||||
public readonly List<byte> SeenUnitIds = new();
|
||||
/// <summary>Connects to the transport.</summary>
|
||||
/// <param name="ct">Token to cancel the connection.</param>
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
/// <summary>Sends a Modbus PDU and returns a response.</summary>
|
||||
/// <param name="unitId">The Modbus unit ID for the request.</param>
|
||||
/// <param name="pdu">The protocol data unit to send.</param>
|
||||
/// <param name="ct">Token to cancel the operation.</param>
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
SeenUnitIds.Add(unitId);
|
||||
@@ -30,9 +36,11 @@ public sealed class ModbusMultiUnitTests
|
||||
default: return Task.FromResult(new byte[] { pdu[0], 0, 0 });
|
||||
}
|
||||
}
|
||||
/// <summary>Disposes the transport resources.</summary>
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that per-tag UnitId routes reads to the correct slave.</summary>
|
||||
[Fact]
|
||||
public async Task PerTag_UnitId_Routes_To_Correct_Slave_In_MBAP()
|
||||
{
|
||||
@@ -52,6 +60,7 @@ public sealed class ModbusMultiUnitTests
|
||||
fake.SeenUnitIds.ShouldNotContain((byte)99);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that tags without UnitId override use the driver-level UnitId.</summary>
|
||||
[Fact]
|
||||
public async Task Tag_Without_UnitId_Falls_Back_To_DriverLevel()
|
||||
{
|
||||
@@ -67,6 +76,7 @@ public sealed class ModbusMultiUnitTests
|
||||
fake.SeenUnitIds.ShouldContain((byte)7);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that IPerCallHostResolver returns per-slave host strings.</summary>
|
||||
[Fact]
|
||||
public async Task IPerCallHostResolver_Returns_Per_Slave_Host_String()
|
||||
{
|
||||
@@ -86,6 +96,7 @@ public sealed class ModbusMultiUnitTests
|
||||
resolver.ResolveHost("S1Temp").ShouldNotBe(resolver.ResolveHost("S5Temp"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that IPerCallHostResolver falls back to hostname for unknown tags.</summary>
|
||||
[Fact]
|
||||
public async Task IPerCallHostResolver_Unknown_Tag_Falls_Back_To_HostName()
|
||||
{
|
||||
|
||||
@@ -19,8 +19,16 @@ public sealed class ModbusProbeTests
|
||||
public volatile bool Reachable = true;
|
||||
public int ProbeCount;
|
||||
|
||||
/// <summary>Asynchronously connects the transport.</summary>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A completed task.</returns>
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
/// <summary>Asynchronously sends a Modbus PDU and returns a response.</summary>
|
||||
/// <param name="unitId">The Modbus unit ID.</param>
|
||||
/// <param name="pdu">The protocol data unit to send.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task that returns the response bytes.</returns>
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
if (pdu[0] == 0x03) Interlocked.Increment(ref ProbeCount);
|
||||
@@ -39,6 +47,8 @@ public sealed class ModbusProbeTests
|
||||
return Task.FromException<byte[]>(new NotSupportedException());
|
||||
}
|
||||
|
||||
/// <summary>Asynchronously disposes the transport.</summary>
|
||||
/// <returns>A completed task.</returns>
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -49,6 +59,7 @@ public sealed class ModbusProbeTests
|
||||
return (new ModbusDriver(opts, "modbus-1", _ => fake), fake);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the initial state is Unknown before the first probe tick.</summary>
|
||||
[Fact]
|
||||
public async Task Initial_state_is_Unknown_before_first_probe_tick()
|
||||
{
|
||||
@@ -61,6 +72,7 @@ public sealed class ModbusProbeTests
|
||||
statuses[0].HostName.ShouldBe("fake:502");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the first successful probe transitions the state to Running.</summary>
|
||||
[Fact]
|
||||
public async Task First_successful_probe_transitions_to_Running()
|
||||
{
|
||||
@@ -92,6 +104,7 @@ public sealed class ModbusProbeTests
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a transport failure transitions the state to Stopped.</summary>
|
||||
[Fact]
|
||||
public async Task Transport_failure_transitions_to_Stopped()
|
||||
{
|
||||
@@ -114,6 +127,7 @@ public sealed class ModbusProbeTests
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that recovery transitions the state from Stopped back to Running.</summary>
|
||||
[Fact]
|
||||
public async Task Recovery_transitions_Stopped_back_to_Running()
|
||||
{
|
||||
@@ -140,6 +154,7 @@ public sealed class ModbusProbeTests
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that repeated successful probes do not generate duplicate Running events.</summary>
|
||||
[Fact]
|
||||
public async Task Repeated_successful_probes_do_not_generate_duplicate_Running_events()
|
||||
{
|
||||
@@ -160,6 +175,7 @@ public sealed class ModbusProbeTests
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a disabled probe stays Unknown and fires no events.</summary>
|
||||
[Fact]
|
||||
public async Task Disabled_probe_stays_Unknown_and_fires_no_events()
|
||||
{
|
||||
@@ -175,6 +191,7 @@ public sealed class ModbusProbeTests
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that shutdown stops the probe loop.</summary>
|
||||
[Fact]
|
||||
public async Task Shutdown_stops_the_probe_loop()
|
||||
{
|
||||
|
||||
@@ -15,7 +15,15 @@ public sealed class ModbusProtocolOptionsTests
|
||||
private sealed class CapturingTransport : IModbusTransport
|
||||
{
|
||||
public readonly List<byte[]> Sent = new();
|
||||
/// <summary>Asynchronously connects the transport.</summary>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A completed task.</returns>
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
/// <summary>Asynchronously sends a Modbus PDU and returns a response.</summary>
|
||||
/// <param name="unitId">The Modbus unit ID.</param>
|
||||
/// <param name="pdu">The protocol data unit to send.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task that returns the response bytes.</returns>
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
Sent.Add(pdu);
|
||||
@@ -45,9 +53,12 @@ public sealed class ModbusProtocolOptionsTests
|
||||
return Task.FromResult(new byte[] { pdu[0], 0, 0 });
|
||||
}
|
||||
}
|
||||
/// <summary>Asynchronously disposes the transport.</summary>
|
||||
/// <returns>A completed task.</returns>
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that defaults match historical behavior.</summary>
|
||||
[Fact]
|
||||
public void Defaults_Match_Historical_Behaviour()
|
||||
{
|
||||
@@ -58,6 +69,7 @@ public sealed class ModbusProtocolOptionsTests
|
||||
opts.DisableFC23.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that single coil write uses FC05 by default.</summary>
|
||||
[Fact]
|
||||
public async Task Single_Coil_Write_Uses_FC05_By_Default()
|
||||
{
|
||||
@@ -71,6 +83,7 @@ public sealed class ModbusProtocolOptionsTests
|
||||
fake.Sent.Last()[0].ShouldBe((byte)0x05); // FC05 Write Single Coil
|
||||
}
|
||||
|
||||
/// <summary>Verifies that single coil write uses FC15 when forced.</summary>
|
||||
[Fact]
|
||||
public async Task Single_Coil_Write_Uses_FC15_When_Forced()
|
||||
{
|
||||
@@ -85,6 +98,7 @@ public sealed class ModbusProtocolOptionsTests
|
||||
fake.Sent.Last()[0].ShouldBe((byte)0x0F); // FC15 Write Multiple Coils
|
||||
}
|
||||
|
||||
/// <summary>Verifies that single register write uses FC06 by default.</summary>
|
||||
[Fact]
|
||||
public async Task Single_Register_Write_Uses_FC06_By_Default()
|
||||
{
|
||||
@@ -98,6 +112,7 @@ public sealed class ModbusProtocolOptionsTests
|
||||
fake.Sent.Last()[0].ShouldBe((byte)0x06); // FC06 Write Single Register
|
||||
}
|
||||
|
||||
/// <summary>Verifies that single register write uses FC16 when forced.</summary>
|
||||
[Fact]
|
||||
public async Task Single_Register_Write_Uses_FC16_When_Forced()
|
||||
{
|
||||
@@ -112,6 +127,7 @@ public sealed class ModbusProtocolOptionsTests
|
||||
fake.Sent.Last()[0].ShouldBe((byte)0x10); // FC16 Write Multiple Registers
|
||||
}
|
||||
|
||||
/// <summary>Verifies that coil array read automatically chunks at MaxCoilsPerRead.</summary>
|
||||
[Fact]
|
||||
public async Task Coil_Array_Read_Auto_Chunks_At_MaxCoilsPerRead()
|
||||
{
|
||||
|
||||
@@ -20,7 +20,13 @@ public sealed class ModbusSubscribeOptionsTests
|
||||
public ushort CurrentValue;
|
||||
public int WritesSent;
|
||||
public int FC06Count;
|
||||
/// <summary>Establishes a connection (no-op for testing).</summary>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
/// <summary>Sends a Modbus PDU and returns a response.</summary>
|
||||
/// <param name="unitId">The Modbus unit ID.</param>
|
||||
/// <param name="pdu">The protocol data unit to send.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
switch (pdu[0])
|
||||
@@ -45,9 +51,11 @@ public sealed class ModbusSubscribeOptionsTests
|
||||
return Task.FromResult(new byte[] { pdu[0], 0, 0 });
|
||||
}
|
||||
}
|
||||
/// <summary>Disposes the transport (no-op for testing).</summary>
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that deadband filter suppresses changes below the threshold.</summary>
|
||||
[Fact]
|
||||
public async Task Deadband_Suppresses_SubThreshold_Changes()
|
||||
{
|
||||
@@ -88,6 +96,7 @@ public sealed class ModbusSubscribeOptionsTests
|
||||
publishes.ShouldNotContain((short)107);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that null deadband publishes every value change.</summary>
|
||||
[Fact]
|
||||
public async Task Deadband_Null_Publishes_Every_Change()
|
||||
{
|
||||
@@ -112,6 +121,7 @@ public sealed class ModbusSubscribeOptionsTests
|
||||
publishes.ShouldContain((short)101);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that WriteOnChangeOnly suppresses writes with identical repeated values.</summary>
|
||||
[Fact]
|
||||
public async Task WriteOnChangeOnly_Suppresses_Identical_Repeated_Writes()
|
||||
{
|
||||
@@ -130,6 +140,7 @@ public sealed class ModbusSubscribeOptionsTests
|
||||
fake.WritesSent.ShouldBe(2, "two distinct values written; identical-value repeats suppressed");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that WriteOnChangeOnly defaults to false and writes all values.</summary>
|
||||
[Fact]
|
||||
public async Task WriteOnChangeOnly_Default_False_Always_Writes()
|
||||
{
|
||||
@@ -147,6 +158,7 @@ public sealed class ModbusSubscribeOptionsTests
|
||||
fake.WritesSent.ShouldBe(3, "default false → every write goes to the wire");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that external reads invalidate the WriteOnChangeOnly cache.</summary>
|
||||
[Fact]
|
||||
public async Task WriteOnChangeOnly_Cache_Invalidated_By_Read_Divergence()
|
||||
{
|
||||
|
||||
@@ -14,11 +14,18 @@ public sealed class ModbusSubscriptionTests
|
||||
/// (Read Holding Registers) path is used. Mutating <see cref="HoldingRegisters"/>
|
||||
/// between polls is how each test simulates a PLC value change.
|
||||
/// </summary>
|
||||
/// <summary>Lightweight fake Modbus transport for testing subscriptions.</summary>
|
||||
private sealed class FakeTransport : IModbusTransport
|
||||
{
|
||||
public readonly ushort[] HoldingRegisters = new ushort[256];
|
||||
/// <summary>Simulates connecting to the Modbus device.</summary>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
/// <summary>Simulates sending a Modbus PDU.</summary>
|
||||
/// <param name="unitId">The Modbus unit ID.</param>
|
||||
/// <param name="pdu">The protocol data unit.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
if (pdu[0] != 0x03) return Task.FromException<byte[]>(new NotSupportedException("FC not supported"));
|
||||
@@ -34,6 +41,7 @@ public sealed class ModbusSubscriptionTests
|
||||
}
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
/// <summary>Disposes the transport asynchronously.</summary>
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -44,6 +52,7 @@ public sealed class ModbusSubscriptionTests
|
||||
return (new ModbusDriver(opts, "modbus-1", _ => fake), fake);
|
||||
}
|
||||
|
||||
/// <summary>Verifies initial poll raises OnDataChange for every subscribed tag.</summary>
|
||||
[Fact]
|
||||
public async Task Initial_poll_raises_OnDataChange_for_every_subscribed_tag()
|
||||
{
|
||||
@@ -65,6 +74,7 @@ public sealed class ModbusSubscriptionTests
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Verifies unchanged values do not raise after initial poll.</summary>
|
||||
[Fact]
|
||||
public async Task Unchanged_values_do_not_raise_after_initial_poll()
|
||||
{
|
||||
@@ -82,6 +92,7 @@ public sealed class ModbusSubscriptionTests
|
||||
events.Count.ShouldBe(1); // only the initial-data push, no change events after
|
||||
}
|
||||
|
||||
/// <summary>Verifies value change between polls raises OnDataChange.</summary>
|
||||
[Fact]
|
||||
public async Task Value_change_between_polls_raises_OnDataChange()
|
||||
{
|
||||
@@ -102,6 +113,7 @@ public sealed class ModbusSubscriptionTests
|
||||
events.Last().Snapshot.Value.ShouldBe((short)200);
|
||||
}
|
||||
|
||||
/// <summary>Verifies unsubscribe stops the polling loop.</summary>
|
||||
[Fact]
|
||||
public async Task Unsubscribe_stops_the_polling_loop()
|
||||
{
|
||||
@@ -121,6 +133,7 @@ public sealed class ModbusSubscriptionTests
|
||||
events.Count.ShouldBe(countAfterUnsub);
|
||||
}
|
||||
|
||||
/// <summary>Verifies SubscribeAsync floors intervals below 100ms.</summary>
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_floors_intervals_below_100ms()
|
||||
{
|
||||
@@ -140,6 +153,7 @@ public sealed class ModbusSubscriptionTests
|
||||
events.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Verifies multiple subscriptions fire independently.</summary>
|
||||
[Fact]
|
||||
public async Task Multiple_subscriptions_fire_independently()
|
||||
{
|
||||
|
||||
@@ -22,11 +22,14 @@ public sealed class ModbusTcpReconnectTests
|
||||
private sealed class FlakeyModbusServer : IAsyncDisposable
|
||||
{
|
||||
private readonly TcpListener _listener;
|
||||
/// <summary>Gets the TCP port the server is listening on.</summary>
|
||||
public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
|
||||
/// <summary>Gets or sets the number of transactions to complete before closing the connection.</summary>
|
||||
public int DropAfterNTransactions { get; set; } = int.MaxValue;
|
||||
private readonly CancellationTokenSource _stop = new();
|
||||
private int _txCount;
|
||||
|
||||
/// <summary>Initializes a new instance and starts listening on a loopback port.</summary>
|
||||
public FlakeyModbusServer()
|
||||
{
|
||||
_listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
@@ -103,6 +106,7 @@ public sealed class ModbusTcpReconnectTests
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Stops the server and releases resources.</summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_stop.Cancel();
|
||||
@@ -111,6 +115,7 @@ public sealed class ModbusTcpReconnectTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the transport recovers from a mid-session socket drop with auto-reconnect enabled.</summary>
|
||||
[Fact]
|
||||
public async Task Transport_recovers_from_mid_session_drop_and_retries_successfully()
|
||||
{
|
||||
@@ -130,6 +135,7 @@ public sealed class ModbusTcpReconnectTests
|
||||
second[0].ShouldBe((byte)0x03);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that socket drops propagate to the caller when auto-reconnect is disabled.</summary>
|
||||
[Fact]
|
||||
public async Task Transport_without_AutoReconnect_propagates_drop_to_caller()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user